| 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 } from "./proposals"; |
| 9 |
|
| 10 |
interface BlameLine { |
| 11 |
line: number; |
| 12 |
hash: string; |
| 13 |
index: number; |
| 14 |
} |
| 15 |
|
| 16 |
/** The hub's comments as threads on files under the repository root. */ |
| 17 |
export class ReviewController implements vscode.Disposable { |
| 18 |
private readonly controller = vscode.comments.createCommentController("oxygenhub", "Oxygenhub review"); |
| 19 |
private threads: vscode.CommentThread[] = []; |
| 20 |
private active: { repo: HubRepo; number: number } | undefined; |
| 21 |
|
| 22 |
constructor(private readonly auth: HubAuth) { |
| 23 |
this.controller.commentingRangeProvider = { |
| 24 |
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)] : []), |
| 25 |
}; |
| 26 |
} |
| 27 |
|
| 28 |
/** Show the threads of a proposal; call after checking it out. */ |
| 29 |
async load(repo: HubRepo, number: number): Promise<void> { |
| 30 |
this.clear(); |
| 31 |
this.active = { repo, number }; |
| 32 |
const comments = await this.auth.clientFor(repo.base).proposalComments(repo.owner, repo.name, number); |
| 33 |
const byPath = new Map<string, api.Comment[]>(); |
| 34 |
for (const c of comments) { |
| 35 |
if (c.path && c.anchor) { |
| 36 |
byPath.set(c.path, [...(byPath.get(c.path) ?? []), c]); |
| 37 |
} |
| 38 |
} |
| 39 |
for (const [path, list] of byPath) { |
| 40 |
let blame: BlameLine[] = []; |
| 41 |
try { |
| 42 |
blame = await repo.handle.json<BlameLine[]>(["blame", path, "--json"]); |
| 43 |
} catch { |
| 44 |
continue; |
| 45 |
} |
| 46 |
const uri = vscode.Uri.file(`${repo.handle.root}/${path}`); |
| 47 |
// One thread per atom. |
| 48 |
const byAtom = new Map<string, api.Comment[]>(); |
| 49 |
for (const c of list) { |
| 50 |
const key = `${c.anchor!.change}:${c.anchor!.index}`; |
| 51 |
byAtom.set(key, [...(byAtom.get(key) ?? []), c]); |
| 52 |
} |
| 53 |
for (const [key, cs] of byAtom) { |
| 54 |
const [hash, index] = key.split(":"); |
| 55 |
const line = blame.find((b) => b.hash === hash && String(b.index) === index)?.line; |
| 56 |
if (!line) { |
| 57 |
continue; |
| 58 |
} |
| 59 |
const thread = this.controller.createCommentThread(uri, new vscode.Range(line - 1, 0, line - 1, 0), cs.map(toComment)); |
| 60 |
thread.label = cs[0].resolved ? "resolved" : "review"; |
| 61 |
thread.state = cs[0].resolved ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; |
| 62 |
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; |
| 63 |
this.threads.push(thread); |
| 64 |
} |
| 65 |
} |
| 66 |
} |
| 67 |
|
| 68 |
/** Post a reply to a thread, anchored to the line it sits on. */ |
| 69 |
async reply(reply: vscode.CommentReply): Promise<void> { |
| 70 |
const a = this.active; |
| 71 |
if (!a) { |
| 72 |
return; |
| 73 |
} |
| 74 |
const rel = reply.thread.uri.fsPath.slice(a.repo.handle.root.length + 1); |
| 75 |
const line = reply.thread.range?.start.line ?? 0; |
| 76 |
// The hub anchors by line number of the projected state; send the path and line. |
| 77 |
await postLineComment(this.auth, a.repo, a.number, rel, line + 1, reply.text); |
| 78 |
await this.load(a.repo, a.number); |
| 79 |
} |
| 80 |
|
| 81 |
clear(): void { |
| 82 |
this.threads.forEach((t) => t.dispose()); |
| 83 |
this.threads = []; |
| 84 |
this.active = undefined; |
| 85 |
} |
| 86 |
|
| 87 |
dispose(): void { |
| 88 |
this.clear(); |
| 89 |
this.controller.dispose(); |
| 90 |
} |
| 91 |
} |
| 92 |
|
| 93 |
function toComment(c: api.Comment): vscode.Comment { |
| 94 |
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) }; |
| 95 |
} |
| 96 |
|
| 97 |
/** 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. */ |
| 98 |
async function postLineComment(auth: HubAuth, repo: HubRepo, number: number, path: string, line: number, body: string): Promise<void> { |
| 99 |
await auth.clientFor(repo.base).commentProposal(repo.owner, repo.name, number, `${body}\n\n_on ${path}:${line}_`); |
| 100 |
} |