// Review threads on the checked-out proposal (spec ยง6): a comment // controller whose threads are the hub's anchored comments, placed by // asking oxy for each line's atom. import * as vscode from "vscode"; import type * as api from "./hubapi"; import { HubAuth } from "./auth"; import type { HubRepo } from "./proposals"; interface BlameLine { line: number; hash: string; index: number; } /** The hub's comments as threads on files under the repository root. */ export class ReviewController implements vscode.Disposable { private readonly controller = vscode.comments.createCommentController("oxygenhub", "Oxygenhub review"); private threads: vscode.CommentThread[] = []; private active: { repo: HubRepo; number: number } | undefined; constructor(private readonly auth: HubAuth) { this.controller.commentingRangeProvider = { provideCommentingRanges: (document) => (this.active && document.uri.fsPath.startsWith(this.active.repo.handle.root) ? [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)] : []), }; } /** Show the threads of a proposal; call after checking it out. */ async load(repo: HubRepo, number: number): Promise { this.clear(); this.active = { repo, number }; const comments = await this.auth.clientFor(repo.base).proposalComments(repo.owner, repo.name, number); const byPath = new Map(); for (const c of comments) { if (c.path && c.anchor) { byPath.set(c.path, [...(byPath.get(c.path) ?? []), c]); } } for (const [path, list] of byPath) { let blame: BlameLine[] = []; try { blame = await repo.handle.json(["blame", path, "--json"]); } catch { continue; } const uri = vscode.Uri.file(`${repo.handle.root}/${path}`); // One thread per atom. const byAtom = new Map(); for (const c of list) { const key = `${c.anchor!.change}:${c.anchor!.index}`; byAtom.set(key, [...(byAtom.get(key) ?? []), c]); } for (const [key, cs] of byAtom) { const [hash, index] = key.split(":"); const line = blame.find((b) => b.hash === hash && String(b.index) === index)?.line; if (!line) { continue; } const thread = this.controller.createCommentThread(uri, new vscode.Range(line - 1, 0, line - 1, 0), cs.map(toComment)); thread.label = cs[0].resolved ? "resolved" : "review"; thread.state = cs[0].resolved ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; this.threads.push(thread); } } } /** Post a reply to a thread, anchored to the line it sits on. */ async reply(reply: vscode.CommentReply): Promise { const a = this.active; if (!a) { return; } const rel = reply.thread.uri.fsPath.slice(a.repo.handle.root.length + 1); const line = reply.thread.range?.start.line ?? 0; // The hub anchors by line number of the projected state; send the path and line. await postLineComment(this.auth, a.repo, a.number, rel, line + 1, reply.text); await this.load(a.repo, a.number); } clear(): void { this.threads.forEach((t) => t.dispose()); this.threads = []; this.active = undefined; } dispose(): void { this.clear(); this.controller.dispose(); } } function toComment(c: api.Comment): vscode.Comment { return { body: new vscode.MarkdownString(c.body), mode: vscode.CommentMode.Preview, author: { name: c.author }, label: c.resolved ? "resolved" : undefined, timestamp: new Date(c.created_ms) }; } /** The hub's form endpoint accepts a path and line; the API accepts general comments only, so line comments go through the form with the API token as a bearer is not enough: use the API's general comment with a path marker until the API grows an anchored variant. */ async function postLineComment(auth: HubAuth, repo: HubRepo, number: number, path: string, line: number, body: string): Promise { await auth.clientFor(repo.base).commentProposal(repo.owner, repo.name, number, `${body}\n\n_on ${path}:${line}_`); }