// The Flow view (hub spec §13.2 in the editor): each repository's recent // OxygenFlow runs and their jobs, coloured and turning while they run; a // job's log streamed into an editor tab; Run pipeline and Re-run; a word when // a run finishes; and a status bar item for the current bookmark's newest run. import * as vscode from "vscode"; import type * as api from "./hubapi"; import { HubAuth } from "./auth"; import type { HubRepo } from "./proposals"; import { duration, live, look, newlyFinished, runDescription, runLabel, subject } from "./flowview"; export const LOG_SCHEME = "oxygenhub-flow"; type Node = | { kind: "repo"; repo: HubRepo } | { kind: "run"; repo: HubRepo; run: api.FlowRun } | { kind: "job"; repo: HubRepo; run: api.FlowRun; job: api.FlowJob }; export type RunNode = Extract; export type JobNode = Extract; const RUNS = 15; function icon(status: string): vscode.ThemeIcon { const l = look(status); return new vscode.ThemeIcon(l.icon, new vscode.ThemeColor(l.color)); } function key(repo: HubRepo, run: api.FlowRun): string { return `${repo.base}/${repo.owner}/${repo.name}#${run.number}`; } export class FlowProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.emitter.event; /** Runs by repository, newest first, as last fetched. */ private runs = new Map(); /** Every run's status at the last look, to notice runs that finish. */ private seen = new Map(); private timer: NodeJS.Timeout | undefined; private readonly bar: vscode.StatusBarItem; private readonly disposables: vscode.Disposable[] = []; constructor( private readonly repos: () => Promise, private readonly auth: HubAuth, ) { this.bar = vscode.window.createStatusBarItem("oxygenhub.flow", vscode.StatusBarAlignment.Left, 98); this.bar.name = "OxygenFlow"; this.disposables.push(this.bar, vscode.window.onDidChangeActiveTextEditor(() => this.updateBar())); } dispose(): void { if (this.timer) { clearTimeout(this.timer); } this.disposables.forEach((d) => d.dispose()); } /** Fetch every repository's runs again, redraw, and keep polling while any is live. */ async refresh(): Promise { const repos = await this.repos().catch(() => [] as HubRepo[]); const next = new Map(); await Promise.all( repos.map(async (repo) => { const runs = await this.auth.clientFor(repo.base).flowRuns(repo.owner, repo.name, RUNS).catch(() => [] as api.FlowRun[]); next.set(`${repo.base}/${repo.owner}/${repo.name}`, { repo, runs }); }), ); const all = [...next.values()].flatMap(({ repo, runs }) => runs.map((run) => ({ key: key(repo, run), repo, run }))); const firstLook = this.seen.size === 0; for (const done of newlyFinished(this.seen, all)) { const found = all.find((a) => a.key === done.key); if (found && !firstLook) { void this.announce(found.repo, found.run); } } this.seen = new Map(all.map((a) => [a.key, a.run.status])); this.runs = next; this.emitter.fire(); this.updateBar(); if (this.timer) { clearTimeout(this.timer); } const anyLive = all.some((a) => live(a.run.status)); const every = vscode.workspace.getConfiguration("oxygenhub").get("flowPollSeconds", 5); this.timer = setTimeout(() => void this.refresh(), (anyLive ? every : Math.max(every, 60)) * 1000); } /** A run finished: say so, with the way to its log when it failed. */ private async announce(repo: HubRepo, run: api.FlowRun): Promise { if (!vscode.workspace.getConfiguration("oxygenhub").get("flowNotifications", true)) { return; } const full = await this.auth.clientFor(repo.base).flowRun(repo.owner, repo.name, run.number).catch(() => run); const text = `OxygenFlow run #${run.number} on ${subject(run)} (${repo.name}) ${look(run.status).word}`; if (run.status === "passed") { const pick = await vscode.window.showInformationMessage(`✔ ${text}.`, "Open Run"); if (pick) { void vscode.env.openExternal(vscode.Uri.parse(run.url)); } return; } const failed = (full.jobs ?? []).find((j) => j.status === "failed" || j.status === "error"); const pick = await vscode.window.showErrorMessage(`✘ ${text}${failed ? `: ${failed.name}` : ""}.`, ...(failed ? ["Show Log"] : []), "Open Run"); if (pick === "Show Log" && failed) { void openLog(repo, full, failed); } else if (pick === "Open Run") { void vscode.env.openExternal(vscode.Uri.parse(run.url)); } } /** The status bar: the newest run of the active repository's current bookmark. */ private updateBar(): void { const file = vscode.window.activeTextEditor?.document.uri.fsPath; const entries = [...this.runs.values()]; const entry = entries.find((e) => file && file.startsWith(e.repo.handle.root)) ?? (entries.length === 1 ? entries[0] : undefined); const bookmark = entry?.repo.handle.currentBookmark; const run = entry?.runs.find((r) => r.bookmark === bookmark) ?? undefined; if (!entry || !run) { this.bar.hide(); return; } const l = look(run.status); this.bar.text = `$(${l.icon}) Flow #${run.number}`; this.bar.tooltip = new vscode.MarkdownString(`**OxygenFlow** run #${run.number} on \`${subject(run)}\`\n\n${runDescription(run)}\n\nClick for recent runs.`); this.bar.backgroundColor = run.status === "failed" || run.status === "error" ? new vscode.ThemeColor("statusBarItem.errorBackground") : undefined; this.bar.color = run.status === "passed" ? new vscode.ThemeColor("testing.iconPassed") : undefined; this.bar.command = { title: "Recent runs", command: "oxygenhub.flowRecent", arguments: [entry.repo] }; this.bar.show(); } /** Recent runs of a repository to pick from; the pick opens on the hub or shows its failed job's log. */ async recent(repo?: HubRepo): Promise { const entry = repo ? this.runs.get(`${repo.base}/${repo.owner}/${repo.name}`) : [...this.runs.values()][0]; if (!entry) { return; } type Item = vscode.QuickPickItem & { run: api.FlowRun }; const items: Item[] = entry.runs.map((run) => ({ label: `$(${look(run.status).icon}) ${runLabel(run)}`, description: runDescription(run), run })); const pick = await vscode.window.showQuickPick(items, { placeHolder: `OxygenFlow runs of ${entry.repo.owner}/${entry.repo.name}` }); if (pick) { void vscode.env.openExternal(vscode.Uri.parse(pick.run.url)); } } getTreeItem(node: Node): vscode.TreeItem { switch (node.kind) { case "repo": { const item = new vscode.TreeItem(`${node.repo.owner}/${node.repo.name}`, vscode.TreeItemCollapsibleState.Expanded); item.iconPath = new vscode.ThemeIcon("repo"); item.contextValue = "flowRepo"; return item; } case "run": { const run = node.run; const item = new vscode.TreeItem(runLabel(run), live(run.status) ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed); item.id = `${key(node.repo, run)}@${run.status}`; item.description = runDescription(run); item.iconPath = icon(run.status); 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\`\`\`` : ""}`); item.contextValue = live(run.status) ? "flowRunLive" : "flowRun"; item.accessibilityInformation = { label: `Run ${run.number} on ${subject(run)}, ${look(run.status).word}` }; return item; } case "job": { const j = node.job; const item = new vscode.TreeItem(j.name, vscode.TreeItemCollapsibleState.None); const took = j.started_ms ? duration((j.finished_ms ?? Date.now()) - j.started_ms) : ""; item.description = [look(j.status).word, took, j.runner ? `on ${j.runner}` : ""].filter(Boolean).join(" · "); item.iconPath = icon(j.status); item.contextValue = "flowJob"; item.command = { title: "Show Log", command: "oxygenhub.flowLog", arguments: [node] }; item.tooltip = j.error ? new vscode.MarkdownString(`**${j.name}**: ${j.error}`) : `${j.name} — ${look(j.status).word}; click for its log`; return item; } } } async getChildren(node?: Node): Promise { if (!node) { if (this.runs.size === 0) { await this.refresh(); } return [...this.runs.values()].map(({ repo }) => ({ kind: "repo" as const, repo })); } if (node.kind === "repo") { const entry = this.runs.get(`${node.repo.base}/${node.repo.owner}/${node.repo.name}`); return (entry?.runs ?? []).map((run) => ({ kind: "run" as const, repo: node.repo, run })); } if (node.kind === "run") { const full = await this.auth.clientFor(node.repo.base).flowRun(node.repo.owner, node.repo.name, node.run.number).catch(() => node.run); return (full.jobs ?? []).map((job) => ({ kind: "job" as const, repo: node.repo, run: full, job })); } return []; } /** Run pipeline: a bookmark's current state (the current one first) or one change. */ async start(repo?: HubRepo): Promise { const repos = repo ? [repo] : [...this.runs.values()].map((e) => e.repo); const target = repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Run the pipeline of" }))?.r; if (!target) { return; } const current = target.handle.currentBookmark; const names = [...new Set([current, ...target.handle.bookmarks.map((b) => b.name)].filter((b): b is string => !!b))]; const pick = await vscode.window.showQuickPick( [...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: "" }], { placeHolder: `Run the pipeline of ${target.owner}/${target.name} on` }, ); if (!pick) { return; } let body: { bookmark?: string; change?: string } = { bookmark: pick.bookmark }; if (!pick.bookmark) { 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") }); if (!change) { return; } body = { change: change.trim().replace(/^#/, "") }; } try { const started = await this.auth.clientFor(target.base).startFlowRun(target.owner, target.name, body); vscode.window.setStatusBarMessage(`$(play) OxygenFlow run #${started.number} started`, 4000); await this.refresh(); } catch (e) { vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`); } } async rerun(node: RunNode): Promise { try { const started = await this.auth.clientFor(node.repo.base).rerunFlowRun(node.repo.owner, node.repo.name, node.run.number); vscode.window.setStatusBarMessage(`$(debug-rerun) OxygenFlow run #${started.number} started`, 4000); await this.refresh(); } catch (e) { vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`); } } } /** Streamed job logs, as read-only documents that grow while the job runs. */ export class FlowLogs implements vscode.TextDocumentContentProvider, vscode.Disposable { private readonly emitter = new vscode.EventEmitter(); readonly onDidChange = this.emitter.event; private readonly logs = new Map(); constructor(private readonly auth: HubAuth) {} dispose(): void { for (const l of this.logs.values()) { if (l.timer) { clearTimeout(l.timer); } } } provideTextDocumentContent(uri: vscode.Uri): string { return this.logs.get(uri.toString())?.text ?? "Loading…\n"; } /** Start (or keep) following a job's log at `uri`. */ follow(uri: vscode.Uri, repo: HubRepo, run: number, job: string): void { if (this.logs.has(uri.toString())) { return; } const entry = { text: "", after: 0, timer: undefined as NodeJS.Timeout | undefined }; this.logs.set(uri.toString(), entry); const client = this.auth.clientFor(repo.base); const tick = async (): Promise => { try { const lines = await client.flowLog(repo.owner, repo.name, run, job, entry.after); if (lines.length) { entry.text += lines.map((l) => (l.stream === "flow" ? `▸ ${l.text}` : l.text)).join("\n") + "\n"; entry.after = lines[lines.length - 1].seq; this.emitter.fire(uri); } const r = await client.flowRun(repo.owner, repo.name, run); const j = (r.jobs ?? []).find((x) => x.name === job); if (j && live(j.status)) { entry.timer = setTimeout(() => void tick(), 2000); } else if (j) { 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`; this.emitter.fire(uri); } } catch (e) { entry.text += `\n(could not read the log: ${(e as Error).message})\n`; this.emitter.fire(uri); } }; void tick(); } } let logs: FlowLogs | undefined; export function useLogs(l: FlowLogs): void { logs = l; } /** Open a job's log in an editor tab, following it while the job runs. */ export async function openLog(repo: HubRepo, run: api.FlowRun, job: api.FlowJob): Promise { const uri = vscode.Uri.from({ scheme: LOG_SCHEME, path: `/${repo.owner}/${repo.name}/run-${run.number}/${job.name}.log`, query: repo.base }); logs?.follow(uri, repo, run.number, job.name); const doc = await vscode.workspace.openTextDocument(uri); await vscode.languages.setTextDocumentLanguage(doc, "log"); await vscode.window.showTextDocument(doc, { preview: false }); }