// // Pull request lifecycle: overview, diff, changed files, checkout, create, // merge, close. Nova has no webview or programmatic diff editor, so the // overview renders as Markdown and diffs open as .diff documents. // const git = require("../util/git.js"); const nodes = require("../views/nodes.js"); const selection = require("./selection.js"); const time = require("../util/time.js"); const ui = require("../ui.js"); const MERGE_STRATEGIES = [ { value: "merge", label: "Create a merge commit" }, { value: "rebase", label: "Rebase then fast-forward" }, { value: "rebase-merge", label: "Rebase then create a merge commit" }, { value: "squash", label: "Squash and merge" }, ]; async function contextFrom(argument, store) { const node = selection.selectedNode(argument); const pull = selection.pullRequestFor(node); const repo = selection.repoOf(node); if (pull && repo) return { node, repo, pull }; // Fall back to picking from the open pull requests of a chosen repository. const chosen = repo || (await selection.resolveRepo(argument)); if (!chosen) return { node, repo: null, pull: null }; const items = store.pullRequestsFor(chosen.key).items; if (!items.length) { ui.info(`No open pull requests in ${chosen.fullName}.`); return { node, repo: chosen, pull: null }; } const picked = items.length === 1 ? items[0] : await ui.choose(items, { placeholder: "Choose a pull request", label: (item) => `#${item.number} ${item.title}`, }); return { node, repo: chosen, pull: picked }; } function renderOverview(repo, pull, detail, timeline) { const lines = []; lines.push(`# #${pull.number} ${pull.title}`); lines.push(""); const facts = [ `- **Repository:** ${repo.fullName}`, `- **Author:** ${pull.user ? pull.user.login : "unknown"}`, `- **State:** ${pull.merged ? "merged" : pull.state}${pull.draft ? " (draft)" : ""}`, pull.head && pull.base ? `- **Branches:** \`${pull.head.ref}\` → \`${pull.base.ref}\`` : null, `- **Updated:** ${time.absolute(pull.updated_at)} (${time.relative(pull.updated_at)})`, pull.mergeable === false ? "- **Conflicts:** this pull request cannot be merged cleanly" : null, `- **URL:** ${pull.html_url}`, ].filter(Boolean); lines.push(...facts, ""); if (pull.body && pull.body.trim()) { lines.push("## Description", "", pull.body.trim(), ""); } if (detail && detail.reviews.length) { lines.push("## Reviews", ""); for (const review of detail.reviews) { const author = review.user ? review.user.login : "someone"; lines.push( `### ${nodes.reviewGlyph(review.state)} ${author} — ${String( review.state || "", ).toLowerCase()} · ${time.relative(review.submitted_at || review.updated_at)}`, ); if (review.body && review.body.trim()) lines.push("", review.body.trim()); const own = detail.comments.filter( (comment) => comment.pull_request_review_id === review.id, ); for (const comment of own) { lines.push( "", `- \`${comment.path}${comment.position ? `:${comment.position}` : ""}\` — ${ (comment.body || "").trim() || "(empty)" }`, ); } lines.push(""); } } if (timeline && timeline.length) { lines.push("## Timeline", ""); for (const entry of timeline) { const author = entry.user ? entry.user.login : "someone"; const when = time.relative(entry.created_at); const kind = entry.type || "comment"; const body = (entry.body || "").trim(); lines.push(`- **${author}** ${kind} · ${when}${body ? `\n > ${body.replace(/\n/g, "\n > ")}` : ""}`); } lines.push(""); } if (detail && detail.files.length) { lines.push("## Changed Files", ""); for (const file of detail.files) { lines.push( `- \`${file.filename}\` — ${file.status}, +${file.additions || 0} −${ file.deletions || 0 }`, ); } lines.push(""); } return lines.join("\n"); } function register(store, refresh) { nova.commands.register("gitea.openPullRequestOverview", async (argument) => { const { repo, pull } = await contextFrom(argument, store); if (!repo || !pull) return; const detail = store.detailFor(repo.key, pull.number) || (await store.loadPullRequestDetail(repo, pull)); const timeline = await store .apiFor(repo) .timeline(repo, pull.number) .catch(() => []); await ui.openText(renderOverview(repo, pull, detail, timeline), { syntax: "markdown" }); }); nova.commands.register("gitea.openPullRequestDiff", async (argument) => { const { repo, pull } = await contextFrom(argument, store); if (!repo || !pull) return; let diff; try { diff = await store.apiFor(repo).pullRequestDiff(repo, pull.number); } catch (error) { ui.error(`Could not fetch the diff: ${error.message || error}`); return; } if (!diff || !diff.trim()) { ui.info(`Pull request #${pull.number} has no textual diff.`); return; } const path = repo.dir ? nova.path.join(repo.dir, ".tmp", "gitea-diffs", `pr-${pull.number}.diff`) : null; await ui.openText(diff, { path: path, syntax: "diff" }); }); nova.commands.register("gitea.openChangedFile", async (argument) => { const node = selection.selectedNode(argument); const file = node && node.data ? node.data.file : null; const repo = selection.repoOf(node); const pull = selection.pullRequestFor(node); if (!file || !repo || !pull) { ui.warn("Select a changed file in the Gitea sidebar first."); return; } // Prefer the working copy when the branch is checked out locally. if (repo.dir) { const local = nova.path.join(repo.dir, file.filename); if (nova.fs.access(local, nova.fs.F_OK)) { await nova.workspace.openFile(local); return; } } try { const diff = await store.apiFor(repo).pullRequestDiff(repo, pull.number); const section = extractFileDiff(diff, file.filename); await ui.openText(section || diff, { syntax: "diff" }); } catch (error) { ui.error(`Could not open ${file.filename}: ${error.message || error}`); } }); nova.commands.register("gitea.checkoutPrBranch", async (argument) => { const { repo, pull } = await contextFrom(argument, store); if (!repo || !pull) return; if (!repo.dir) { ui.warn( `${repo.fullName} is not checked out in this workspace, so its branch cannot be switched.`, ); return; } if (!pull.head || !pull.head.ref) { ui.warn("This pull request does not report a head branch."); return; } const remote = repo.remoteName || "origin"; const failure = await git.checkoutBranch(repo.dir, pull.head.ref, remote); if (failure) { ui.error(`Could not check out ${pull.head.ref}: ${failure}`); return; } ui.info(`Checked out ${pull.head.ref}.`); await refresh({ rediscover: true }); }); nova.commands.register("gitea.createPullRequest", async (argument) => { const repo = await selection.resolveRepo(argument, { prompt: "Create a pull request in which repository?", }); if (!repo) return; const api = store.apiFor(repo); let branches = []; try { branches = (await api.branches(repo)).map((branch) => branch.name); } catch (error) { ui.error(`Could not list branches: ${error.message || error}`); return; } if (!branches.length) { ui.warn(`${repo.fullName} has no branches to open a pull request from.`); return; } const current = repo.dir ? await git.currentBranch(repo.dir) : null; const headChoices = current ? [current, ...branches.filter((name) => name !== current)] : branches; const head = await ui.choose(headChoices, { placeholder: "Source branch (head)" }); if (!head) return; const baseChoices = branches.filter((name) => name !== head); const base = await ui.choose(baseChoices, { placeholder: "Target branch (base)" }); if (!base) return; const title = await ui.input("Pull request title", { placeholder: `Merge ${head} into ${base}`, value: head.replace(/[-_/]+/g, " "), }); if (!title) return; const body = await ui.input("Description (optional)", { placeholder: "Markdown" }); try { const created = await api.createPullRequest(repo, { head: head, base: base, title: title, body: body || "", }); ui.info(`Created #${created.number} in ${repo.fullName}.`); if (created.html_url && (await ui.confirm("Open it in the browser?", "Open"))) { nova.openURL(created.html_url); } } catch (error) { ui.error(`Could not create the pull request: ${error.message || error}`); return; } await refresh(); }); nova.commands.register("gitea.mergePullRequest", async (argument) => { const { repo, pull } = await contextFrom(argument, store); if (!repo || !pull) return; if (pull.mergeable === false) { ui.warn(`#${pull.number} has conflicts and cannot be merged.`); return; } const strategy = await ui.choose(MERGE_STRATEGIES, { placeholder: `Merge #${pull.number} how?`, label: (entry) => entry.label, }); if (!strategy) return; if (!(await ui.confirm(`Merge #${pull.number} (${strategy.label})?`, "Merge"))) return; try { await store.apiFor(repo).mergePullRequest(repo, pull.number, { strategy: strategy.value, title: pull.title, }); ui.info(`Merged #${pull.number}.`); } catch (error) { ui.error(`Could not merge #${pull.number}: ${error.message || error}`); return; } await refresh(); }); nova.commands.register("gitea.closePullRequest", async (argument) => { const { repo, pull } = await contextFrom(argument, store); if (!repo || !pull) return; if (!(await ui.confirm(`Close #${pull.number} without merging?`, "Close"))) return; try { await store.apiFor(repo).closePullRequest(repo, pull.number); ui.info(`Closed #${pull.number}.`); } catch (error) { ui.error(`Could not close #${pull.number}: ${error.message || error}`); return; } await refresh(); }); } /** Slices the hunks for one file out of a unified diff. */ function extractFileDiff(diff, filename) { if (!diff) return null; const lines = diff.split("\n"); const start = lines.findIndex( (line) => line.startsWith("diff --git ") && line.includes(` b/${filename}`), ); if (start === -1) return null; let end = lines.length; for (let index = start + 1; index < lines.length; index += 1) { if (lines[index].startsWith("diff --git ")) { end = index; break; } } return lines.slice(start, end).join("\n"); } exports.register = register; exports.extractFileDiff = extractFileDiff; exports.renderOverview = renderOverview;