// // Node builders shared by the Current Branch, Workflows, and Pull Requests // sections so the same run, job, and pull request rows behave identically // wherever they appear. // const nodes = require("./nodes.js"); const time = require("../util/time.js"); const { isActiveRun } = require("../store.js"); const { node, message } = nodes; function stepNode(job, step, index) { const state = nodes.runState(step); return node("step", `${nodes.glyphFor(state)} ${step.name || `Step ${index + 1}`}`, { identifier: `step-${job.id}-${step.number || index}`, description: time.duration(step.started_at, step.completed_at), tooltip: `${step.name || ""}\nStatus: ${state}`, contextValue: "step", }); } function jobNode(store, repo, run, job) { const state = nodes.runState(job); const steps = Array.isArray(job.steps) ? job.steps : []; return node("job", `${nodes.glyphFor(state)} ${job.name || `Job ${job.id}`}`, { identifier: `job-${repo.key}-${job.id}`, description: nodes.timingText(job), tooltip: [ job.name, `Status: ${state}`, job.runner_name ? `Runner: ${job.runner_name}` : null, "Double-click to open logs", ] .filter(Boolean) .join("\n"), contextValue: "job", command: "gitea.viewJobLogs", repo: repo, data: { job: job, run: run, repoKey: repo.key }, children: steps.length ? steps.map((step, index) => stepNode(job, step, index)) : null, }); } function artifactNode(store, repo, run, artifact) { const size = artifact.size_in_bytes ? formatBytes(artifact.size_in_bytes) : ""; const expired = artifact.expired ? " · expired" : ""; return node("artifact", `⬇ ${artifact.name}`, { identifier: `artifact-${repo.key}-${artifact.id}`, description: `${size}${expired}`, tooltip: [ artifact.name, size ? `Size: ${size}` : null, artifact.expired ? "This artifact has expired." : null, "Double-click to download", ] .filter(Boolean) .join("\n"), contextValue: "artifact", command: "gitea.downloadArtifact", repo: repo, data: { artifact: artifact, run: run, repoKey: repo.key }, }); } function formatBytes(bytes) { const units = ["B", "KB", "MB", "GB"]; let value = Number(bytes); let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`; } /** A run row. Jobs and artifacts load only when the row is expanded. */ function runNode(store, repo, run, { showBranch = true } = {}) { const state = nodes.runState(run); const subtitle = []; if (showBranch && run.head_branch) subtitle.push(run.head_branch); if (run.event) subtitle.push(run.event); const timing = nodes.timingText(run); if (timing) subtitle.push(timing); return node("run", `${nodes.glyphFor(state)} ${nodes.runTitle(run)}`, { identifier: `run-${repo.key}-${run.id}`, description: subtitle.join(" · "), tooltip: [ nodes.runTitle(run), `Status: ${state}`, run.head_branch ? `Branch: ${run.head_branch}` : null, run.head_sha ? `Commit: ${String(run.head_sha).slice(0, 8)}` : null, run.run_number ? `Run #${run.run_number}` : null, run.actor && run.actor.login ? `Actor: ${run.actor.login}` : null, ] .filter(Boolean) .join("\n"), contextValue: "run", repo: repo, data: { run: run, repoKey: repo.key }, load: async () => { // Reuse cached jobs so a periodic tree reload does not refetch every // expanded run; a run that is still going always refreshes. const cachedJobs = store.jobsFor(repo.key, run.id); const active = isActiveRun(run); const detail = cachedJobs && !active ? { jobs: cachedJobs, artifacts: store.artifactsFor(repo.key, run.id) || [] } : await store.loadRunDetail(repo, run); if (!detail) return [message("Unable to load run details.")]; const children = detail.jobs.map((job) => jobNode(store, repo, run, job)); if (!children.length) children.push(message("No jobs reported.")); if (detail.artifacts.length) { children.push( node("artifactsFolder", "Artifacts", { identifier: `artifacts-${repo.key}-${run.id}`, description: String(detail.artifacts.length), contextValue: "artifactsFolder", repo: repo, children: detail.artifacts.map((artifact) => artifactNode(store, repo, run, artifact), ), }), ); } return children; }, }); } function reviewCommentNode(store, repo, pull, comment) { const author = (comment.user && comment.user.login) || "someone"; const location = comment.path ? `${comment.path}${comment.position ? `:${comment.position}` : ""}` : ""; const resolved = comment.resolver && comment.resolver.login ? "✓ " : ""; return node("comment", `${resolved}${author}: ${firstLine(comment.body)}`, { identifier: `comment-${comment.id}`, description: location, tooltip: [location, comment.body, comment.diff_hunk].filter(Boolean).join("\n\n"), contextValue: "comment", repo: repo, command: "gitea.openReviewComment", data: { comment: comment, pull: pull, repoKey: repo.key }, }); } function reviewNode(store, repo, pull, review, comments) { const author = (review.user && review.user.login) || "someone"; const own = comments.filter((comment) => comment.pull_request_review_id === review.id); return node("review", `${nodes.reviewGlyph(review.state)} ${author}`, { identifier: `review-${review.id}`, description: `${String(review.state || "").toLowerCase()} · ${time.relative( review.submitted_at || review.updated_at, )}`, tooltip: [review.body, `State: ${review.state}`].filter(Boolean).join("\n\n"), contextValue: "review", repo: repo, data: { review: review, pull: pull, repoKey: repo.key }, children: own.length ? own.map((comment) => reviewCommentNode(store, repo, pull, comment)) : null, }); } function changedFileNode(store, repo, pull, file) { const marker = file.status === "added" ? "+" : file.status === "deleted" ? "−" : file.status === "renamed" ? "→" : "±"; return node("prFile", `${marker} ${file.filename}`, { identifier: `prfile-${pull.number}-${file.filename}`, description: `+${file.additions || 0} −${file.deletions || 0}`, tooltip: `${file.filename}\nStatus: ${file.status}`, contextValue: "prFile", repo: repo, command: "gitea.openChangedFile", data: { file: file, pull: pull, repoKey: repo.key }, }); } /** A pull request row; reviews, comments, and files load on expand. */ function pullRequestNode(store, repo, pull, { showRepo = false } = {}) { const subtitle = []; if (showRepo) subtitle.push(repo.fullName); if (pull.head && pull.head.ref) subtitle.push(`${pull.head.ref} → ${pull.base.ref}`); subtitle.push(time.relative(pull.updated_at)); return node("pr", `${nodes.pullRequestGlyph(pull)} #${pull.number} ${pull.title}`, { identifier: `pr-${repo.key}-${pull.number}`, description: subtitle.filter(Boolean).join(" · "), tooltip: [ `#${pull.number} ${pull.title}`, pull.user ? `Author: ${pull.user.login}` : null, pull.head && pull.base ? `${pull.head.label} → ${pull.base.label}` : null, pull.mergeable === false ? "Has conflicts" : null, pull.draft ? "Draft" : null, ] .filter(Boolean) .join("\n"), contextValue: "pr", repo: repo, data: { pull: pull, repoKey: repo.key }, load: async () => { // Cached detail is reused until the pull request itself changes. const cached = store.detailFor(repo.key, pull.number); const fresh = cached && cached.loadedAt >= new Date(pull.updated_at || 0) ? cached : null; const detail = fresh || (await store.loadPullRequestDetail(repo, pull)); if (!detail) return [message("Unable to load pull request details.")]; const children = []; if (detail.reviews.length) { children.push( node("reviewsFolder", "Reviews", { identifier: `reviews-${repo.key}-${pull.number}`, description: String(detail.reviews.length), contextValue: "reviewsFolder", repo: repo, children: detail.reviews.map((review) => reviewNode(store, repo, pull, review, detail.comments), ), }), ); } const orphans = detail.comments.filter( (comment) => !detail.reviews.some( (review) => review.id === comment.pull_request_review_id, ), ); if (orphans.length) { children.push( node("commentsFolder", "Comments", { identifier: `comments-${repo.key}-${pull.number}`, description: String(orphans.length), contextValue: "commentsFolder", repo: repo, children: orphans.map((comment) => reviewCommentNode(store, repo, pull, comment), ), }), ); } if (detail.files.length) { children.push( node("filesFolder", "Changed Files", { identifier: `files-${repo.key}-${pull.number}`, description: String(detail.files.length), contextValue: "filesFolder", repo: repo, children: detail.files.map((file) => changedFileNode(store, repo, pull, file), ), }), ); } return children.length ? children : [message("No reviews or changed files.")]; }, }); } /** * Rows for remote hosts that matched no configured instance. This is what an * instance serving SSH from a different hostname than the web UI looks like, * so the row offers the fix directly. */ function unmatchedHostNodes(store) { return store.unmatchedHosts.map((entry) => node("unmatchedHost", `\u26A0 No Gitea instance for ${entry.host}`, { identifier: `unmatched-${entry.host}`, description: "add a host alias", tooltip: [ entry.remoteUrl, "", "This remote's host matches no configured Gitea instance.", "If your server answers SSH on a different hostname than its web", "UI, map one onto the other with an SSH host alias.", ].join("\n"), contextValue: "unmatchedHost", command: "gitea.addHostAlias", data: { host: entry.host, remoteUrl: entry.remoteUrl }, }), ); } function firstLine(text) { const line = String(text || "").split("\n").find((entry) => entry.trim()); if (!line) return "(empty comment)"; return line.length > 80 ? `${line.slice(0, 77)}…` : line.trim(); } exports.runNode = runNode; exports.jobNode = jobNode; exports.artifactNode = artifactNode; exports.pullRequestNode = pullRequestNode; exports.reviewCommentNode = reviewCommentNode; exports.changedFileNode = changedFileNode; exports.unmatchedHostNodes = unmatchedHostNodes; exports.formatBytes = formatBytes; exports.firstLine = firstLine;