OXYGENHUB
# f162b1532f9f oxygenhub-vscode / src / flow.ts
14.0 KiB · last changed Flow view and a live Flow status bar item (0.8.0) 1 month 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, live, look, newlyFinished, runDescription, runLabel, subject } 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
        item.contextValue = "flowJob";
173
        item.command = { title: "Show Log", command: "oxygenhub.flowLog", arguments: [node] };
174
        item.tooltip = j.error ? new vscode.MarkdownString(`**${j.name}**: ${j.error}`) : `${j.name} — ${look(j.status).word}; click for its log`;
175
        return item;
176
      }
177
    }
178
  }
179
180
  async getChildren(node?: Node): Promise<Node[]> {
181
    if (!node) {
182
      if (this.runs.size === 0) {
183
        await this.refresh();
184
      }
185
      return [...this.runs.values()].map(({ repo }) => ({ kind: "repo" as const, repo }));
186
    }
187
    if (node.kind === "repo") {
188
      const entry = this.runs.get(`${node.repo.base}/${node.repo.owner}/${node.repo.name}`);
189
      return (entry?.runs ?? []).map((run) => ({ kind: "run" as const, repo: node.repo, run }));
190
    }
191
    if (node.kind === "run") {
192
      const full = await this.auth.clientFor(node.repo.base).flowRun(node.repo.owner, node.repo.name, node.run.number).catch(() => node.run);
193
      return (full.jobs ?? []).map((job) => ({ kind: "job" as const, repo: node.repo, run: full, job }));
194
    }
195
    return [];
196
  }
197
198
  /** Run pipeline: a bookmark's current state (the current one first) or one change. */
199
  async start(repo?: HubRepo): Promise<void> {
200
    const repos = repo ? [repo] : [...this.runs.values()].map((e) => e.repo);
201
    const target =
202
      repos.length === 1
203
        ? repos[0]
204
        : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Run the pipeline of" }))?.r;
205
    if (!target) {
206
      return;
207
    }
208
    const current = target.handle.currentBookmark;
209
    const names = [...new Set([current, ...target.handle.bookmarks.map((b) => b.name)].filter((b): b is string => !!b))];
210
    const pick = await vscode.window.showQuickPick(
211
      [...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: "" }],
212
      { placeHolder: `Run the pipeline of ${target.owner}/${target.name} on` },
213
    );
214
    if (!pick) {
215
      return;
216
    }
217
    let body: { bookmark?: string; change?: string } = { bookmark: pick.bookmark };
218
    if (!pick.bookmark) {
219
      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") });
220
      if (!change) {
221
        return;
222
      }
223
      body = { change: change.trim().replace(/^#/, "") };
224
    }
225
    try {
226
      const started = await this.auth.clientFor(target.base).startFlowRun(target.owner, target.name, body);
227
      vscode.window.setStatusBarMessage(`$(play) OxygenFlow run #${started.number} started`, 4000);
228
      await this.refresh();
229
    } catch (e) {
230
      vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`);
231
    }
232
  }
233
234
  async rerun(node: RunNode): Promise<void> {
235
    try {
236
      const started = await this.auth.clientFor(node.repo.base).rerunFlowRun(node.repo.owner, node.repo.name, node.run.number);
237
      vscode.window.setStatusBarMessage(`$(debug-rerun) OxygenFlow run #${started.number} started`, 4000);
238
      await this.refresh();
239
    } catch (e) {
240
      vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`);
241
    }
242
  }
243
}
244
245
/** Streamed job logs, as read-only documents that grow while the job runs. */
246
export class FlowLogs implements vscode.TextDocumentContentProvider, vscode.Disposable {
247
  private readonly emitter = new vscode.EventEmitter<vscode.Uri>();
248
  readonly onDidChange = this.emitter.event;
249
  private readonly logs = new Map<string, { text: string; after: number; timer?: NodeJS.Timeout }>();
250
251
  constructor(private readonly auth: HubAuth) {}
252
253
  dispose(): void {
254
    for (const l of this.logs.values()) {
255
      if (l.timer) {
256
        clearTimeout(l.timer);
257
      }
258
    }
259
  }
260
261
  provideTextDocumentContent(uri: vscode.Uri): string {
262
    return this.logs.get(uri.toString())?.text ?? "Loading…\n";
263
  }
264
265
  /** Start (or keep) following a job's log at `uri`. */
266
  follow(uri: vscode.Uri, repo: HubRepo, run: number, job: string): void {
267
    if (this.logs.has(uri.toString())) {
268
      return;
269
    }
270
    const entry = { text: "", after: 0, timer: undefined as NodeJS.Timeout | undefined };
271
    this.logs.set(uri.toString(), entry);
272
    const client = this.auth.clientFor(repo.base);
273
    const tick = async (): Promise<void> => {
274
      try {
275
        const lines = await client.flowLog(repo.owner, repo.name, run, job, entry.after);
276
        if (lines.length) {
277
          entry.text += lines.map((l) => (l.stream === "flow" ? `▸ ${l.text}` : l.text)).join("\n") + "\n";
278
          entry.after = lines[lines.length - 1].seq;
279
          this.emitter.fire(uri);
280
        }
281
        const r = await client.flowRun(repo.owner, repo.name, run);
282
        const j = (r.jobs ?? []).find((x) => x.name === job);
283
        if (j && live(j.status)) {
284
          entry.timer = setTimeout(() => void tick(), 2000);
285
        } else if (j) {
286
          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`;
287
          this.emitter.fire(uri);
288
        }
289
      } catch (e) {
290
        entry.text += `\n(could not read the log: ${(e as Error).message})\n`;
291
        this.emitter.fire(uri);
292
      }
293
    };
294
    void tick();
295
  }
296
}
297
298
let logs: FlowLogs | undefined;
299
300
export function useLogs(l: FlowLogs): void {
301
  logs = l;
302
}
303
304
/** Open a job's log in an editor tab, following it while the job runs. */
305
export async function openLog(repo: HubRepo, run: api.FlowRun, job: api.FlowJob): Promise<void> {
306
  const uri = vscode.Uri.from({ scheme: LOG_SCHEME, path: `/${repo.owner}/${repo.name}/run-${run.number}/${job.name}.log`, query: repo.base });
307
  logs?.follow(uri, repo, run.number, job.name);
308
  const doc = await vscode.workspace.openTextDocument(uri);
309
  await vscode.languages.setTextDocumentLanguage(doc, "log");
310
  await vscode.window.showTextDocument(doc, { preview: false });
311
}