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