// 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, RepoRef } from "./proposals"; import { diffUri, refOf } from "./diff"; 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; /** Threads in diff editors, per proposal (`base/owner/name#n`). */ private readonly diffThreads = new Map(); constructor(private readonly auth: HubAuth) { this.controller.commentingRangeProvider = { provideCommentingRanges: (document) => { const whole = [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; // In a proposal's diff, only the projected side takes comments. const r = refOf(document.uri); if (r) { return r.side === "head" ? whole : []; } return this.active && document.uri.fsPath.startsWith(this.active.repo.handle.root) ? whole : []; }, }; } /** * Show a proposal's threads in its diff documents: every anchored * comment on the projected side, at the line the hub places it. */ async loadDiff(repo: RepoRef, number: number): Promise { const key = `${repo.base}/${repo.owner}/${repo.name}#${number}`; this.diffThreads.get(key)?.forEach((t) => t.dispose()); const comments = await this.auth.clientFor(repo.base).proposalComments(repo.owner, repo.name, number); const byLine = new Map(); for (const c of comments) { if (c.path && c.line) { const k = `${c.path}\n${c.line}`; byLine.set(k, [...(byLine.get(k) ?? []), c]); } } const threads: vscode.CommentThread[] = []; for (const [k, cs] of byLine) { const [path, line] = k.split("\n"); const thread = this.controller.createCommentThread(diffUri(repo, number, "head", path), new vscode.Range(Number(line) - 1, 0, Number(line) - 1, 0), cs.map(toComment)); thread.label = cs.every((c) => c.resolved) ? "resolved" : "review"; thread.state = cs.every((c) => c.resolved) ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; threads.push(thread); } this.diffThreads.set(key, threads); } /** 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 r = refOf(reply.thread.uri); if (r) { const line = (reply.thread.range?.start.line ?? 0) + 1; const repo: RepoRef = { base: r.base, owner: r.owner, name: r.name }; try { await postLineComment(this.auth, repo, r.number, r.path, line, reply.text); } catch (e) { void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); return; } // A new thread from the gutter is replaced by the hub's copy. if (!reply.thread.comments.length) { reply.thread.dispose(); } await this.loadDiff(repo, r.number); return; } 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 resolves the line to its atom, so the thread follows the line through rebases. 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.diffThreads.forEach((ts) => ts.forEach((t) => t.dispose())); this.diffThreads.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) }; } /** A comment anchored to the atom at `path:line` of the projected state. */ async function postLineComment(auth: HubAuth, repo: RepoRef, number: number, path: string, line: number, body: string): Promise { await auth.clientFor(repo.base).commentProposal(repo.owner, repo.name, number, body, path, line); }