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