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