263de209 Andy McCall 1 month ago ↶
1
// The Flow view (hub spec §13.2 in the editor): each repository's recent
2
// OxygenFlow runs and their jobs, coloured and turning while they run; a
3
// job's log streamed into an editor tab; Run pipeline and Re-run; a word when
4
// a run finishes; and a status bar item for the current bookmark's newest run.
5
6
import * as vscode from "vscode";
7
import type * as api from "./hubapi";
8
import { HubAuth } from "./auth";
9
import type { HubRepo } from "./proposals";
991516a1 Andy McCall 6 days ago ↶
10
import { duration, jobTimings, live, look, newlyFinished, runDescription, runLabel, subject, waitingApproval } from "./flowview";
263de209 Andy McCall 1 month ago ↶
11
12
export const LOG_SCHEME = "oxygenhub-flow";
13
14
type Node =
15
| { kind: "repo"; repo: HubRepo }
16
| { kind: "run"; repo: HubRepo; run: api.FlowRun }
17
| { kind: "job"; repo: HubRepo; run: api.FlowRun; job: api.FlowJob };
18
19
export type RunNode = Extract<Node, { kind: "run" }>;
20
export type JobNode = Extract<Node, { kind: "job" }>;
21
22
const RUNS = 15;
23
24
function icon(status: string): vscode.ThemeIcon {
25
const l = look(status);
26
return new vscode.ThemeIcon(l.icon, new vscode.ThemeColor(l.color));
27
}
28
29
function key(repo: HubRepo, run: api.FlowRun): string {
30
return `${repo.base}/${repo.owner}/${repo.name}#${run.number}`;
31
}
32
33
export class FlowProvider implements vscode.TreeDataProvider<Node>, vscode.Disposable {
34
private readonly emitter = new vscode.EventEmitter<Node | void>();
35
readonly onDidChangeTreeData = this.emitter.event;
36
/** Runs by repository, newest first, as last fetched. */
37
private runs = new Map<string, { repo: HubRepo; runs: api.FlowRun[] }>();
38
/** Every run's status at the last look, to notice runs that finish. */
39
private seen = new Map<string, string>();
40
private timer: NodeJS.Timeout | undefined;
41
private readonly bar: vscode.StatusBarItem;
42
private readonly disposables: vscode.Disposable[] = [];
43
44
constructor(
45
private readonly repos: () => Promise<HubRepo[]>,
46
private readonly auth: HubAuth,
47
) {
48
this.bar = vscode.window.createStatusBarItem("oxygenhub.flow", vscode.StatusBarAlignment.Left, 98);
49
this.bar.name = "OxygenFlow";
50
this.disposables.push(this.bar, vscode.window.onDidChangeActiveTextEditor(() => this.updateBar()));
51
}
52
53
dispose(): void {
54
if (this.timer) {
55
clearTimeout(this.timer);
56
}
57
this.disposables.forEach((d) => d.dispose());
58
}
59
60
/** Fetch every repository's runs again, redraw, and keep polling while any is live. */
61
async refresh(): Promise<void> {
62
const repos = await this.repos().catch(() => [] as HubRepo[]);
63
const next = new Map<string, { repo: HubRepo; runs: api.FlowRun[] }>();
64
await Promise.all(
65
repos.map(async (repo) => {
66
const runs = await this.auth.clientFor(repo.base).flowRuns(repo.owner, repo.name, RUNS).catch(() => [] as api.FlowRun[]);
67
next.set(`${repo.base}/${repo.owner}/${repo.name}`, { repo, runs });
68
}),
69
);
70
const all = [...next.values()].flatMap(({ repo, runs }) => runs.map((run) => ({ key: key(repo, run), repo, run })));
71
const firstLook = this.seen.size === 0;
72
for (const done of newlyFinished(this.seen, all)) {
73
const found = all.find((a) => a.key === done.key);
74
if (found && !firstLook) {
75
void this.announce(found.repo, found.run);
76
}
77
}
78
this.seen = new Map(all.map((a) => [a.key, a.run.status]));
79
this.runs = next;
80
this.emitter.fire();
81
this.updateBar();
82
if (this.timer) {
83
clearTimeout(this.timer);
84
}
85
const anyLive = all.some((a) => live(a.run.status));
86
const every = vscode.workspace.getConfiguration("oxygenhub").get<number>("flowPollSeconds", 5);
87
this.timer = setTimeout(() => void this.refresh(), (anyLive ? every : Math.max(every, 60)) * 1000);
88
}
89
90
/** A run finished: say so, with the way to its log when it failed. */
91
private async announce(repo: HubRepo, run: api.FlowRun): Promise<void> {
92
if (!vscode.workspace.getConfiguration("oxygenhub").get<boolean>("flowNotifications", true)) {
93
return;
94
}
95
const full = await this.auth.clientFor(repo.base).flowRun(repo.owner, repo.name, run.number).catch(() => run);
96
const text = `OxygenFlow run #${run.number} on ${subject(run)} (${repo.name}) ${look(run.status).word}`;
97
if (run.status === "passed") {
98
const pick = await vscode.window.showInformationMessage(`✔ ${text}.`, "Open Run");
99
if (pick) {
100
void vscode.env.openExternal(vscode.Uri.parse(run.url));
101
}
102
return;
103
}
104
const failed = (full.jobs ?? []).find((j) => j.status === "failed" || j.status === "error");
105
const pick = await vscode.window.showErrorMessage(`✘ ${text}${failed ? `: ${failed.name}` : ""}.`, ...(failed ? ["Show Log"] : []), "Open Run");
106
if (pick === "Show Log" && failed) {
107
void openLog(repo, full, failed);
108
} else if (pick === "Open Run") {
109
void vscode.env.openExternal(vscode.Uri.parse(run.url));
110
}
111
}
112
113
/** The status bar: the newest run of the active repository's current bookmark. */
114
private updateBar(): void {
115
const file = vscode.window.activeTextEditor?.document.uri.fsPath;
116
const entries = [...this.runs.values()];
117
const entry = entries.find((e) => file && file.startsWith(e.repo.handle.root)) ?? (entries.length === 1 ? entries[0] : undefined);
118
const bookmark = entry?.repo.handle.currentBookmark;
119
const run = entry?.runs.find((r) => r.bookmark === bookmark) ?? undefined;
120
if (!entry || !run) {
121
this.bar.hide();
122
return;
123
}
124
const l = look(run.status);
125
this.bar.text = `$(${l.icon}) Flow #${run.number}`;
126
this.bar.tooltip = new vscode.MarkdownString(`**OxygenFlow** run #${run.number} on \`${subject(run)}\`\n\n${runDescription(run)}\n\nClick for recent runs.`);
127
this.bar.backgroundColor = run.status === "failed" || run.status === "error" ? new vscode.ThemeColor("statusBarItem.errorBackground") : undefined;
128
this.bar.color = run.status === "passed" ? new vscode.ThemeColor("testing.iconPassed") : undefined;
129
this.bar.command = { title: "Recent runs", command: "oxygenhub.flowRecent", arguments: [entry.repo] };
130
this.bar.show();
131
}
132
133
/** Recent runs of a repository to pick from; the pick opens on the hub or shows its failed job's log. */
134
async recent(repo?: HubRepo): Promise<void> {
135
const entry = repo ? this.runs.get(`${repo.base}/${repo.owner}/${repo.name}`) : [...this.runs.values()][0];
136
if (!entry) {
137
return;
138
}
139
type Item = vscode.QuickPickItem & { run: api.FlowRun };
140
const items: Item[] = entry.runs.map((run) => ({ label: `$(${look(run.status).icon}) ${runLabel(run)}`, description: runDescription(run), run }));
141
const pick = await vscode.window.showQuickPick(items, { placeHolder: `OxygenFlow runs of ${entry.repo.owner}/${entry.repo.name}` });
142
if (pick) {
143
void vscode.env.openExternal(vscode.Uri.parse(pick.run.url));
144
}
145
}
146
147
getTreeItem(node: Node): vscode.TreeItem {
148
switch (node.kind) {
149
case "repo": {
150
const item = new vscode.TreeItem(`${node.repo.owner}/${node.repo.name}`, vscode.TreeItemCollapsibleState.Expanded);
151
item.iconPath = new vscode.ThemeIcon("repo");
152
item.contextValue = "flowRepo";
153
return item;
154
}
155
case "run": {
156
const run = node.run;
157
const item = new vscode.TreeItem(runLabel(run), live(run.status) ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed);
158
item.id = `${key(node.repo, run)}@${run.status}`;
159
item.description = runDescription(run);
160
item.iconPath = icon(run.status);
161
item.tooltip = new vscode.MarkdownString(`**Run #${run.number}** on \`${subject(run)}\` — ${look(run.status).word}\n\n${runDescription(run)}${run.error ? `\n\n\`\`\`\n${run.error}\n\`\`\`` : ""}`);
162
item.contextValue = live(run.status) ? "flowRunLive" : "flowRun";
163
item.accessibilityInformation = { label: `Run ${run.number} on ${subject(run)}, ${look(run.status).word}` };
164
return item;
165
}
166
case "job": {
167
const j = node.job;
168
const item = new vscode.TreeItem(j.name, vscode.TreeItemCollapsibleState.None);
169
const took = j.started_ms ? duration((j.finished_ms ?? Date.now()) - j.started_ms) : "";
170
item.description = [look(j.status).word, took, j.runner ? `on ${j.runner}` : ""].filter(Boolean).join(" · ");
171
item.iconPath = icon(j.status);
991516a1 Andy McCall 6 days ago ↶
172
const waiting = waitingApproval(j);
173
if (waiting) {
174
item.description = "waiting for approval";
175
item.iconPath = new vscode.ThemeIcon("shield", new vscode.ThemeColor("testing.iconQueued"));
176
}
177
item.contextValue = waiting ? "flowJobWaiting" : "flowJob";
263de209 Andy McCall 1 month ago ↶
178
item.command = { title: "Show Log", command: "oxygenhub.flowLog", arguments: [node] };
991516a1 Andy McCall 6 days ago ↶
179
const timings = jobTimings(j);
180
const md = new vscode.MarkdownString(`**${j.name}** — ${waiting ? "waiting for approval" : look(j.status).word}${j.error ? `: ${j.error}` : ""}`);
181
if (timings.length) {
182
md.appendMarkdown(`\n\n${timings.join(" · ")}`);
183
}
184
md.appendMarkdown(waiting ? "\n\nApprove or reject it from its menu." : "\n\nClick for its log.");
185
item.tooltip = md;
263de209 Andy McCall 1 month ago ↶
186
return item;
187
}
188
}
189
}
190
191
async getChildren(node?: Node): Promise<Node[]> {
192
if (!node) {
193
if (this.runs.size === 0) {
194
await this.refresh();
195
}
196
return [...this.runs.values()].map(({ repo }) => ({ kind: "repo" as const, repo }));
197
}
198
if (node.kind === "repo") {
199
const entry = this.runs.get(`${node.repo.base}/${node.repo.owner}/${node.repo.name}`);
200
return (entry?.runs ?? []).map((run) => ({ kind: "run" as const, repo: node.repo, run }));
201
}
202
if (node.kind === "run") {
203
const full = await this.auth.clientFor(node.repo.base).flowRun(node.repo.owner, node.repo.name, node.run.number).catch(() => node.run);
204
return (full.jobs ?? []).map((job) => ({ kind: "job" as const, repo: node.repo, run: full, job }));
205
}
206
return [];
207
}
208
209
/** Run pipeline: a bookmark's current state (the current one first) or one change. */
210
async start(repo?: HubRepo): Promise<void> {
211
const repos = repo ? [repo] : [...this.runs.values()].map((e) => e.repo);
212
const target =
213
repos.length === 1
214
? repos[0]
215
: (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Run the pipeline of" }))?.r;
216
if (!target) {
217
return;
218
}
219
const current = target.handle.currentBookmark;
220
const names = [...new Set([current, ...target.handle.bookmarks.map((b) => b.name)].filter((b): b is string => !!b))];
221
const pick = await vscode.window.showQuickPick(
222
[...names.map((n) => ({ label: `$(bookmark) ${n}`, description: n === current ? "current" : "", bookmark: n })), { label: "$(git-commit) A change…", description: "run one change's own state", bookmark: "" }],
223
{ placeHolder: `Run the pipeline of ${target.owner}/${target.name} on` },
224
);
225
if (!pick) {
226
return;
227
}
228
let body: { bookmark?: string; change?: string } = { bookmark: pick.bookmark };
229
if (!pick.bookmark) {
230
const change = await vscode.window.showInputBox({ prompt: "The change's full hash", validateInput: (v) => (/^#?[0-9a-f]{64}$/.test(v.trim()) ? undefined : "64 hex digits") });
231
if (!change) {
232
return;
233
}
234
body = { change: change.trim().replace(/^#/, "") };
235
}
236
try {
237
const started = await this.auth.clientFor(target.base).startFlowRun(target.owner, target.name, body);
238
vscode.window.setStatusBarMessage(`$(play) OxygenFlow run #${started.number} started`, 4000);
991516a1 Andy McCall 6 days ago ↶
239
await this.refresh();
240
} catch (e) {
241
vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`);
242
}
243
}
244
245
/** Approve or reject a job waiting on its approval rules. */
246
async decide(node: JobNode, approve: boolean): Promise<void> {
247
if (!approve) {
248
const ok = await vscode.window.showWarningMessage(`Reject ${node.job.name} in run #${node.run.number}?`, { modal: true, detail: "The job does not run, and the run fails." }, "Reject");
249
if (!ok) {
250
return;
251
}
252
}
253
try {
254
await this.auth.clientFor(node.repo.base).decideFlowJob(node.repo.owner, node.repo.name, node.run.number, node.job.name, approve);
255
vscode.window.setStatusBarMessage(`$(${approve ? "pass" : "close"}) ${node.job.name} ${approve ? "approved" : "rejected"}`, 4000);
256
await this.refresh();
257
} catch (e) {
258
vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`);
259
}
260
}
261
262
/** Make a live run urgent, saying why: it goes ahead of the queue, within the owner's cap. */
263
async expedite(node: RunNode): Promise<void> {
264
const reason = await vscode.window.showInputBox({ prompt: `Why is run #${node.run.number} urgent?`, placeHolder: "a fix for the outage", validateInput: (v) => (v.trim() ? undefined : "Say why; the run page shows it") });
265
if (!reason) {
266
return;
267
}
268
try {
269
await this.auth.clientFor(node.repo.base).expediteFlowRun(node.repo.owner, node.repo.name, node.run.number, reason.trim());
270
vscode.window.setStatusBarMessage(`$(rocket) Run #${node.run.number} expedited`, 4000);
271
await this.refresh();
272
} catch (e) {
273
vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`);
274
}
275
}
276
277
async cancel(node: RunNode): Promise<void> {
278
try {
279
await this.auth.clientFor(node.repo.base).cancelFlowRun(node.repo.owner, node.repo.name, node.run.number);
263de209 Andy McCall 1 month ago ↶
280
await this.refresh();
991516a1 Andy McCall 6 days ago ↶
281
} catch (e) {
282
vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`);
283
}
284
}
285
286
/** The reports a job published: pick one to open it on the hub. */
287
async reports(node: JobNode): Promise<void> {
288
try {
289
const list = await this.auth.clientFor(node.repo.base).jobReports(node.repo.owner, node.repo.name, node.run.number, node.job.name);
290
if (!list.length) {
291
void vscode.window.showInformationMessage(`OxygenFlow: ${node.job.name} published no report.`);
292
return;
293
}
294
const pick = await vscode.window.showQuickPick(
295
list.map((r) => ({
296
label: `$(graph) ${r.title || r.name}`,
297
description: r.summary,
298
detail: r.metrics.map((m) => `${m.key} ${m.value}${m.unit ? ` ${m.unit}` : ""}`).join(" · ") || undefined,
299
r,
300
})),
301
{ placeHolder: `Reports of ${node.job.name} in run #${node.run.number}` },
302
);
303
if (pick) {
304
void vscode.env.openExternal(vscode.Uri.parse(pick.r.url.startsWith("/") ? `${node.repo.base}${pick.r.url}` : pick.r.url));
305
}
263de209 Andy McCall 1 month ago ↶
306
} catch (e) {
307
vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`);
308
}
309
}
310
311
async rerun(node: RunNode): Promise<void> {
312
try {
313
const started = await this.auth.clientFor(node.repo.base).rerunFlowRun(node.repo.owner, node.repo.name, node.run.number);
314
vscode.window.setStatusBarMessage(`$(debug-rerun) OxygenFlow run #${started.number} started`, 4000);
315
await this.refresh();
316
} catch (e) {
317
vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`);
318
}
319
}
320
}
321
322
/** Streamed job logs, as read-only documents that grow while the job runs. */
323
export class FlowLogs implements vscode.TextDocumentContentProvider, vscode.Disposable {
324
private readonly emitter = new vscode.EventEmitter<vscode.Uri>();
325
readonly onDidChange = this.emitter.event;
326
private readonly logs = new Map<string, { text: string; after: number; timer?: NodeJS.Timeout }>();
327
328
constructor(private readonly auth: HubAuth) {}
329
330
dispose(): void {
331
for (const l of this.logs.values()) {
332
if (l.timer) {
333
clearTimeout(l.timer);
334
}
335
}
336
}
337
338
provideTextDocumentContent(uri: vscode.Uri): string {
339
return this.logs.get(uri.toString())?.text ?? "Loading…\n";
340
}
341
342
/** Start (or keep) following a job's log at `uri`. */
343
follow(uri: vscode.Uri, repo: HubRepo, run: number, job: string): void {
344
if (this.logs.has(uri.toString())) {
345
return;
346
}
347
const entry = { text: "", after: 0, timer: undefined as NodeJS.Timeout | undefined };
348
this.logs.set(uri.toString(), entry);
349
const client = this.auth.clientFor(repo.base);
350
const tick = async (): Promise<void> => {
351
try {
352
const lines = await client.flowLog(repo.owner, repo.name, run, job, entry.after);
353
if (lines.length) {
354
entry.text += lines.map((l) => (l.stream === "flow" ? `▸ ${l.text}` : l.text)).join("\n") + "\n";
355
entry.after = lines[lines.length - 1].seq;
356
this.emitter.fire(uri);
357
}
358
const r = await client.flowRun(repo.owner, repo.name, run);
359
const j = (r.jobs ?? []).find((x) => x.name === job);
360
if (j && live(j.status)) {
361
entry.timer = setTimeout(() => void tick(), 2000);
362
} else if (j) {
363
entry.text += `\n── ${job}: ${look(j.status).word}${j.exit_code !== null && j.exit_code !== undefined ? ` (exit ${j.exit_code})` : ""}${j.error ? `: ${j.error}` : ""} ──\n`;
364
this.emitter.fire(uri);
365
}
366
} catch (e) {
367
entry.text += `\n(could not read the log: ${(e as Error).message})\n`;
368
this.emitter.fire(uri);
369
}
370
};
371
void tick();
372
}
373
}
374
375
let logs: FlowLogs | undefined;
376
377
export function useLogs(l: FlowLogs): void {
378
logs = l;
379
}
380
381
/** Open a job's log in an editor tab, following it while the job runs. */
382
export async function openLog(repo: HubRepo, run: api.FlowRun, job: api.FlowJob): Promise<void> {
383
const uri = vscode.Uri.from({ scheme: LOG_SCHEME, path: `/${repo.owner}/${repo.name}/run-${run.number}/${job.name}.log`, query: repo.base });
384
logs?.follow(uri, repo, run.number, job.name);
385
const doc = await vscode.workspace.openTextDocument(uri);
386
await vscode.languages.setTextDocumentLanguage(doc, "log");
387
await vscode.window.showTextDocument(doc, { preview: false });
388
}