OXYGENHUB
◆ main oxygenhub-vscode / src / review.ts
6.4 KiB · last changed Write the forge's name as OxygenHub in everything people read 1 month ago source blame history raw
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
}