// // Reviewing pull requests: submitting approvals and change requests, adding a // comment anchored to the line under the cursor, replying, and resolving. // const config = require("../util/config.js"); const selection = require("./selection.js"); const ui = require("../ui.js"); /** * Converts a review comment's diff hunk into a line number in the new file. * The commented line is the last line of the hunk, so counting forward from the * hunk header gives its position. */ function lineFromDiffHunk(diffHunk, fallback) { if (!diffHunk) return fallback || null; const lines = diffHunk.split("\n"); let headerIndex = -1; let start = null; for (let index = lines.length - 1; index >= 0; index -= 1) { const match = lines[index].match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); if (match) { headerIndex = index; start = Number(match[1]); break; } } if (headerIndex === -1 || start === null) return fallback || null; let line = start - 1; for (let index = headerIndex + 1; index < lines.length; index += 1) { // Removed lines do not exist in the new file, so they do not advance it. if (!lines[index].startsWith("-")) line += 1; } return Math.max(1, line); } /** 1-based line number of the start of the editor's current selection. */ function selectedLine(editor) { const range = editor.selectedRange; const before = editor.getTextInRange(new Range(0, range.start)); return before.split("\n").length; } /** The repository whose checkout contains `path`, if any. */ function repoContaining(store, path) { if (!path) return null; let best = null; for (const repo of store.repos) { if (!repo.dir) continue; if (path === repo.dir || path.startsWith(repo.dir + "/")) { if (!best || repo.dir.length > best.dir.length) best = repo; } } return best; } function relativePath(repo, path) { return path.slice(repo.dir.length).replace(/^\/+/, ""); } /** Open pull requests whose head branch is currently checked out in `repo`. */ function pullRequestsForCheckout(store, repo) { const branch = repo.branch; const items = store.pullRequestsFor(repo.key).items; if (!branch) return items; const matching = items.filter((pull) => pull.head && pull.head.ref === branch); return matching.length ? matching : items; } async function submit(store, refresh, repo, pull, event, prompt) { let body = ""; if (prompt) { const entered = await ui.input(prompt, { placeholder: "Markdown" }); if (entered === null) return; body = entered; } if (event === "REQUEST_CHANGES" && !body.trim()) { ui.warn("Gitea requires a message when requesting changes."); return; } try { await store.apiFor(repo).submitReview(repo, pull.number, { event: event, body: body }); } catch (error) { const hint = error.status === 403 ? " The token needs pull request write access." : ""; ui.error(`Could not submit the review: ${error.message || error}${hint}`); return; } ui.info(`Review submitted on #${pull.number}.`); store.pullRequestDetail.delete(`${repo.key}:${pull.number}`); await refresh(); } async function pickPullRequest(store, argument) { const node = selection.selectedNode(argument); const pull = selection.pullRequestFor(node); const repo = selection.repoOf(node); if (pull && repo) return { repo, pull }; const chosen = repo || (await selection.resolveRepo(argument)); if (!chosen) return { repo: null, pull: null }; const items = store.pullRequestsFor(chosen.key).items; if (!items.length) { ui.info(`No open pull requests in ${chosen.fullName}.`); return { repo: chosen, pull: null }; } const picked = items.length === 1 ? items[0] : await ui.choose(items, { placeholder: "Review which pull request?", label: (item) => `#${item.number} ${item.title}`, }); return { repo: chosen, pull: picked }; } function register(store, refresh, reviewComments) { nova.commands.register("gitea.reviewApprove", async (argument) => { const { repo, pull } = await pickPullRequest(store, argument); if (!repo || !pull) return; await submit(store, refresh, repo, pull, "APPROVED", `Approve #${pull.number} (optional message)`); }); nova.commands.register("gitea.reviewRequestChanges", async (argument) => { const { repo, pull } = await pickPullRequest(store, argument); if (!repo || !pull) return; await submit( store, refresh, repo, pull, "REQUEST_CHANGES", `What should change in #${pull.number}?`, ); }); nova.commands.register("gitea.reviewComment", async (argument) => { const { repo, pull } = await pickPullRequest(store, argument); if (!repo || !pull) return; await submit(store, refresh, repo, pull, "COMMENT", `Comment on #${pull.number}`); }); // Invoked from the Editor menu: comments on the line under the cursor. nova.commands.register("gitea.addReviewComment", async (editor) => { const target = editor && editor.document ? editor : nova.workspace.activeTextEditor; if (!target || !target.document || !target.document.path) { ui.warn("Open a saved file in the repository first."); return; } const repo = repoContaining(store, target.document.path); if (!repo) { ui.warn("This file is not inside a discovered Gitea repository."); return; } const candidates = pullRequestsForCheckout(store, repo); if (!candidates.length) { ui.info(`No open pull requests in ${repo.fullName}.`); return; } const pull = candidates.length === 1 ? candidates[0] : await ui.choose(candidates, { placeholder: "Comment on which pull request?", label: (item) => `#${item.number} ${item.title}`, }); if (!pull) return; const path = relativePath(repo, target.document.path); const line = selectedLine(target); const body = await ui.input(`Comment on ${path}:${line}`, { placeholder: "Markdown" }); if (!body) return; try { await store.apiFor(repo).submitReview(repo, pull.number, { event: "COMMENT", body: "", comments: [{ path: path, body: body, new_position: line }], }); } catch (error) { const hint = error.status === 403 ? " The token needs pull request write access." : ""; ui.error(`Could not add the comment: ${error.message || error}${hint}`); return; } ui.info(`Commented on ${path}:${line} in #${pull.number}.`); store.pullRequestDetail.delete(`${repo.key}:${pull.number}`); await refresh(); if (reviewComments) await reviewComments.reload(); }); nova.commands.register("gitea.replyToReviewComment", async (argument) => { const node = selection.selectedNode(argument); const comment = node && node.data ? node.data.comment : null; const repo = selection.repoOf(node); const pull = selection.pullRequestFor(node); if (!comment || !repo || !pull) { ui.warn("Select a review comment first."); return; } const body = await ui.input("Reply", { placeholder: "Markdown" }); if (!body) return; try { await store.apiFor(repo).replyToReviewComment(repo, pull.number, comment.id, body); } catch (error) { ui.error(`Could not post the reply: ${error.message || error}`); return; } ui.info("Reply posted."); store.pullRequestDetail.delete(`${repo.key}:${pull.number}`); await refresh(); if (reviewComments) await reviewComments.reload(); }); const setResolved = async (argument, resolved) => { const node = selection.selectedNode(argument); const comment = node && node.data ? node.data.comment : null; const repo = selection.repoOf(node); const pull = selection.pullRequestFor(node); if (!comment || !repo) { ui.warn("Select a review comment first."); return; } const api = store.apiFor(repo); try { if (resolved) await api.resolveReviewThread(repo, comment.id); else await api.unresolveReviewThread(repo, comment.id); } catch (error) { ui.error( `Could not ${resolved ? "resolve" : "unresolve"} the thread: ${ error.message || error }`, ); return; } ui.info(resolved ? "Thread resolved." : "Thread reopened."); if (pull) store.pullRequestDetail.delete(`${repo.key}:${pull.number}`); await refresh(); if (reviewComments) await reviewComments.reload(); }; nova.commands.register("gitea.resolveReviewThread", (argument) => setResolved(argument, true)); nova.commands.register("gitea.unresolveReviewThread", (argument) => setResolved(argument, false)); // Jumps from a comment row to the commented line in the working copy. nova.commands.register("gitea.openReviewComment", async (argument) => { const node = selection.selectedNode(argument); const comment = node && node.data ? node.data.comment : null; const repo = selection.repoOf(node); if (!comment || !repo) return; if (!repo.dir || !comment.path) { if (comment.html_url) nova.openURL(comment.html_url); return; } const path = nova.path.join(repo.dir, comment.path); if (!nova.fs.access(path, nova.fs.F_OK)) { if (comment.html_url) nova.openURL(comment.html_url); return; } const line = lineFromDiffHunk(comment.diff_hunk, comment.position); await nova.workspace.openFile(path, line ? { line: line } : undefined); }); nova.commands.register("gitea.switchBranchFilter", async () => { const options = [ { mode: "current", label: "Checked-out branch" }, { mode: "all", label: "All branches" }, { mode: "custom", label: "A specific branch…" }, ]; const picked = await ui.choose(options, { placeholder: "Which branch should the Current Branch section track?", label: (option) => option.label, }); if (!picked) return; if (picked.mode === "custom") { const current = config.branchFilter(); const branch = await ui.input("Track which branch?", { placeholder: "e.g. main", value: current.branch || "", }); if (!branch) return; config.setBranchFilter("custom", branch.trim()); } else { config.setBranchFilter(picked.mode, null); } await refresh(); if (reviewComments) await reviewComments.reload(); }); } exports.register = register; exports.lineFromDiffHunk = lineFromDiffHunk; exports.repoContaining = repoContaining;