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