OXYGENHUB
# 991516a107fa oxygenhub-vscode / src / extension.ts
27.5 KiB · last changed OxygenHub 0.13.0: Drops in the editor with Share as a Drop (closes #7); Flow approvals, expedite, reports, timings and test results (closes #8); ready/draft, landable, trust reports, asset downloads and new repositories (closes #9) 6 days 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 { FlowLogs, FlowProvider, JobNode, LOG_SCHEME, RunNode, openLog, useLogs } from "./flow";
9
import { hubUrl } from "./hub";
10
import { InboxBar } from "./inbox";
11
import { AssetNode, draftRelease, ReleaseNode, ReleasesProvider, saveAsset, showTrust } from "./releases";
12
import { DROP_SCHEME } from "./dropkit";
13
import { DropFileSystem, DropsProvider, registerDropCommands, shareFiles, shareSelection } from "./drops";
14
import { newRepository } from "./newrepo";
15
import { HubTests } from "./tests";
16
import { DiffusionPinNode, DiffusionProvider, diffusionTab, pinUrl } from "./diffusion";
17
import { ReferenceCompletions, ReferenceHovers, ReferenceIndex, SELECTOR } from "./references";
18
import { annotateTodo, issueBody, todoTitle } from "./refs";
19
import { IssueNode, IssuesProvider, showIssue } from "./issues";
20
import type { OxygenAPI } from "./oxygenapi";
21
import { HubRepo, ProposalFileNode, ProposalNode, ProposalsProvider, showDetail } from "./proposals";
22
import { makeProvider } from "./provider";
23
import { ReviewController } from "./review";
24
25
let auth: HubAuth;
26
let oxygen: OxygenAPI | undefined;
27
let proposals: ProposalsProvider;
28
let issues: IssuesProvider;
29
let review: ReviewController;
30
let bar: ChecksBar;
31
let content: ProposalContentProvider;
32
let inbox: InboxBar;
33
let references: ReferenceIndex;
34
let releases: ReleasesProvider;
35
let diffusion: DiffusionProvider;
36
let flow: FlowProvider;
37
let drops: DropsProvider;
38
let tests: HubTests;
39
40
export async function activate(context: vscode.ExtensionContext): Promise<void> {
41
  auth = new HubAuth(context.secrets);
42
  await auth.load();
43
  context.subscriptions.push(auth);
44
45
  // Oxygen VCS may be missing or fail to start (two copies of it installed
46
  // at once): OxygenHub still starts, so signing in and the hub views work.
47
  const ext = vscode.extensions.getExtension<OxygenAPI>("oxygenhub.oxygen-vscode");
48
  try {
49
    oxygen = ext ? ((await ext.activate()) as OxygenAPI | undefined) : undefined;
50
  } catch {
51
    oxygen = undefined;
52
  }
53
  if (!oxygen) {
54
    void vscode.window.showWarningMessage("OxygenHub: the Oxygen VCS extension is not available; install Oxygen VCS (oxygenhub.oxygen-vscode) 0.17.1 or later, and only one copy of it.");
55
  } else {
56
    context.subscriptions.push(oxygen.registerRemoteProvider(makeProvider({ bases: () => auth.bases() })));
57
    context.subscriptions.push(oxygen.onDidChangeRepositories(() => refreshAll()));
58
  }
59
60
  proposals = new ProposalsProvider(() => oxygen, auth);
61
  issues = new IssuesProvider(() => proposals.repos(), auth);
62
  review = new ReviewController(auth);
63
  bar = new ChecksBar(auth, () => proposals.repos());
64
  inbox = new InboxBar(auth, async (t) => {
65
    const repo = (await proposals.repos()).find((r) => r.base === t.base && r.owner === t.owner && r.name === t.name);
66
    if (!repo) {
67
      return false;
68
    }
69
    const client = auth.clientFor(repo.base);
70
    if (t.kind === "proposal") {
71
      const [p, checks] = await Promise.all([client.proposal(repo.owner, repo.name, t.number), client.proposalChecks(repo.owner, repo.name, t.number).catch(() => [])]);
72
      showDetail(repo, p, checks);
73
    } else {
74
      await showIssue(auth, repo, await client.issue(repo.owner, repo.name, t.number));
75
    }
76
    return true;
77
  });
78
  context.subscriptions.push(inbox);
79
  releases = new ReleasesProvider(() => proposals.repos(), auth);
80
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.releases", releases));
81
  diffusion = new DiffusionProvider(() => proposals.repos(), auth);
82
  context.subscriptions.push(
83
    vscode.window.registerTreeDataProvider("oxygenhub.diffusion", diffusion),
84
    // A bump on this repository opens beside the code, as any proposal does;
85
    // the rest open on the hub.
86
    vscode.commands.registerCommand("oxygenhub.openDiffusionPin", async (node: DiffusionPinNode) => {
87
      const bump = node.pin.bump;
88
      if (node.side === "takes" && bump !== null && bump !== undefined) {
89
        try {
90
          const client = auth.clientFor(node.repo.base);
91
          const [p, checks] = await Promise.all([client.proposal(node.repo.owner, node.repo.name, bump), client.proposalChecks(node.repo.owner, node.repo.name, bump).catch(() => [])]);
92
          showDetail(node.repo, p, checks);
93
          return;
94
        } catch (e) {
95
          void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`);
96
        }
97
      }
98
      void vscode.env.openExternal(vscode.Uri.parse(pinUrl(node)));
99
    }),
100
    vscode.commands.registerCommand("oxygenhub.openDiffusionOnHub", (node?: DiffusionPinNode | { kind: "repo"; repo: HubRepo }) => {
101
      if (node) {
102
        void vscode.env.openExternal(vscode.Uri.parse(node.kind === "pin" ? pinUrl(node) : diffusionTab(node.repo)));
103
      }
104
    }),
105
  );
106
  flow = new FlowProvider(() => proposals.repos(), auth);
107
  const flowLogs = new FlowLogs(auth);
108
  useLogs(flowLogs);
109
  context.subscriptions.push(
110
    flow,
111
    flowLogs,
112
    vscode.window.registerTreeDataProvider("oxygenhub.flow", flow),
113
    vscode.workspace.registerTextDocumentContentProvider(LOG_SCHEME, flowLogs),
114
    vscode.commands.registerCommand("oxygenhub.flowRefresh", () => flow.refresh()),
115
    vscode.commands.registerCommand("oxygenhub.runPipeline", (node?: { repo?: HubRepo }) => flow.start(node?.repo)),
116
    vscode.commands.registerCommand("oxygenhub.rerunFlow", (node: RunNode) => flow.rerun(node)),
117
    vscode.commands.registerCommand("oxygenhub.flowLog", (node: JobNode) => openLog(node.repo, node.run, node.job)),
118
    vscode.commands.registerCommand("oxygenhub.openFlowRun", (node: RunNode) => vscode.env.openExternal(vscode.Uri.parse(node.run.url))),
119
    vscode.commands.registerCommand("oxygenhub.flowRecent", (repo?: HubRepo) => flow.recent(repo)),
120
    vscode.commands.registerCommand("oxygenhub.approveJob", (node: JobNode) => flow.decide(node, true)),
121
    vscode.commands.registerCommand("oxygenhub.rejectJob", (node: JobNode) => flow.decide(node, false)),
122
    vscode.commands.registerCommand("oxygenhub.expediteRun", (node: RunNode) => flow.expedite(node)),
123
    vscode.commands.registerCommand("oxygenhub.cancelRun", (node: RunNode) => flow.cancel(node)),
124
    vscode.commands.registerCommand("oxygenhub.jobReports", (node: JobNode) => flow.reports(node)),
125
  );
126
  drops = new DropsProvider(auth);
127
  context.subscriptions.push(
128
    vscode.window.registerTreeDataProvider("oxygenhub.drops", drops),
129
    vscode.workspace.registerFileSystemProvider(DROP_SCHEME, new DropFileSystem(auth, () => drops.refresh()), { isCaseSensitive: true }),
130
    ...registerDropCommands(auth, drops),
131
    vscode.commands.registerCommand("oxygenhub.shareSelectionAsDrop", (uri?: vscode.Uri) => shareSelection(auth, drops, uri instanceof vscode.Uri ? uri : undefined)),
132
    vscode.commands.registerCommand("oxygenhub.shareFilesAsDrop", (uri?: vscode.Uri, uris?: vscode.Uri[]) => shareFiles(auth, drops, uri instanceof vscode.Uri ? uri : undefined, uris)),
133
  );
134
  tests = new HubTests(auth, () => proposals.repos());
135
  context.subscriptions.push(
136
    tests,
137
    vscode.commands.registerCommand("oxygenhub.releaseTrust", (node: ReleaseNode) => showTrust(auth, node).catch((e: Error) => vscode.window.showErrorMessage(`OxygenHub: ${e.message}`))),
138
    vscode.commands.registerCommand("oxygenhub.saveAsset", (node: AssetNode) => saveAsset(auth, node).catch((e: Error) => vscode.window.showErrorMessage(`OxygenHub: ${e.message}`))),
139
    vscode.commands.registerCommand("oxygenhub.newRepository", () => newRepository(auth)),
140
    vscode.commands.registerCommand("oxygenhub.markReady", (...args: unknown[]) => withProposal(args, (repo, number) => setDraft(repo, number, false))),
141
    vscode.commands.registerCommand("oxygenhub.convertToDraft", (...args: unknown[]) => withProposal(args, (repo, number) => setDraft(repo, number, true))),
142
    vscode.commands.registerCommand("oxygenhub.landable", () => landable()),
143
  );
144
  references = new ReferenceIndex(auth, () => proposals.repos());
145
  context.subscriptions.push(
146
    vscode.languages.registerCompletionItemProvider(SELECTOR, new ReferenceCompletions(references), "#", "@"),
147
    vscode.languages.registerHoverProvider(SELECTOR, new ReferenceHovers(references)),
148
  );
149
  content = new ProposalContentProvider(auth);
150
  context.subscriptions.push(content, vscode.workspace.registerTextDocumentContentProvider(SCHEME, content));
151
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.proposals", proposals), vscode.window.registerTreeDataProvider("oxygenhub.issues", issues), review, bar);
152
  context.subscriptions.push(auth.onDidChange(() => refreshAll()));
153
154
  context.subscriptions.push(
155
    vscode.commands.registerCommand("oxygenhub.signIn", async () => {
156
      const repos = oxygen ? await guessHubs() : [];
157
      await auth.signIn(repos[0]);
158
      refreshAll();
159
    }),
160
    vscode.commands.registerCommand("oxygenhub.signOut", async () => {
161
      const pick = await vscode.window.showQuickPick(auth.all().map((s) => s.base), { placeHolder: "Sign out of" });
162
      if (pick) {
163
        await auth.removeSession(pick);
164
        refreshAll();
165
      }
166
    }),
167
    vscode.commands.registerCommand("oxygenhub.refresh", () => refreshAll()),
168
    vscode.commands.registerCommand("oxygenhub.inbox", () => inbox.show()),
169
    vscode.commands.registerCommand("oxygenhub.draftRelease", async (node?: { repo?: HubRepo }) => {
170
      const repos = await proposals.repos();
171
      const repo = node?.repo ?? (repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Repository" }))?.r);
172
      if (!repo) {
173
        return;
174
      }
175
      try {
176
        const r = await draftRelease(auth, repo);
177
        if (r) {
178
          releases.refresh();
179
          const open = await vscode.window.showInformationMessage(`OxygenHub: released ${r.tag}.`, "Open on Hub");
180
          if (open) {
181
            void vscode.env.openExternal(vscode.Uri.parse(r.url));
182
          }
183
        }
184
      } catch (e) {
185
        void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`);
186
      }
187
    }),
188
    vscode.commands.registerCommand("oxygenhub.openProposal", async (node?: ProposalNode) => {
189
      const n = node ?? (await pickProposal());
190
      if (!n) {
191
        return;
192
      }
193
      const checks = await auth.clientFor(n.repo.base).proposalChecks(n.repo.owner, n.repo.name, n.p.number).catch(() => []);
194
      showDetail(n.repo, n.p, checks);
195
    }),
196
    vscode.commands.registerCommand("oxygenhub.openOnHub", (node?: ProposalNode | IssueNode) => {
197
      if (!node) {
198
        return;
199
      }
200
      const target = node.kind === "proposal" ? { kind: "proposal", number: node.p.number } : { kind: "issue", number: node.i.number };
201
      void vscode.env.openExternal(vscode.Uri.parse(hubUrl(node.repo.base, node.repo.owner, node.repo.name, target)));
202
    }),
203
    vscode.commands.registerCommand("oxygenhub.openProposalFile", async (node: ProposalFileNode) => {
204
      await openFileDiff(node.repo, node.p.number, node.f);
205
      await review.loadDiff(node.repo, node.p.number).catch(() => undefined);
206
    }),
207
    vscode.commands.registerCommand("oxygenhub.openProposalChanges", (...args: unknown[]) =>
208
      withProposal(args, async (repo, number) => {
209
        try {
210
          const client = auth.clientFor(repo.base);
211
          const [p, files] = await Promise.all([client.proposal(repo.owner, repo.name, number), client.proposalFiles(repo.owner, repo.name, number)]);
212
          await openAllChanges(repo, p, files);
213
          await review.loadDiff(repo, number);
214
        } catch (e) {
215
          void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`);
216
        }
217
      }),
218
    ),
219
    vscode.commands.registerCommand("oxygenhub.checkoutProposal", (...args: unknown[]) => withProposal(args, checkout)),
220
    vscode.commands.registerCommand("oxygenhub.review", (...args: unknown[]) => withProposal(args, reviewProposal)),
221
    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 })}`))),
222
    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 })}`))),
223
    vscode.commands.registerCommand("oxygenhub.closeProposal", (...args: unknown[]) =>
224
      withProposal(args, async (repo, number) => {
225
        void vscode.env.openExternal(vscode.Uri.parse(hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })));
226
      }),
227
    ),
228
    vscode.commands.registerCommand("oxygenhub.createProposal", () => createProposal()),
229
    vscode.commands.registerCommand("oxygenhub.newIssue", () => newIssue()),
230
    vscode.commands.registerCommand("oxygenhub.issueFromSelection", () => issueFromSelection()),
231
    vscode.commands.registerCommand("oxygenhub.openIssue", async (node?: IssueNode) => node && showIssue(auth, node.repo, node.i)),
232
    vscode.commands.registerCommand("oxygenhub.commentIssue", (...args: unknown[]) =>
233
      withIssue(args, async (repo, number) => {
234
        const body = await vscode.window.showInputBox({ prompt: `Comment on #${number}` });
235
        if (body) {
236
          await auth.clientFor(repo.base).commentIssue(repo.owner, repo.name, number, body);
237
          issues.refresh();
238
        }
239
      }),
240
    ),
241
    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()))),
242
    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()))),
243
    vscode.commands.registerCommand("oxygenhub.assignMe", (...args: unknown[]) =>
244
      withIssue(args, async (repo, number) => {
245
        void vscode.window.showInformationMessage(`OxygenHub: assigning is on the hub for now: ${hubUrl(repo.base, repo.owner, repo.name, { kind: "issue", number })}`);
246
      }),
247
    ),
248
    vscode.commands.registerCommand("oxygenhub.reply", (reply: vscode.CommentReply) => review.reply(reply)),
249
    vscode.commands.registerCommand("oxygenhub.newThread", (reply: vscode.CommentReply) => review.reply(reply)),
250
    vscode.commands.registerCommand("oxygenhub.resolveThread", (thread: vscode.CommentThread) => {
251
      thread.state = thread.state === vscode.CommentThreadState.Resolved ? vscode.CommentThreadState.Unresolved : vscode.CommentThreadState.Resolved;
252
    }),
253
  );
254
  bar.start();
255
  inbox.start();
256
  void tests.load();
257
}
258
259
function refreshAll(): void {
260
  proposals.refresh();
261
  references.clear();
262
  refreshOpenDiffs();
263
  issues.refresh();
264
  releases.refresh();
265
  diffusion.refresh();
266
  drops.refresh();
267
  void tests.load();
268
  void flow.refresh();
269
  void bar.refresh();
270
  void inbox.refresh();
271
}
272
273
/** Reload the sides of open proposal diffs, which may have gained changes. */
274
function refreshOpenDiffs(): void {
275
  const seen = new Set<string>();
276
  for (const doc of vscode.workspace.textDocuments) {
277
    const r = refOf(doc.uri);
278
    const key = r && `${r.base}/${r.owner}/${r.name}#${r.number}`;
279
    if (r && key && !seen.has(key)) {
280
      seen.add(key);
281
      content.refresh(r.base, r.owner, r.name, r.number);
282
    }
283
  }
284
}
285
286
async function guessHubs(): Promise<string[]> {
287
  const out: string[] = [];
288
  for (const r of oxygen?.repositories ?? []) {
289
    for (const remote of await r.remotes().catch(() => [])) {
290
      const m = /^(https?:\/\/[^/]+)\/[^/]+\/[^/]+\/?$/.exec(remote.url);
291
      if (m && remote.ca) {
292
        auth.setCa(m[1], remote.ca);
293
      }
294
      if (m && !out.includes(m[1])) {
295
        out.push(m[1]);
296
      }
297
    }
298
  }
299
  return out;
300
}
301
302
async function pickProposal(): Promise<ProposalNode | undefined> {
303
  const items: { label: string; description: string; node: ProposalNode }[] = [];
304
  for (const repo of await proposals.repos()) {
305
    for (const p of await proposals.proposalsOf(repo).catch(() => [])) {
306
      items.push({ label: `#${p.number} ${p.title}`, description: `${repo.owner}/${repo.name} · ${p.author} → ${p.target}`, node: { kind: "proposal", repo, p } });
307
    }
308
  }
309
  return (await vscode.window.showQuickPick(items, { placeHolder: "Proposal" }))?.node;
310
}
311
312
/** Commands take a tree node, or the webview's `[base, owner, name, number]`. */
313
async function withProposal(args: unknown[], f: (repo: HubRepo, number: number) => Promise<void>): Promise<void> {
314
  const a = args[0];
315
  if (a && typeof a === "object" && (a as ProposalNode).kind === "proposal") {
316
    const n = a as ProposalNode;
317
    return f(n.repo, n.p.number);
318
  }
319
  if (Array.isArray(a) && a.length === 4) {
320
    const [base, owner, name, number] = a as [string, string, string, number];
321
    const repo = (await proposals.repos()).find((r) => r.base === base && r.owner === owner && r.name === name);
322
    if (repo) {
323
      return f(repo, number);
324
    }
325
  }
326
  const n = await pickProposal();
327
  if (n) {
328
    return f(n.repo, n.p.number);
329
  }
330
}
331
332
async function withIssue(args: unknown[], f: (repo: HubRepo, number: number) => Promise<void>): Promise<void> {
333
  const a = args[0];
334
  if (a && typeof a === "object" && (a as IssueNode).kind === "issue") {
335
    const n = a as IssueNode;
336
    return f(n.repo, n.i.number);
337
  }
338
  if (Array.isArray(a) && a.length === 4) {
339
    const [base, owner, name, number] = a as [string, string, string, number];
340
    const repo = (await proposals.repos()).find((r) => r.base === base && r.owner === owner && r.name === name);
341
    if (repo) {
342
      return f(repo, number);
343
    }
344
  }
345
}
346
347
function runInTerminal(repo: HubRepo, command: string): Promise<void> {
348
  const t = vscode.window.createTerminal({ name: "OxygenHub", cwd: repo.handle.root });
349
  t.show();
350
  t.sendText(command);
351
  return Promise.resolve();
352
}
353
354
/** Fetch the proposal's changes and switch to a local bookmark at its projected heads (spec §5). */
355
async function checkout(repo: HubRepo, number: number): Promise<void> {
356
  const p = await auth.clientFor(repo.base).proposal(repo.owner, repo.name, number);
357
  const prefix = vscode.workspace.getConfiguration("oxygenhub").get<string>("proposalBookmarkPrefix") ?? "proposal/";
358
  const bookmark = `${prefix}${number}`;
359
  try {
360
    await repo.handle.json(["fetch", repo.remote, "--json"]);
361
    const existing = repo.handle.bookmarks.some((b) => b.name === bookmark);
362
    if (!existing) {
363
      await repo.handle.json(["bookmark", "create", bookmark, ...p.projected_heads, "--json"]).catch(() => repo.handle.json(["bookmark", "create", bookmark, "--json"]));
364
    }
365
    await repo.handle.json(["bookmark", "set", bookmark, ...p.projected_heads, "--json"]).catch(() => undefined);
366
    await repo.handle.json(["bookmark", "switch", bookmark, "--json"]);
367
    await repo.handle.refresh();
368
    await review.load(repo, number);
369
    void vscode.window.showInformationMessage(`OxygenHub: checked out #${number} as ${bookmark}; review threads are in place.`);
370
  } catch (e) {
371
    void vscode.window.showErrorMessage(`OxygenHub: could not check out #${number}: ${(e as Error).message}`);
372
  }
373
}
374
375
async function reviewProposal(repo: HubRepo, number: number): Promise<void> {
376
  const verdict = await vscode.window.showQuickPick(
377
    [
378
      { label: "Comment", value: "comment" as const },
379
      { label: "Approve", value: "approve" as const, description: "then seal it with oxy so it counts" },
380
      { label: "Request changes", value: "changes" as const },
381
    ],
382
    { placeHolder: `Review #${number}` },
383
  );
384
  if (!verdict) {
385
    return;
386
  }
387
  const body = (await vscode.window.showInputBox({ prompt: "Summary (optional)" })) ?? "";
388
  try {
389
    await auth.clientFor(repo.base).review(repo.owner, repo.name, number, verdict.value, body);
390
  } catch (e) {
391
    void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`);
392
    return;
393
  }
394
  proposals.refresh();
395
  if (verdict.value === "approve") {
396
    const pick = await vscode.window.showInformationMessage("Approval recorded. Seal it with your key so it counts?", "Seal now");
397
    if (pick === "Seal now") {
398
      await runInTerminal(repo, `oxy seal --proposal ${hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })}`);
399
    }
400
  }
401
}
402
403
async function createProposal(): Promise<void> {
404
  const repos = await proposals.repos();
405
  const repo = repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Repository" }))?.r;
406
  if (!repo) {
407
    return;
408
  }
409
  const source = repo.handle.currentBookmark;
410
  if (!source) {
411
    void vscode.window.showWarningMessage("OxygenHub: no current bookmark.");
412
    return;
413
  }
414
  const remoteBookmarks = await auth.clientFor(repo.base).bookmarks(repo.owner, repo.name).catch(() => []);
415
  const target = await vscode.window.showQuickPick(remoteBookmarks.filter((b) => b.name !== source).map((b) => b.name), { placeHolder: `Propose ${source} into…` });
416
  if (!target) {
417
    return;
418
  }
419
  const title = await vscode.window.showInputBox({ prompt: "Title", value: source });
420
  if (!title) {
421
    return;
422
  }
423
  try {
424
    await repo.handle.json(["push", repo.remote, "--bookmark", source, "--json"]).catch(() => undefined);
425
    const p = await auth.clientFor(repo.base).createProposal(repo.owner, repo.name, { title, body: "", target, source, draft: false });
426
    proposals.refresh();
427
    void vscode.env.openExternal(vscode.Uri.parse(p.url));
428
  } catch (e) {
429
    void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`);
430
  }
431
}
432
433
async function newIssue(): Promise<void> {
434
  const repos = await proposals.repos();
435
  const repo = repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Repository" }))?.r;
436
  if (!repo) {
437
    return;
438
  }
439
  const title = await vscode.window.showInputBox({ prompt: "Issue title" });
440
  if (!title) {
441
    return;
442
  }
443
  const body = (await vscode.window.showInputBox({ prompt: "Description (optional)" })) ?? "";
444
  try {
445
    const i = await auth.clientFor(repo.base).createIssue(repo.owner, repo.name, title, body);
446
    issues.refresh();
447
    void vscode.window.showInformationMessage(`OxygenHub: opened #${i.number}.`);
448
  } catch (e) {
449
    void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`);
450
  }
451
}
452
453
/**
454
 * An issue from the editor (spec §8.1): the selection, or the current line,
455
 * quoted under a permalink to it on the hub. A TODO line suggests the title
456
 * and afterwards records the issue's number.
457
 */
458
async function issueFromSelection(): Promise<void> {
459
  const editor = vscode.window.activeTextEditor;
460
  if (!editor || editor.document.uri.scheme !== "file") {
461
    void vscode.window.showWarningMessage("OxygenHub: open a file of the repository first.");
462
    return;
463
  }
464
  const doc = editor.document;
465
  const file = doc.uri.fsPath;
466
  const repo = (await proposals.repos()).filter((r) => file.startsWith(r.handle.root + "/") || file.startsWith(r.handle.root + "\\")).sort((a, b) => b.handle.root.length - a.handle.root.length)[0];
467
  if (!repo) {
468
    void vscode.window.showWarningMessage("OxygenHub: this file is not in a repository with a hub remote.");
469
    return;
470
  }
471
  const sel = editor.selection;
472
  const startLine = sel.start.line;
473
  const endLine = sel.isEmpty ? sel.start.line : sel.end.character === 0 && sel.end.line > sel.start.line ? sel.end.line - 1 : sel.end.line;
474
  const code = doc.getText(new vscode.Range(startLine, 0, endLine, doc.lineAt(endLine).text.length));
475
  const todoLine = [...Array(endLine - startLine + 1).keys()].map((i) => startLine + i).find((l) => todoTitle(doc.lineAt(l).text) !== undefined);
476
  const title = await vscode.window.showInputBox({ prompt: "Issue title", value: todoLine !== undefined ? todoTitle(doc.lineAt(todoLine).text) : "" });
477
  if (!title) {
478
    return;
479
  }
480
  // Pin the link to the head when the bookmark has one; otherwise follow the bookmark.
481
  const bookmark = repo.handle.bookmarks.find((b) => b.name === repo.handle.currentBookmark);
482
  const pinned = bookmark?.heads.length === 1 && !doc.isDirty;
483
  const reference = pinned ? bookmark!.heads[0] : (repo.handle.currentBookmark ?? "main");
484
  const path = file.slice(repo.handle.root.length + 1).split("\\").join("/");
485
  const permalink = hubUrl(repo.base, repo.owner, repo.name, { kind: "file", reference, path, line: startLine + 1, endLine: endLine + 1 });
486
  const body = issueBody({ permalink, path, startLine: startLine + 1, endLine: endLine + 1, language: doc.languageId === "plaintext" ? "" : doc.languageId, code, pinned });
487
  try {
488
    const client = auth.clientFor(repo.base);
489
    const issue = await client.createIssue(repo.owner, repo.name, title, body);
490
    issues.refresh();
491
    references.clear();
492
    if (todoLine !== undefined) {
493
      const line = doc.lineAt(todoLine);
494
      const annotated = annotateTodo(line.text, issue.number);
495
      if (annotated !== line.text) {
496
        const pick = await vscode.window.showInformationMessage(`OxygenHub: opened #${issue.number}. Record it in the TODO?`, "Add (#" + issue.number + ")");
497
        if (pick) {
498
          await editor.edit((e) => e.replace(line.range, annotated));
499
        }
500
      }
501
    }
502
    await showIssue(auth, repo, issue);
503
  } catch (e) {
504
    void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`);
505
  }
506
}
507
508
/** Mark a draft ready for review, or turn an open proposal back into a draft. */
509
async function setDraft(repo: HubRepo, number: number, draft: boolean): Promise<void> {
510
  try {
511
    await auth.clientFor(repo.base).setProposalDraft(repo.owner, repo.name, number, draft);
512
    proposals.refresh();
513
    vscode.window.setStatusBarMessage(draft ? `$(circle-outline) #${number} is a draft again` : `$(git-pull-request) #${number} is ready for review`, 4000);
514
  } catch (e) {
515
    void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`);
516
  }
517
}
518
519
/** What you can land now, across every repository you write to (#311). */
520
async function landable(): Promise<void> {
521
  const repos = await proposals.repos();
522
  type Item = vscode.QuickPickItem & { url: string; repo?: HubRepo; number: number };
523
  const items: Item[] = [];
524
  for (const base of auth.all().map((x) => x.base)) {
525
    const list = await auth.clientFor(base).userProposals(true).catch(() => undefined);
526
    for (const p of list?.landable ?? []) {
527
      const [owner, name] = p.repository.split("/");
528
      const repo = repos.find((r) => r.base === base && r.owner === owner && r.name === name);
529
      items.push({ label: `$(pass) #${p.number} ${p.title}`, description: `${p.repository} · ${p.author}`, detail: repo ? undefined : "not in this workspace: opens on the hub", url: p.url, repo, number: p.number });
530
    }
531
  }
532
  if (!items.length) {
533
    void vscode.window.showInformationMessage("OxygenHub: nothing is ready for you to land.");
534
    return;
535
  }
536
  const pick = await vscode.window.showQuickPick(items, { placeHolder: "Ready to land: approved, checks passed, no conflicts" });
537
  if (!pick) {
538
    return;
539
  }
540
  if (!pick.repo) {
541
    void vscode.env.openExternal(vscode.Uri.parse(pick.url));
542
    return;
543
  }
544
  const act = await vscode.window.showQuickPick(["Land", "Open"], { placeHolder: `#${pick.number}` });
545
  if (act === "Land") {
546
    await runInTerminal(pick.repo, `oxy proposal land ${pick.url}`);
547
  } else if (act === "Open") {
548
    await vscode.commands.executeCommand("oxygenhub.openProposal", { kind: "proposal", repo: pick.repo, p: await auth.clientFor(pick.repo.base).proposal(pick.repo.owner, pick.repo.name, pick.number) });
549
  }
550
}
551
552
export function deactivate(): void {}