OXYGENHUB
# b2dd082c57e4 oxygenhub-vscode / src / extension.ts
15.4 KiB · last changed Notifications in the status bar and an inbox (0.3.0) 1 month ago source blame history raw
1
// Activation: sign in, find the Oxygen extension's API, register the
2
// remote provider, wire the views, the review controller and the commands.
3
4
import * as vscode from "vscode";
5
import { HubAuth } from "./auth";
6
import { ChecksBar } from "./checks";
7
import { ProposalContentProvider, SCHEME, openAllChanges, openFileDiff, refOf } from "./diff";
8
import { hubUrl } from "./hub";
9
import { InboxBar } from "./inbox";
10
import { IssueNode, IssuesProvider, showIssue } from "./issues";
11
import type { OxygenAPI } from "./oxygenapi";
12
import { HubRepo, ProposalFileNode, ProposalNode, ProposalsProvider, showDetail } from "./proposals";
13
import { makeProvider } from "./provider";
14
import { ReviewController } from "./review";
15
16
let auth: HubAuth;
17
let oxygen: OxygenAPI | undefined;
18
let proposals: ProposalsProvider;
19
let issues: IssuesProvider;
20
let review: ReviewController;
21
let bar: ChecksBar;
22
let content: ProposalContentProvider;
23
let inbox: InboxBar;
24
25
export async function activate(context: vscode.ExtensionContext): Promise<void> {
26
  auth = new HubAuth(context.secrets);
27
  await auth.load();
28
  context.subscriptions.push(auth);
29
30
  const ext = vscode.extensions.getExtension<OxygenAPI>("oxygen.oxygen-vscode");
31
  oxygen = ext ? ((await ext.activate()) as OxygenAPI | undefined) : undefined;
32
  if (!oxygen) {
33
    void vscode.window.showWarningMessage("Oxygenhub: the Oxygen extension is not available; install oxygen.oxygen-vscode 0.7.0 or later.");
34
  } else {
35
    context.subscriptions.push(oxygen.registerRemoteProvider(makeProvider({ bases: () => auth.bases() })));
36
    context.subscriptions.push(oxygen.onDidChangeRepositories(() => refreshAll()));
37
  }
38
39
  proposals = new ProposalsProvider(() => oxygen, auth);
40
  issues = new IssuesProvider(() => proposals.repos(), auth);
41
  review = new ReviewController(auth);
42
  bar = new ChecksBar(auth, () => proposals.repos());
43
  inbox = new InboxBar(auth, async (t) => {
44
    const repo = (await proposals.repos()).find((r) => r.base === t.base && r.owner === t.owner && r.name === t.name);
45
    if (!repo) {
46
      return false;
47
    }
48
    const client = auth.clientFor(repo.base);
49
    if (t.kind === "proposal") {
50
      const [p, checks] = await Promise.all([client.proposal(repo.owner, repo.name, t.number), client.proposalChecks(repo.owner, repo.name, t.number).catch(() => [])]);
51
      showDetail(repo, p, checks);
52
    } else {
53
      await showIssue(auth, repo, await client.issue(repo.owner, repo.name, t.number));
54
    }
55
    return true;
56
  });
57
  context.subscriptions.push(inbox);
58
  content = new ProposalContentProvider(auth);
59
  context.subscriptions.push(content, vscode.workspace.registerTextDocumentContentProvider(SCHEME, content));
60
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.proposals", proposals), vscode.window.registerTreeDataProvider("oxygenhub.issues", issues), review, bar);
61
  context.subscriptions.push(auth.onDidChange(() => refreshAll()));
62
63
  context.subscriptions.push(
64
    vscode.commands.registerCommand("oxygenhub.signIn", async () => {
65
      const repos = oxygen ? await guessHubs() : [];
66
      await auth.signIn(repos[0]);
67
      refreshAll();
68
    }),
69
    vscode.commands.registerCommand("oxygenhub.signOut", async () => {
70
      const pick = await vscode.window.showQuickPick(auth.all().map((s) => s.base), { placeHolder: "Sign out of" });
71
      if (pick) {
72
        await auth.removeSession(pick);
73
        refreshAll();
74
      }
75
    }),
76
    vscode.commands.registerCommand("oxygenhub.refresh", () => refreshAll()),
77
    vscode.commands.registerCommand("oxygenhub.inbox", () => inbox.show()),
78
    vscode.commands.registerCommand("oxygenhub.openProposal", async (node?: ProposalNode) => {
79
      const n = node ?? (await pickProposal());
80
      if (!n) {
81
        return;
82
      }
83
      const checks = await auth.clientFor(n.repo.base).proposalChecks(n.repo.owner, n.repo.name, n.p.number).catch(() => []);
84
      showDetail(n.repo, n.p, checks);
85
    }),
86
    vscode.commands.registerCommand("oxygenhub.openOnHub", (node?: ProposalNode | IssueNode) => {
87
      if (!node) {
88
        return;
89
      }
90
      const target = node.kind === "proposal" ? { kind: "proposal", number: node.p.number } : { kind: "issue", number: node.i.number };
91
      void vscode.env.openExternal(vscode.Uri.parse(hubUrl(node.repo.base, node.repo.owner, node.repo.name, target)));
92
    }),
93
    vscode.commands.registerCommand("oxygenhub.openProposalFile", async (node: ProposalFileNode) => {
94
      await openFileDiff(node.repo, node.p.number, node.f);
95
      await review.loadDiff(node.repo, node.p.number).catch(() => undefined);
96
    }),
97
    vscode.commands.registerCommand("oxygenhub.openProposalChanges", (...args: unknown[]) =>
98
      withProposal(args, async (repo, number) => {
99
        try {
100
          const client = auth.clientFor(repo.base);
101
          const [p, files] = await Promise.all([client.proposal(repo.owner, repo.name, number), client.proposalFiles(repo.owner, repo.name, number)]);
102
          await openAllChanges(repo, p, files);
103
          await review.loadDiff(repo, number);
104
        } catch (e) {
105
          void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`);
106
        }
107
      }),
108
    ),
109
    vscode.commands.registerCommand("oxygenhub.checkoutProposal", (...args: unknown[]) => withProposal(args, checkout)),
110
    vscode.commands.registerCommand("oxygenhub.review", (...args: unknown[]) => withProposal(args, reviewProposal)),
111
    vscode.commands.registerCommand("oxygenhub.sealApproval", (...args: unknown[]) => withProposal(args, (repo, number) => runInTerminal(repo, `oxy seal --proposal ${hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })}`))),
112
    vscode.commands.registerCommand("oxygenhub.land", (...args: unknown[]) => withProposal(args, (repo, number) => runInTerminal(repo, `oxy proposal land ${hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })}`))),
113
    vscode.commands.registerCommand("oxygenhub.closeProposal", (...args: unknown[]) =>
114
      withProposal(args, async (repo, number) => {
115
        void vscode.env.openExternal(vscode.Uri.parse(hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })));
116
      }),
117
    ),
118
    vscode.commands.registerCommand("oxygenhub.createProposal", () => createProposal()),
119
    vscode.commands.registerCommand("oxygenhub.newIssue", () => newIssue()),
120
    vscode.commands.registerCommand("oxygenhub.openIssue", async (node?: IssueNode) => node && showIssue(auth, node.repo, node.i)),
121
    vscode.commands.registerCommand("oxygenhub.commentIssue", (...args: unknown[]) =>
122
      withIssue(args, async (repo, number) => {
123
        const body = await vscode.window.showInputBox({ prompt: `Comment on #${number}` });
124
        if (body) {
125
          await auth.clientFor(repo.base).commentIssue(repo.owner, repo.name, number, body);
126
          issues.refresh();
127
        }
128
      }),
129
    ),
130
    vscode.commands.registerCommand("oxygenhub.closeIssue", (...args: unknown[]) => withIssue(args, async (repo, number) => (await auth.clientFor(repo.base).setIssueState(repo.owner, repo.name, number, "closed"), issues.refresh()))),
131
    vscode.commands.registerCommand("oxygenhub.reopenIssue", (...args: unknown[]) => withIssue(args, async (repo, number) => (await auth.clientFor(repo.base).setIssueState(repo.owner, repo.name, number, "open"), issues.refresh()))),
132
    vscode.commands.registerCommand("oxygenhub.assignMe", (...args: unknown[]) =>
133
      withIssue(args, async (repo, number) => {
134
        void vscode.window.showInformationMessage(`Oxygenhub: assigning is on the hub for now: ${hubUrl(repo.base, repo.owner, repo.name, { kind: "issue", number })}`);
135
      }),
136
    ),
137
    vscode.commands.registerCommand("oxygenhub.reply", (reply: vscode.CommentReply) => review.reply(reply)),
138
    vscode.commands.registerCommand("oxygenhub.newThread", (reply: vscode.CommentReply) => review.reply(reply)),
139
    vscode.commands.registerCommand("oxygenhub.resolveThread", (thread: vscode.CommentThread) => {
140
      thread.state = thread.state === vscode.CommentThreadState.Resolved ? vscode.CommentThreadState.Unresolved : vscode.CommentThreadState.Resolved;
141
    }),
142
  );
143
  bar.start();
144
  inbox.start();
145
}
146
147
function refreshAll(): void {
148
  proposals.refresh();
149
  refreshOpenDiffs();
150
  issues.refresh();
151
  void bar.refresh();
152
  void inbox.refresh();
153
}
154
155
/** Reload the sides of open proposal diffs, which may have gained changes. */
156
function refreshOpenDiffs(): void {
157
  const seen = new Set<string>();
158
  for (const doc of vscode.workspace.textDocuments) {
159
    const r = refOf(doc.uri);
160
    const key = r && `${r.base}/${r.owner}/${r.name}#${r.number}`;
161
    if (r && key && !seen.has(key)) {
162
      seen.add(key);
163
      content.refresh(r.base, r.owner, r.name, r.number);
164
    }
165
  }
166
}
167
168
async function guessHubs(): Promise<string[]> {
169
  const out: string[] = [];
170
  for (const r of oxygen?.repositories ?? []) {
171
    for (const remote of await r.remotes().catch(() => [])) {
172
      const m = /^(https?:\/\/[^/]+)\/[^/]+\/[^/]+\/?$/.exec(remote.url);
173
      if (m && !out.includes(m[1])) {
174
        out.push(m[1]);
175
      }
176
    }
177
  }
178
  return out;
179
}
180
181
async function pickProposal(): Promise<ProposalNode | undefined> {
182
  const items: { label: string; description: string; node: ProposalNode }[] = [];
183
  for (const repo of await proposals.repos()) {
184
    for (const p of await proposals.proposalsOf(repo).catch(() => [])) {
185
      items.push({ label: `#${p.number} ${p.title}`, description: `${repo.owner}/${repo.name} · ${p.author} → ${p.target}`, node: { kind: "proposal", repo, p } });
186
    }
187
  }
188
  return (await vscode.window.showQuickPick(items, { placeHolder: "Proposal" }))?.node;
189
}
190
191
/** Commands take a tree node, or the webview's `[base, owner, name, number]`. */
192
async function withProposal(args: unknown[], f: (repo: HubRepo, number: number) => Promise<void>): Promise<void> {
193
  const a = args[0];
194
  if (a && typeof a === "object" && (a as ProposalNode).kind === "proposal") {
195
    const n = a as ProposalNode;
196
    return f(n.repo, n.p.number);
197
  }
198
  if (Array.isArray(a) && a.length === 4) {
199
    const [base, owner, name, number] = a as [string, string, string, number];
200
    const repo = (await proposals.repos()).find((r) => r.base === base && r.owner === owner && r.name === name);
201
    if (repo) {
202
      return f(repo, number);
203
    }
204
  }
205
  const n = await pickProposal();
206
  if (n) {
207
    return f(n.repo, n.p.number);
208
  }
209
}
210
211
async function withIssue(args: unknown[], f: (repo: HubRepo, number: number) => Promise<void>): Promise<void> {
212
  const a = args[0];
213
  if (a && typeof a === "object" && (a as IssueNode).kind === "issue") {
214
    const n = a as IssueNode;
215
    return f(n.repo, n.i.number);
216
  }
217
  if (Array.isArray(a) && a.length === 4) {
218
    const [base, owner, name, number] = a as [string, string, string, number];
219
    const repo = (await proposals.repos()).find((r) => r.base === base && r.owner === owner && r.name === name);
220
    if (repo) {
221
      return f(repo, number);
222
    }
223
  }
224
}
225
226
function runInTerminal(repo: HubRepo, command: string): Promise<void> {
227
  const t = vscode.window.createTerminal({ name: "Oxygenhub", cwd: repo.handle.root });
228
  t.show();
229
  t.sendText(command);
230
  return Promise.resolve();
231
}
232
233
/** Fetch the proposal's changes and switch to a local bookmark at its projected heads (spec §5). */
234
async function checkout(repo: HubRepo, number: number): Promise<void> {
235
  const p = await auth.clientFor(repo.base).proposal(repo.owner, repo.name, number);
236
  const prefix = vscode.workspace.getConfiguration("oxygenhub").get<string>("proposalBookmarkPrefix") ?? "proposal/";
237
  const bookmark = `${prefix}${number}`;
238
  try {
239
    await repo.handle.json(["fetch", repo.remote, "--json"]);
240
    const existing = repo.handle.bookmarks.some((b) => b.name === bookmark);
241
    if (!existing) {
242
      await repo.handle.json(["bookmark", "create", bookmark, ...p.projected_heads, "--json"]).catch(() => repo.handle.json(["bookmark", "create", bookmark, "--json"]));
243
    }
244
    await repo.handle.json(["bookmark", "set", bookmark, ...p.projected_heads, "--json"]).catch(() => undefined);
245
    await repo.handle.json(["bookmark", "switch", bookmark, "--json"]);
246
    await repo.handle.refresh();
247
    await review.load(repo, number);
248
    void vscode.window.showInformationMessage(`Oxygenhub: checked out #${number} as ${bookmark}; review threads are in place.`);
249
  } catch (e) {
250
    void vscode.window.showErrorMessage(`Oxygenhub: could not check out #${number}: ${(e as Error).message}`);
251
  }
252
}
253
254
async function reviewProposal(repo: HubRepo, number: number): Promise<void> {
255
  const verdict = await vscode.window.showQuickPick(
256
    [
257
      { label: "Comment", value: "comment" as const },
258
      { label: "Approve", value: "approve" as const, description: "then seal it with oxy so it counts" },
259
      { label: "Request changes", value: "changes" as const },
260
    ],
261
    { placeHolder: `Review #${number}` },
262
  );
263
  if (!verdict) {
264
    return;
265
  }
266
  const body = (await vscode.window.showInputBox({ prompt: "Summary (optional)" })) ?? "";
267
  try {
268
    await auth.clientFor(repo.base).review(repo.owner, repo.name, number, verdict.value, body);
269
  } catch (e) {
270
    void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`);
271
    return;
272
  }
273
  proposals.refresh();
274
  if (verdict.value === "approve") {
275
    const pick = await vscode.window.showInformationMessage("Approval recorded. Seal it with your key so it counts?", "Seal now");
276
    if (pick === "Seal now") {
277
      await runInTerminal(repo, `oxy seal --proposal ${hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })}`);
278
    }
279
  }
280
}
281
282
async function createProposal(): Promise<void> {
283
  const repos = await proposals.repos();
284
  const repo = repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Repository" }))?.r;
285
  if (!repo) {
286
    return;
287
  }
288
  const source = repo.handle.currentBookmark;
289
  if (!source) {
290
    void vscode.window.showWarningMessage("Oxygenhub: no current bookmark.");
291
    return;
292
  }
293
  const remoteBookmarks = await auth.clientFor(repo.base).bookmarks(repo.owner, repo.name).catch(() => []);
294
  const target = await vscode.window.showQuickPick(remoteBookmarks.filter((b) => b.name !== source).map((b) => b.name), { placeHolder: `Propose ${source} into…` });
295
  if (!target) {
296
    return;
297
  }
298
  const title = await vscode.window.showInputBox({ prompt: "Title", value: source });
299
  if (!title) {
300
    return;
301
  }
302
  try {
303
    await repo.handle.json(["push", repo.remote, "--bookmark", source, "--json"]).catch(() => undefined);
304
    const p = await auth.clientFor(repo.base).createProposal(repo.owner, repo.name, { title, body: "", target, source, draft: false });
305
    proposals.refresh();
306
    void vscode.env.openExternal(vscode.Uri.parse(p.url));
307
  } catch (e) {
308
    void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`);
309
  }
310
}
311
312
async function newIssue(): Promise<void> {
313
  const repos = await proposals.repos();
314
  const repo = repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Repository" }))?.r;
315
  if (!repo) {
316
    return;
317
  }
318
  const title = await vscode.window.showInputBox({ prompt: "Issue title" });
319
  if (!title) {
320
    return;
321
  }
322
  const body = (await vscode.window.showInputBox({ prompt: "Description (optional)" })) ?? "";
323
  try {
324
    const i = await auth.clientFor(repo.base).createIssue(repo.owner, repo.name, title, body);
325
    issues.refresh();
326
    void vscode.window.showInformationMessage(`Oxygenhub: opened #${i.number}.`);
327
  } catch (e) {
328
    void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`);
329
  }
330
}
331
332
export function deactivate(): void {}