| 1 |
// Review threads on the checked-out proposal (spec §6): a comment |
| 2 |
// controller whose threads are the hub's anchored comments, placed by |
| 3 |
// asking oxy for each line's atom. |
| 4 |
|
| 5 |
import * as vscode from "vscode"; |
| 6 |
import type * as api from "./hubapi"; |
| 7 |
import { HubAuth } from "./auth"; |
| 8 |
import type { HubRepo, RepoRef } from "./proposals"; |
| 9 |
import { diffUri, refOf } from "./diff"; |
| 10 |
|
| 11 |
interface BlameLine { |
| 12 |
line: number; |
| 13 |
hash: string; |
| 14 |
index: number; |
| 15 |
} |
| 16 |
|
| 17 |
/** The hub's comments as threads on files under the repository root. */ |
| 18 |
export class ReviewController implements vscode.Disposable { |
| 19 |
private readonly controller = vscode.comments.createCommentController("oxygenhub", "OxygenHub review"); |
| 20 |
private threads: vscode.CommentThread[] = []; |
| 21 |
private active: { repo: HubRepo; number: number } | undefined; |
| 22 |
/** Threads in diff editors, per proposal (`base/owner/name#n`). */ |
| 23 |
private readonly diffThreads = new Map<string, vscode.CommentThread[]>(); |
| 24 |
|
| 25 |
constructor(private readonly auth: HubAuth) { |
| 26 |
this.controller.commentingRangeProvider = { |
| 27 |
provideCommentingRanges: (document) => { |
| 28 |
const whole = [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; |
| 29 |
// In a proposal's diff, only the projected side takes comments. |
| 30 |
const r = refOf(document.uri); |
| 31 |
if (r) { |
| 32 |
return r.side === "head" ? whole : []; |
| 33 |
} |
| 34 |
return this.active && document.uri.fsPath.startsWith(this.active.repo.handle.root) ? whole : []; |
| 35 |
}, |
| 36 |
}; |
| 37 |
} |
| 38 |
|
| 39 |
/** |
| 40 |
* Show a proposal's threads in its diff documents: every anchored |
| 41 |
* comment on the projected side, at the line the hub places it. |
| 42 |
*/ |
| 43 |
async loadDiff(repo: RepoRef, number: number): Promise<void> { |
| 44 |
const key = `${repo.base}/${repo.owner}/${repo.name}#${number}`; |
| 45 |
this.diffThreads.get(key)?.forEach((t) => t.dispose()); |
| 46 |
const comments = await this.auth.clientFor(repo.base).proposalComments(repo.owner, repo.name, number); |
| 47 |
const byLine = new Map<string, api.Comment[]>(); |
| 48 |
for (const c of comments) { |
| 49 |
if (c.path && c.line) { |
| 50 |
const k = `${c.path}\n${c.line}`; |
| 51 |
byLine.set(k, [...(byLine.get(k) ?? []), c]); |
| 52 |
} |
| 53 |
} |
| 54 |
const threads: vscode.CommentThread[] = []; |
| 55 |
for (const [k, cs] of byLine) { |
| 56 |
const [path, line] = k.split("\n"); |
| 57 |
const thread = this.controller.createCommentThread(diffUri(repo, number, "head", path), new vscode.Range(Number(line) - 1, 0, Number(line) - 1, 0), cs.map(toComment)); |
| 58 |
thread.label = cs.every((c) => c.resolved) ? "resolved" : "review"; |
| 59 |
thread.state = cs.every((c) => c.resolved) ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; |
| 60 |
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; |
| 61 |
threads.push(thread); |
| 62 |
} |
| 63 |
this.diffThreads.set(key, threads); |
| 64 |
} |
| 65 |
|
| 66 |
/** Show the threads of a proposal; call after checking it out. */ |
| 67 |
async load(repo: HubRepo, number: number): Promise<void> { |
| 68 |
this.clear(); |
| 69 |
this.active = { repo, number }; |
| 70 |
const comments = await this.auth.clientFor(repo.base).proposalComments(repo.owner, repo.name, number); |
| 71 |
const byPath = new Map<string, api.Comment[]>(); |
| 72 |
for (const c of comments) { |
| 73 |
if (c.path && c.anchor) { |
| 74 |
byPath.set(c.path, [...(byPath.get(c.path) ?? []), c]); |
| 75 |
} |
| 76 |
} |
| 77 |
for (const [path, list] of byPath) { |
| 78 |
let blame: BlameLine[] = []; |
| 79 |
try { |
| 80 |
blame = await repo.handle.json<BlameLine[]>(["blame", path, "--json"]); |
| 81 |
} catch { |
| 82 |
continue; |
| 83 |
} |
| 84 |
const uri = vscode.Uri.file(`${repo.handle.root}/${path}`); |
| 85 |
// One thread per atom. |
| 86 |
const byAtom = new Map<string, api.Comment[]>(); |
| 87 |
for (const c of list) { |
| 88 |
const key = `${c.anchor!.change}:${c.anchor!.index}`; |
| 89 |
byAtom.set(key, [...(byAtom.get(key) ?? []), c]); |
| 90 |
} |
| 91 |
for (const [key, cs] of byAtom) { |
| 92 |
const [hash, index] = key.split(":"); |
| 93 |
const line = blame.find((b) => b.hash === hash && String(b.index) === index)?.line; |
| 94 |
if (!line) { |
| 95 |
continue; |
| 96 |
} |
| 97 |
const thread = this.controller.createCommentThread(uri, new vscode.Range(line - 1, 0, line - 1, 0), cs.map(toComment)); |
| 98 |
thread.label = cs[0].resolved ? "resolved" : "review"; |
| 99 |
thread.state = cs[0].resolved ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; |
| 100 |
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; |
| 101 |
this.threads.push(thread); |
| 102 |
} |
| 103 |
} |
| 104 |
} |
| 105 |
|
| 106 |
/** Post a reply to a thread, anchored to the line it sits on. */ |
| 107 |
async reply(reply: vscode.CommentReply): Promise<void> { |
| 108 |
const r = refOf(reply.thread.uri); |
| 109 |
if (r) { |
| 110 |
const line = (reply.thread.range?.start.line ?? 0) + 1; |
| 111 |
const repo: RepoRef = { base: r.base, owner: r.owner, name: r.name }; |
| 112 |
try { |
| 113 |
await postLineComment(this.auth, repo, r.number, r.path, line, reply.text); |
| 114 |
} catch (e) { |
| 115 |
void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); |
| 116 |
return; |
| 117 |
} |
| 118 |
// A new thread from the gutter is replaced by the hub's copy. |
| 119 |
if (!reply.thread.comments.length) { |
| 120 |
reply.thread.dispose(); |
| 121 |
} |
| 122 |
await this.loadDiff(repo, r.number); |
| 123 |
return; |
| 124 |
} |
| 125 |
const a = this.active; |
| 126 |
if (!a) { |
| 127 |
return; |
| 128 |
} |
| 129 |
const rel = reply.thread.uri.fsPath.slice(a.repo.handle.root.length + 1); |
| 130 |
const line = reply.thread.range?.start.line ?? 0; |
| 131 |
// The hub resolves the line to its atom, so the thread follows the line through rebases. |
| 132 |
await postLineComment(this.auth, a.repo, a.number, rel, line + 1, reply.text); |
| 133 |
await this.load(a.repo, a.number); |
| 134 |
} |
| 135 |
|
| 136 |
clear(): void { |
| 137 |
this.threads.forEach((t) => t.dispose()); |
| 138 |
this.threads = []; |
| 139 |
this.active = undefined; |
| 140 |
} |
| 141 |
|
| 142 |
dispose(): void { |
| 143 |
this.clear(); |
| 144 |
this.diffThreads.forEach((ts) => ts.forEach((t) => t.dispose())); |
| 145 |
this.diffThreads.clear(); |
| 146 |
this.controller.dispose(); |
| 147 |
} |
| 148 |
} |
| 149 |
|
| 150 |
function toComment(c: api.Comment): vscode.Comment { |
| 151 |
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) }; |
| 152 |
} |
| 153 |
|
| 154 |
/** A comment anchored to the atom at `path:line` of the projected state. */ |
| 155 |
async function postLineComment(auth: HubAuth, repo: RepoRef, number: number, path: string, line: number, body: string): Promise<void> { |
| 156 |
await auth.clientFor(repo.base).commentProposal(repo.owner, repo.name, number, body, path, line); |
| 157 |
} |