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