OXYGENHUB
◆ main oxygenhub-vscode / src / flow.ts
17.5 KiB · last changed OxygenHub 0.13.0: Drops in the editor with Share as a Drop (closes #7); Flow approvals, expedite, reports, timings and test results (closes #8); ready/draft, landable, trust reports, asset downloads and new repositories (closes #9) 6 days ago source blame history raw
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";
10
import { duration, jobTimings, live, look, newlyFinished, runDescription, runLabel, subject, waitingApproval } from "./flowview";
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);
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";
178
        item.command = { title: "Show Log", command: "oxygenhub.flowLog", arguments: [node] };
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;
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);
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);
280
      await this.refresh();
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
      }
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
}