// Activation: sign in, find the Oxygen extension's API, register the // remote provider, wire the views, the review controller and the commands. import * as vscode from "vscode"; import { HubAuth } from "./auth"; import { ChecksBar } from "./checks"; import { ProposalContentProvider, SCHEME, openAllChanges, openFileDiff, refOf } from "./diff"; import { hubUrl } from "./hub"; import { InboxBar } from "./inbox"; import { draftRelease, ReleasesProvider } from "./releases"; import { ReferenceCompletions, ReferenceHovers, ReferenceIndex, SELECTOR } from "./references"; import { annotateTodo, issueBody, todoTitle } from "./refs"; import { IssueNode, IssuesProvider, showIssue } from "./issues"; import type { OxygenAPI } from "./oxygenapi"; import { HubRepo, ProposalFileNode, ProposalNode, ProposalsProvider, showDetail } from "./proposals"; import { makeProvider } from "./provider"; import { ReviewController } from "./review"; let auth: HubAuth; let oxygen: OxygenAPI | undefined; let proposals: ProposalsProvider; let issues: IssuesProvider; let review: ReviewController; let bar: ChecksBar; let content: ProposalContentProvider; let inbox: InboxBar; let references: ReferenceIndex; let releases: ReleasesProvider; export async function activate(context: vscode.ExtensionContext): Promise { auth = new HubAuth(context.secrets); await auth.load(); context.subscriptions.push(auth); const ext = vscode.extensions.getExtension("oxygen.oxygen-vscode"); oxygen = ext ? ((await ext.activate()) as OxygenAPI | undefined) : undefined; if (!oxygen) { void vscode.window.showWarningMessage("Oxygenhub: the Oxygen extension is not available; install oxygen.oxygen-vscode 0.7.0 or later."); } else { context.subscriptions.push(oxygen.registerRemoteProvider(makeProvider({ bases: () => auth.bases() }))); context.subscriptions.push(oxygen.onDidChangeRepositories(() => refreshAll())); } proposals = new ProposalsProvider(() => oxygen, auth); issues = new IssuesProvider(() => proposals.repos(), auth); review = new ReviewController(auth); bar = new ChecksBar(auth, () => proposals.repos()); inbox = new InboxBar(auth, async (t) => { const repo = (await proposals.repos()).find((r) => r.base === t.base && r.owner === t.owner && r.name === t.name); if (!repo) { return false; } const client = auth.clientFor(repo.base); if (t.kind === "proposal") { const [p, checks] = await Promise.all([client.proposal(repo.owner, repo.name, t.number), client.proposalChecks(repo.owner, repo.name, t.number).catch(() => [])]); showDetail(repo, p, checks); } else { await showIssue(auth, repo, await client.issue(repo.owner, repo.name, t.number)); } return true; }); context.subscriptions.push(inbox); releases = new ReleasesProvider(() => proposals.repos(), auth); context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.releases", releases)); references = new ReferenceIndex(auth, () => proposals.repos()); context.subscriptions.push( vscode.languages.registerCompletionItemProvider(SELECTOR, new ReferenceCompletions(references), "#", "@"), vscode.languages.registerHoverProvider(SELECTOR, new ReferenceHovers(references)), ); content = new ProposalContentProvider(auth); context.subscriptions.push(content, vscode.workspace.registerTextDocumentContentProvider(SCHEME, content)); context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.proposals", proposals), vscode.window.registerTreeDataProvider("oxygenhub.issues", issues), review, bar); context.subscriptions.push(auth.onDidChange(() => refreshAll())); context.subscriptions.push( vscode.commands.registerCommand("oxygenhub.signIn", async () => { const repos = oxygen ? await guessHubs() : []; await auth.signIn(repos[0]); refreshAll(); }), vscode.commands.registerCommand("oxygenhub.signOut", async () => { const pick = await vscode.window.showQuickPick(auth.all().map((s) => s.base), { placeHolder: "Sign out of" }); if (pick) { await auth.removeSession(pick); refreshAll(); } }), vscode.commands.registerCommand("oxygenhub.refresh", () => refreshAll()), vscode.commands.registerCommand("oxygenhub.inbox", () => inbox.show()), vscode.commands.registerCommand("oxygenhub.draftRelease", async (node?: { repo?: HubRepo }) => { const repos = await proposals.repos(); 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); if (!repo) { return; } try { const r = await draftRelease(auth, repo); if (r) { releases.refresh(); const open = await vscode.window.showInformationMessage(`Oxygenhub: released ${r.tag}.`, "Open on Hub"); if (open) { void vscode.env.openExternal(vscode.Uri.parse(r.url)); } } } catch (e) { void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`); } }), vscode.commands.registerCommand("oxygenhub.openProposal", async (node?: ProposalNode) => { const n = node ?? (await pickProposal()); if (!n) { return; } const checks = await auth.clientFor(n.repo.base).proposalChecks(n.repo.owner, n.repo.name, n.p.number).catch(() => []); showDetail(n.repo, n.p, checks); }), vscode.commands.registerCommand("oxygenhub.openOnHub", (node?: ProposalNode | IssueNode) => { if (!node) { return; } const target = node.kind === "proposal" ? { kind: "proposal", number: node.p.number } : { kind: "issue", number: node.i.number }; void vscode.env.openExternal(vscode.Uri.parse(hubUrl(node.repo.base, node.repo.owner, node.repo.name, target))); }), vscode.commands.registerCommand("oxygenhub.openProposalFile", async (node: ProposalFileNode) => { await openFileDiff(node.repo, node.p.number, node.f); await review.loadDiff(node.repo, node.p.number).catch(() => undefined); }), vscode.commands.registerCommand("oxygenhub.openProposalChanges", (...args: unknown[]) => withProposal(args, async (repo, number) => { try { const client = auth.clientFor(repo.base); const [p, files] = await Promise.all([client.proposal(repo.owner, repo.name, number), client.proposalFiles(repo.owner, repo.name, number)]); await openAllChanges(repo, p, files); await review.loadDiff(repo, number); } catch (e) { void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`); } }), ), vscode.commands.registerCommand("oxygenhub.checkoutProposal", (...args: unknown[]) => withProposal(args, checkout)), vscode.commands.registerCommand("oxygenhub.review", (...args: unknown[]) => withProposal(args, reviewProposal)), 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 })}`))), 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 })}`))), vscode.commands.registerCommand("oxygenhub.closeProposal", (...args: unknown[]) => withProposal(args, async (repo, number) => { void vscode.env.openExternal(vscode.Uri.parse(hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number }))); }), ), vscode.commands.registerCommand("oxygenhub.createProposal", () => createProposal()), vscode.commands.registerCommand("oxygenhub.newIssue", () => newIssue()), vscode.commands.registerCommand("oxygenhub.issueFromSelection", () => issueFromSelection()), vscode.commands.registerCommand("oxygenhub.openIssue", async (node?: IssueNode) => node && showIssue(auth, node.repo, node.i)), vscode.commands.registerCommand("oxygenhub.commentIssue", (...args: unknown[]) => withIssue(args, async (repo, number) => { const body = await vscode.window.showInputBox({ prompt: `Comment on #${number}` }); if (body) { await auth.clientFor(repo.base).commentIssue(repo.owner, repo.name, number, body); issues.refresh(); } }), ), 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()))), 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()))), vscode.commands.registerCommand("oxygenhub.assignMe", (...args: unknown[]) => withIssue(args, async (repo, number) => { void vscode.window.showInformationMessage(`Oxygenhub: assigning is on the hub for now: ${hubUrl(repo.base, repo.owner, repo.name, { kind: "issue", number })}`); }), ), vscode.commands.registerCommand("oxygenhub.reply", (reply: vscode.CommentReply) => review.reply(reply)), vscode.commands.registerCommand("oxygenhub.newThread", (reply: vscode.CommentReply) => review.reply(reply)), vscode.commands.registerCommand("oxygenhub.resolveThread", (thread: vscode.CommentThread) => { thread.state = thread.state === vscode.CommentThreadState.Resolved ? vscode.CommentThreadState.Unresolved : vscode.CommentThreadState.Resolved; }), ); bar.start(); inbox.start(); } function refreshAll(): void { proposals.refresh(); references.clear(); refreshOpenDiffs(); issues.refresh(); releases.refresh(); void bar.refresh(); void inbox.refresh(); } /** Reload the sides of open proposal diffs, which may have gained changes. */ function refreshOpenDiffs(): void { const seen = new Set(); for (const doc of vscode.workspace.textDocuments) { const r = refOf(doc.uri); const key = r && `${r.base}/${r.owner}/${r.name}#${r.number}`; if (r && key && !seen.has(key)) { seen.add(key); content.refresh(r.base, r.owner, r.name, r.number); } } } async function guessHubs(): Promise { const out: string[] = []; for (const r of oxygen?.repositories ?? []) { for (const remote of await r.remotes().catch(() => [])) { const m = /^(https?:\/\/[^/]+)\/[^/]+\/[^/]+\/?$/.exec(remote.url); if (m && !out.includes(m[1])) { out.push(m[1]); } } } return out; } async function pickProposal(): Promise { const items: { label: string; description: string; node: ProposalNode }[] = []; for (const repo of await proposals.repos()) { for (const p of await proposals.proposalsOf(repo).catch(() => [])) { items.push({ label: `#${p.number} ${p.title}`, description: `${repo.owner}/${repo.name} · ${p.author} → ${p.target}`, node: { kind: "proposal", repo, p } }); } } return (await vscode.window.showQuickPick(items, { placeHolder: "Proposal" }))?.node; } /** Commands take a tree node, or the webview's `[base, owner, name, number]`. */ async function withProposal(args: unknown[], f: (repo: HubRepo, number: number) => Promise): Promise { const a = args[0]; if (a && typeof a === "object" && (a as ProposalNode).kind === "proposal") { const n = a as ProposalNode; return f(n.repo, n.p.number); } if (Array.isArray(a) && a.length === 4) { const [base, owner, name, number] = a as [string, string, string, number]; const repo = (await proposals.repos()).find((r) => r.base === base && r.owner === owner && r.name === name); if (repo) { return f(repo, number); } } const n = await pickProposal(); if (n) { return f(n.repo, n.p.number); } } async function withIssue(args: unknown[], f: (repo: HubRepo, number: number) => Promise): Promise { const a = args[0]; if (a && typeof a === "object" && (a as IssueNode).kind === "issue") { const n = a as IssueNode; return f(n.repo, n.i.number); } if (Array.isArray(a) && a.length === 4) { const [base, owner, name, number] = a as [string, string, string, number]; const repo = (await proposals.repos()).find((r) => r.base === base && r.owner === owner && r.name === name); if (repo) { return f(repo, number); } } } function runInTerminal(repo: HubRepo, command: string): Promise { const t = vscode.window.createTerminal({ name: "Oxygenhub", cwd: repo.handle.root }); t.show(); t.sendText(command); return Promise.resolve(); } /** Fetch the proposal's changes and switch to a local bookmark at its projected heads (spec §5). */ async function checkout(repo: HubRepo, number: number): Promise { const p = await auth.clientFor(repo.base).proposal(repo.owner, repo.name, number); const prefix = vscode.workspace.getConfiguration("oxygenhub").get("proposalBookmarkPrefix") ?? "proposal/"; const bookmark = `${prefix}${number}`; try { await repo.handle.json(["fetch", repo.remote, "--json"]); const existing = repo.handle.bookmarks.some((b) => b.name === bookmark); if (!existing) { await repo.handle.json(["bookmark", "create", bookmark, ...p.projected_heads, "--json"]).catch(() => repo.handle.json(["bookmark", "create", bookmark, "--json"])); } await repo.handle.json(["bookmark", "set", bookmark, ...p.projected_heads, "--json"]).catch(() => undefined); await repo.handle.json(["bookmark", "switch", bookmark, "--json"]); await repo.handle.refresh(); await review.load(repo, number); void vscode.window.showInformationMessage(`Oxygenhub: checked out #${number} as ${bookmark}; review threads are in place.`); } catch (e) { void vscode.window.showErrorMessage(`Oxygenhub: could not check out #${number}: ${(e as Error).message}`); } } async function reviewProposal(repo: HubRepo, number: number): Promise { const verdict = await vscode.window.showQuickPick( [ { label: "Comment", value: "comment" as const }, { label: "Approve", value: "approve" as const, description: "then seal it with oxy so it counts" }, { label: "Request changes", value: "changes" as const }, ], { placeHolder: `Review #${number}` }, ); if (!verdict) { return; } const body = (await vscode.window.showInputBox({ prompt: "Summary (optional)" })) ?? ""; try { await auth.clientFor(repo.base).review(repo.owner, repo.name, number, verdict.value, body); } catch (e) { void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`); return; } proposals.refresh(); if (verdict.value === "approve") { const pick = await vscode.window.showInformationMessage("Approval recorded. Seal it with your key so it counts?", "Seal now"); if (pick === "Seal now") { await runInTerminal(repo, `oxy seal --proposal ${hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })}`); } } } async function createProposal(): Promise { const repos = await proposals.repos(); const repo = repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Repository" }))?.r; if (!repo) { return; } const source = repo.handle.currentBookmark; if (!source) { void vscode.window.showWarningMessage("Oxygenhub: no current bookmark."); return; } const remoteBookmarks = await auth.clientFor(repo.base).bookmarks(repo.owner, repo.name).catch(() => []); const target = await vscode.window.showQuickPick(remoteBookmarks.filter((b) => b.name !== source).map((b) => b.name), { placeHolder: `Propose ${source} into…` }); if (!target) { return; } const title = await vscode.window.showInputBox({ prompt: "Title", value: source }); if (!title) { return; } try { await repo.handle.json(["push", repo.remote, "--bookmark", source, "--json"]).catch(() => undefined); const p = await auth.clientFor(repo.base).createProposal(repo.owner, repo.name, { title, body: "", target, source, draft: false }); proposals.refresh(); void vscode.env.openExternal(vscode.Uri.parse(p.url)); } catch (e) { void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`); } } async function newIssue(): Promise { const repos = await proposals.repos(); const repo = repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Repository" }))?.r; if (!repo) { return; } const title = await vscode.window.showInputBox({ prompt: "Issue title" }); if (!title) { return; } const body = (await vscode.window.showInputBox({ prompt: "Description (optional)" })) ?? ""; try { const i = await auth.clientFor(repo.base).createIssue(repo.owner, repo.name, title, body); issues.refresh(); void vscode.window.showInformationMessage(`Oxygenhub: opened #${i.number}.`); } catch (e) { void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`); } } /** * An issue from the editor (spec §8.1): the selection, or the current line, * quoted under a permalink to it on the hub. A TODO line suggests the title * and afterwards records the issue's number. */ async function issueFromSelection(): Promise { const editor = vscode.window.activeTextEditor; if (!editor || editor.document.uri.scheme !== "file") { void vscode.window.showWarningMessage("Oxygenhub: open a file of the repository first."); return; } const doc = editor.document; const file = doc.uri.fsPath; 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]; if (!repo) { void vscode.window.showWarningMessage("Oxygenhub: this file is not in a repository with a hub remote."); return; } const sel = editor.selection; const startLine = sel.start.line; const endLine = sel.isEmpty ? sel.start.line : sel.end.character === 0 && sel.end.line > sel.start.line ? sel.end.line - 1 : sel.end.line; const code = doc.getText(new vscode.Range(startLine, 0, endLine, doc.lineAt(endLine).text.length)); const todoLine = [...Array(endLine - startLine + 1).keys()].map((i) => startLine + i).find((l) => todoTitle(doc.lineAt(l).text) !== undefined); const title = await vscode.window.showInputBox({ prompt: "Issue title", value: todoLine !== undefined ? todoTitle(doc.lineAt(todoLine).text) : "" }); if (!title) { return; } // Pin the link to the head when the bookmark has one; otherwise follow the bookmark. const bookmark = repo.handle.bookmarks.find((b) => b.name === repo.handle.currentBookmark); const pinned = bookmark?.heads.length === 1 && !doc.isDirty; const reference = pinned ? bookmark!.heads[0] : (repo.handle.currentBookmark ?? "main"); const path = file.slice(repo.handle.root.length + 1).split("\\").join("/"); const permalink = hubUrl(repo.base, repo.owner, repo.name, { kind: "file", reference, path, line: startLine + 1, endLine: endLine + 1 }); const body = issueBody({ permalink, path, startLine: startLine + 1, endLine: endLine + 1, language: doc.languageId === "plaintext" ? "" : doc.languageId, code, pinned }); try { const client = auth.clientFor(repo.base); const issue = await client.createIssue(repo.owner, repo.name, title, body); issues.refresh(); references.clear(); if (todoLine !== undefined) { const line = doc.lineAt(todoLine); const annotated = annotateTodo(line.text, issue.number); if (annotated !== line.text) { const pick = await vscode.window.showInformationMessage(`Oxygenhub: opened #${issue.number}. Record it in the TODO?`, "Add (#" + issue.number + ")"); if (pick) { await editor.edit((e) => e.replace(line.range, annotated)); } } } await showIssue(auth, repo, issue); } catch (e) { void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`); } } export function deactivate(): void {}