Flow view and a live Flow status bar item (0.8.0) 263de209e5ba
The OxygenHub sidebar gains Flow: each repository's recent runs and their jobs, coloured by status and turning while they run; a job's log streamed into an editor tab; Run Pipeline (a bookmark or one change), Re-run and Open Run; a notification when a run finishes, with Show Log for a failed job. The status bar shows the current bookmark's newest run, red when it failed. The hub-api types now include the Flow types. closes #2
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 18:35 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
263de209e5ba12114b0f2f9fcb466521ab1599ad71ec2b571b53d8811ccf6409- Depends on
- 092b8bf9f2ac 1cc0ff44e5ca 426ddc5039b5 486c15b00314 5888949525ec b2dd082c57e4 bf73998c5198 e04950748142 f0b06deac4b9 f902d5b8bfeb
- Depended on by
- 991516a107fa d21a85954a34 b1047516e064
- In bookmarks
- main
- In tags
- v0.10.0 v0.10.1 v0.10.2 v0.11.0 v0.11.1 v0.11.2 v0.13.0
- Edits
- 27 atoms, 3 tree operations, 9 files
Browse the tree at this change
added src/flow.ts +311 −0
| 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 | + | } |
added src/flowview.ts +98 −0
| 1 | + | // OxygenFlow in the editor, without VS Code: how runs and jobs look (icon, |
|
| 2 | + | // colour, labels) and which runs just finished. Shared by the Flow view, the |
|
| 3 | + | // status bar item and the tests. |
|
| 4 | + | ||
| 5 | + | import type { FlowRun } from "./hubapi"; |
|
| 6 | + | ||
| 7 | + | /** A codicon and a theme colour id for a run's or job's status. */ |
|
| 8 | + | export interface Look { |
|
| 9 | + | /** Codicon id; `~spin` makes it turn. */ |
|
| 10 | + | icon: string; |
|
| 11 | + | /** A theme colour id, e.g. `testing.iconPassed`. */ |
|
| 12 | + | color: string; |
|
| 13 | + | /** A short word for screen readers and tooltips. */ |
|
| 14 | + | word: string; |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | export function look(status: string): Look { |
|
| 18 | + | switch (status) { |
|
| 19 | + | case "passed": |
|
| 20 | + | return { icon: "pass-filled", color: "testing.iconPassed", word: "passed" }; |
|
| 21 | + | case "failed": |
|
| 22 | + | return { icon: "error", color: "testing.iconFailed", word: "failed" }; |
|
| 23 | + | case "error": |
|
| 24 | + | return { icon: "warning", color: "testing.iconErrored", word: "could not run" }; |
|
| 25 | + | case "running": |
|
| 26 | + | return { icon: "sync~spin", color: "oxygenhub.flowRunning", word: "running" }; |
|
| 27 | + | case "queued": |
|
| 28 | + | return { icon: "clock", color: "testing.iconQueued", word: "queued" }; |
|
| 29 | + | case "skipped": |
|
| 30 | + | return { icon: "debug-step-over", color: "testing.iconSkipped", word: "skipped" }; |
|
| 31 | + | default: |
|
| 32 | + | return { icon: "circle-large-outline", color: "descriptionForeground", word: status }; |
|
| 33 | + | } |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | export function live(status: string): boolean { |
|
| 37 | + | return status === "queued" || status === "running"; |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /** "3.2 s", "4 min 10 s". */ |
|
| 41 | + | export function duration(ms: number): string { |
|
| 42 | + | const s = Math.max(0, ms) / 1000; |
|
| 43 | + | if (s < 60) { |
|
| 44 | + | return `${s.toFixed(1)} s`; |
|
| 45 | + | } |
|
| 46 | + | return `${Math.floor(s / 60)} min ${Math.floor(s % 60)} s`; |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /** "just now", "5 minutes ago", as the hub says it. */ |
|
| 50 | + | export function ago(ms: number, now = Date.now()): string { |
|
| 51 | + | const s = Math.max(0, Math.floor((now - ms) / 1000)); |
|
| 52 | + | const unit = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"} ago`; |
|
| 53 | + | if (s < 45) { |
|
| 54 | + | return "just now"; |
|
| 55 | + | } |
|
| 56 | + | if (s < 90) { |
|
| 57 | + | return "a minute ago"; |
|
| 58 | + | } |
|
| 59 | + | if (s < 2700) { |
|
| 60 | + | return unit(Math.ceil(s / 60), "minute"); |
|
| 61 | + | } |
|
| 62 | + | if (s < 5400) { |
|
| 63 | + | return "an hour ago"; |
|
| 64 | + | } |
|
| 65 | + | if (s < 79200) { |
|
| 66 | + | return unit(Math.ceil(s / 3600), "hour"); |
|
| 67 | + | } |
|
| 68 | + | return unit(Math.ceil(s / 86400), "day"); |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | /** What a run ran on: its bookmark, or the change for a run on one change. */ |
|
| 72 | + | export function subject(run: FlowRun): string { |
|
| 73 | + | return run.bookmark || `change ${(run.heads[0] ?? "").slice(0, 12)}`; |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | /** "#12 main". */ |
|
| 77 | + | export function runLabel(run: FlowRun): string { |
|
| 78 | + | return `#${run.number} ${subject(run)}`; |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | /** "passed in 2 min 3 s · push by andy · 5 minutes ago". */ |
|
| 82 | + | export function runDescription(run: FlowRun, now = Date.now()): string { |
|
| 83 | + | const took = run.finished_ms ? ` in ${duration(run.finished_ms - run.created_ms)}` : ""; |
|
| 84 | + | const queued = run.status === "queued" && run.queue_position ? ` (#${run.queue_position} in line)` : ""; |
|
| 85 | + | const trigger = run.trigger.startsWith("rerun:") ? `re-run of #${run.trigger.slice(6)}` : run.trigger; |
|
| 86 | + | return `${look(run.status).word}${took}${queued} · ${trigger} by ${run.actor} · ${ago(run.created_ms, now)}`; |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | /** |
|
| 90 | + | * Runs that were live at the last look and are finished now, so the editor |
|
| 91 | + | * can say so once. `before` maps a run's key to its last status. |
|
| 92 | + | */ |
|
| 93 | + | export function newlyFinished(before: Map<string, string>, runs: { key: string; run: FlowRun }[]): { key: string; run: FlowRun }[] { |
|
| 94 | + | return runs.filter(({ key, run }) => { |
|
| 95 | + | const was = before.get(key); |
|
| 96 | + | return was !== undefined && live(was) && !live(run.status); |
|
| 97 | + | }); |
|
| 98 | + | } |
added test/flowview.test.mjs +33 −0
| 1 | + | import { test } from "node:test"; |
|
| 2 | + | import assert from "node:assert/strict"; |
|
| 3 | + | import { ago, duration, live, look, newlyFinished, runDescription, runLabel } from "../out/flowview.mjs"; |
|
| 4 | + | ||
| 5 | + | const run = (over = {}) => ({ number: 12, bookmark: "main", heads: ["a".repeat(64)], status: "passed", trigger: "push", actor: "andy", created_ms: 1_000_000, finished_ms: 1_125_000, url: "", state: "", pipeline_hash: "", error: "", ...over }); |
|
| 6 | + | ||
| 7 | + | test("statuses look the part", () => { |
|
| 8 | + | assert.equal(look("running").icon, "sync~spin", "running turns"); |
|
| 9 | + | assert.equal(look("passed").color, "testing.iconPassed"); |
|
| 10 | + | assert.equal(look("failed").icon, "error"); |
|
| 11 | + | assert.equal(look("queued").word, "queued"); |
|
| 12 | + | assert.ok(live("queued") && live("running") && !live("passed")); |
|
| 13 | + | }); |
|
| 14 | + | ||
| 15 | + | test("runs are labelled and described", () => { |
|
| 16 | + | assert.equal(runLabel(run()), "#12 main"); |
|
| 17 | + | assert.equal(runLabel(run({ bookmark: "" })), `#12 change ${"a".repeat(12)}`); |
|
| 18 | + | assert.equal(runDescription(run(), 1_125_000 + 10_000), "passed in 2 min 5 s · push by andy · 3 minutes ago"); |
|
| 19 | + | assert.match(runDescription(run({ status: "queued", finished_ms: null, queue_position: 3, trigger: "rerun:9" }), 1_000_000), /^queued \(#3 in line\) · re-run of #9 by andy · just now$/); |
|
| 20 | + | assert.equal(duration(3200), "3.2 s"); |
|
| 21 | + | assert.equal(ago(0, 7_200_000), "2 hours ago"); |
|
| 22 | + | }); |
|
| 23 | + | ||
| 24 | + | test("a run is announced once, when it stops being live", () => { |
|
| 25 | + | const before = new Map([["a#1", "running"], ["a#2", "passed"], ["a#3", "queued"]]); |
|
| 26 | + | const now = [ |
|
| 27 | + | { key: "a#1", run: run({ status: "failed" }) }, |
|
| 28 | + | { key: "a#2", run: run({ status: "passed" }) }, |
|
| 29 | + | { key: "a#3", run: run({ status: "running" }) }, |
|
| 30 | + | { key: "a#4", run: run({ status: "passed" }) }, |
|
| 31 | + | ]; |
|
| 32 | + | assert.deepEqual(newlyFinished(before, now).map((x) => x.key), ["a#1"], "only the one that finished; new runs are not news"); |
|
| 33 | + | }); |
modified CHANGELOG.md +10 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## 0.8.0 (2026-09-29) |
|
| 4 | + | ||
| 5 | + | - **Flow view.** The OxygenHub sidebar shows each repository's recent OxygenFlow runs and their jobs. |
|
| 6 | + | - A running run or job turns, and each status has its own colour: passed, failed, queued, skipped and could not run. Running uses the new themeable colour `oxygenhub.flowRunning`. |
|
| 7 | + | - Click a job to open its log in an editor tab. The log grows while the job runs and ends with the job's result. |
|
| 8 | + | - **Run Pipeline…** runs a bookmark's current state (the current bookmark first) or one change, without a push. **Re-run** repeats a finished run. **Open Run on OxygenHub** opens the run's page. |
|
| 9 | + | - When a run you can see finishes, a notification says so, with **Show Log** for a failed job (`oxygenhub.flowNotifications`). |
|
| 10 | + | - It looks again every 5 seconds while something runs, and once a minute otherwise (`oxygenhub.flowPollSeconds`). |
|
| 11 | + | - **Flow in the status bar.** The newest run of the current bookmark shows as `Flow #N`. It turns while it runs, shows green when it passes and red when it fails. Click it for recent runs. |
|
| 12 | + | ||
| 3 | 13 | ## 0.7.0 (2026-09-29) |
|
| 4 | 14 | ||
| 5 | 15 | - **Self-signed hubs work.** "OxygenHub: fetch failed" came from calling a hub whose certificate is self-signed (Earth's) with plain `fetch`, which does not trust it. The extension now uses the CA file the checkout's remote names (`remote.NAME.ca`, as `oxy` does), or one from the new `oxygenhub.caFiles` setting. It needs Oxygen extension 0.13 for the remote's CA. |
|
| … | 57 unchanged lines |
modified README.md +2 −0
| … | 56 unchanged lines |
||
| 57 | 57 | | Setting | Default | What it does | |
|
| 58 | 58 | |---|---|---| |
|
| 59 | 59 | | `oxygenhub.hubs` | `[]` | Base URLs of the hubs you use. Signing in adds to it, so you rarely edit it by hand. | |
|
| 60 | + | | `oxygenhub.flowPollSeconds` | `5` | How often the Flow view and its status bar item look again while a run is queued or running; once a minute otherwise. | |
|
| 61 | + | | `oxygenhub.flowNotifications` | `true` | Say when an OxygenFlow run finishes, with a way to a failed job's log. | |
|
| 60 | 62 | | `oxygenhub.caFiles` | `{}` | CA certificate files for hubs with self-signed certificates, by base URL. A checkout whose remote names a CA (`remote.NAME.ca`, which `oxy` uses too) needs no entry: the extension takes it from the remote. | |
|
| 61 | 63 | | `oxygenhub.proposalBookmarkPrefix` | `"proposal/"` | Prefix of the local bookmark made by **Check Out Proposal** (`proposal/12`). | |
|
| 62 | 64 | | `oxygenhub.autoRefresh` | `60` | Seconds between refreshes of the views, the checks and the notification count; `0` turns it off. | |
|
| … | 16 unchanged lines |
modified package.json +111 −3
| … | 1 unchanged lines |
||
| 2 | 2 | "name": "oxygenhub-vscode", |
|
| 3 | 3 | "displayName": "OxygenHub", |
|
| 4 | 4 | "description": "Proposals, reviews, issues and checks from an OxygenHub, in the editor. Builds on the Oxygen extension.", |
|
| 5 | - | "version": "0.7.0", |
|
| 5 | + | "version": "0.8.0", |
|
| 6 | 6 | "publisher": "andymccall", |
|
| 7 | 7 | "license": "MIT OR Apache-2.0", |
|
| 8 | 8 | "icon": "assets/images/oxygenhub.png", |
|
| … | 60 unchanged lines |
||
| 69 | 69 | "type": "boolean", |
|
| 70 | 70 | "default": true, |
|
| 71 | 71 | "description": "Show a message when new notifications arrive (checked on the autoRefresh interval)." |
|
| 72 | + | }, |
|
| 73 | + | "oxygenhub.flowPollSeconds": { |
|
| 74 | + | "type": "number", |
|
| 75 | + | "default": 5, |
|
| 76 | + | "minimum": 2, |
|
| 77 | + | "description": "How often the Flow view and status bar look again while a run is queued or running (seconds); once a minute otherwise." |
|
| 78 | + | }, |
|
| 79 | + | "oxygenhub.flowNotifications": { |
|
| 80 | + | "type": "boolean", |
|
| 81 | + | "default": true, |
|
| 82 | + | "description": "Say when an OxygenFlow run you can see finishes, with a way to its failed job's log." |
|
| 72 | 83 | } |
|
| 73 | 84 | } |
|
| 74 | 85 | }, |
|
| … | 19 unchanged lines |
||
| 94 | 105 | { |
|
| 95 | 106 | "id": "oxygenhub.releases", |
|
| 96 | 107 | "name": "Releases" |
|
| 108 | + | }, |
|
| 109 | + | { |
|
| 110 | + | "id": "oxygenhub.flow", |
|
| 111 | + | "name": "Flow" |
|
| 97 | 112 | } |
|
| 98 | 113 | ] |
|
| 99 | 114 | }, |
|
| … | 1 unchanged lines |
||
| 101 | 116 | { |
|
| 102 | 117 | "view": "oxygenhub.proposals", |
|
| 103 | 118 | "contents": "Sign in to an OxygenHub to see proposals.\n[Sign in](command:oxygenhub.signIn)" |
|
| 119 | + | }, |
|
| 120 | + | { |
|
| 121 | + | "view": "oxygenhub.flow", |
|
| 122 | + | "contents": "OxygenFlow runs of your hub repositories appear here: a push that moves a bookmark starts one.\n[Run Pipeline](command:oxygenhub.runPipeline)" |
|
| 104 | 123 | } |
|
| 105 | 124 | ], |
|
| 106 | 125 | "commands": [ |
|
| … | 129 unchanged lines |
||
| 236 | 255 | "command": "oxygenhub.resolveThread", |
|
| 237 | 256 | "title": "Resolve", |
|
| 238 | 257 | "category": "OxygenHub" |
|
| 258 | + | }, |
|
| 259 | + | { |
|
| 260 | + | "command": "oxygenhub.flowRefresh", |
|
| 261 | + | "title": "Refresh Flow", |
|
| 262 | + | "category": "OxygenHub", |
|
| 263 | + | "icon": "$(refresh)" |
|
| 264 | + | }, |
|
| 265 | + | { |
|
| 266 | + | "command": "oxygenhub.runPipeline", |
|
| 267 | + | "title": "Run Pipeline\u2026", |
|
| 268 | + | "category": "OxygenHub", |
|
| 269 | + | "icon": "$(play)" |
|
| 270 | + | }, |
|
| 271 | + | { |
|
| 272 | + | "command": "oxygenhub.rerunFlow", |
|
| 273 | + | "title": "Re-run", |
|
| 274 | + | "category": "OxygenHub", |
|
| 275 | + | "icon": "$(debug-rerun)" |
|
| 276 | + | }, |
|
| 277 | + | { |
|
| 278 | + | "command": "oxygenhub.flowLog", |
|
| 279 | + | "title": "Show Job Log", |
|
| 280 | + | "category": "OxygenHub", |
|
| 281 | + | "icon": "$(output)" |
|
| 282 | + | }, |
|
| 283 | + | { |
|
| 284 | + | "command": "oxygenhub.openFlowRun", |
|
| 285 | + | "title": "Open Run on OxygenHub", |
|
| 286 | + | "category": "OxygenHub", |
|
| 287 | + | "icon": "$(link-external)" |
|
| 288 | + | }, |
|
| 289 | + | { |
|
| 290 | + | "command": "oxygenhub.flowRecent", |
|
| 291 | + | "title": "Recent Flow Runs\u2026", |
|
| 292 | + | "category": "OxygenHub" |
|
| 239 | 293 | } |
|
| 240 | 294 | ], |
|
| 241 | 295 | "menus": { |
|
| … | 17 unchanged lines |
||
| 259 | 313 | "command": "oxygenhub.draftRelease", |
|
| 260 | 314 | "when": "view == oxygenhub.releases", |
|
| 261 | 315 | "group": "navigation" |
|
| 316 | + | }, |
|
| 317 | + | { |
|
| 318 | + | "command": "oxygenhub.runPipeline", |
|
| 319 | + | "when": "view == oxygenhub.flow", |
|
| 320 | + | "group": "navigation@1" |
|
| 321 | + | }, |
|
| 322 | + | { |
|
| 323 | + | "command": "oxygenhub.flowRefresh", |
|
| 324 | + | "when": "view == oxygenhub.flow", |
|
| 325 | + | "group": "navigation@2" |
|
| 262 | 326 | } |
|
| 263 | 327 | ], |
|
| 264 | 328 | "view/item/context": [ |
|
| … | 60 unchanged lines |
||
| 325 | 389 | { |
|
| 326 | 390 | "command": "oxygenhub.draftRelease", |
|
| 327 | 391 | "when": "view == oxygenhub.releases && viewItem == releaseRepo", |
|
| 392 | + | "group": "inline" |
|
| 393 | + | }, |
|
| 394 | + | { |
|
| 395 | + | "command": "oxygenhub.runPipeline", |
|
| 396 | + | "when": "view == oxygenhub.flow && viewItem == flowRepo", |
|
| 397 | + | "group": "inline" |
|
| 398 | + | }, |
|
| 399 | + | { |
|
| 400 | + | "command": "oxygenhub.rerunFlow", |
|
| 401 | + | "when": "view == oxygenhub.flow && viewItem == flowRun", |
|
| 402 | + | "group": "inline@1" |
|
| 403 | + | }, |
|
| 404 | + | { |
|
| 405 | + | "command": "oxygenhub.openFlowRun", |
|
| 406 | + | "when": "view == oxygenhub.flow && viewItem =~ /^flowRun/", |
|
| 407 | + | "group": "inline@2" |
|
| 408 | + | }, |
|
| 409 | + | { |
|
| 410 | + | "command": "oxygenhub.flowLog", |
|
| 411 | + | "when": "view == oxygenhub.flow && viewItem == flowJob", |
|
| 328 | 412 | "group": "inline" |
|
| 329 | 413 | } |
|
| 330 | 414 | ], |
|
| … | 20 unchanged lines |
||
| 351 | 435 | { |
|
| 352 | 436 | "command": "oxygenhub.openProposalFile", |
|
| 353 | 437 | "when": "false" |
|
| 438 | + | }, |
|
| 439 | + | { |
|
| 440 | + | "command": "oxygenhub.rerunFlow", |
|
| 441 | + | "when": "false" |
|
| 442 | + | }, |
|
| 443 | + | { |
|
| 444 | + | "command": "oxygenhub.flowLog", |
|
| 445 | + | "when": "false" |
|
| 446 | + | }, |
|
| 447 | + | { |
|
| 448 | + | "command": "oxygenhub.openFlowRun", |
|
| 449 | + | "when": "false" |
|
| 354 | 450 | } |
|
| 355 | 451 | ], |
|
| 356 | 452 | "editor/context": [ |
|
| … | 3 unchanged lines |
||
| 360 | 456 | "group": "9_oxygenhub@1" |
|
| 361 | 457 | } |
|
| 362 | 458 | ] |
|
| 363 | - | } |
|
| 459 | + | }, |
|
| 460 | + | "colors": [ |
|
| 461 | + | { |
|
| 462 | + | "id": "oxygenhub.flowRunning", |
|
| 463 | + | "description": "OxygenFlow: the icon of a run or job that is running.", |
|
| 464 | + | "defaults": { |
|
| 465 | + | "dark": "#35c7ff", |
|
| 466 | + | "light": "#0a74b8", |
|
| 467 | + | "highContrast": "#35c7ff", |
|
| 468 | + | "highContrastLight": "#0a5a8f" |
|
| 469 | + | } |
|
| 470 | + | } |
|
| 471 | + | ] |
|
| 364 | 472 | }, |
|
| 365 | 473 | "scripts": { |
|
| 366 | 474 | "types": "cp ../oxygenhub/crates/hub-api/ts/index.d.ts src/hubapi.d.ts", |
|
| 367 | 475 | "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js --sourcemap", |
|
| 368 | 476 | "watch": "npm run build -- --watch", |
|
| 369 | 477 | "typecheck": "tsc --noEmit -p .", |
|
| 370 | - | "pretest": "esbuild src/hub.ts --bundle --platform=node --format=esm --outfile=out/hub.mjs && esbuild src/provider.ts --bundle --platform=node --format=esm --outfile=out/provider.mjs && esbuild src/refs.ts --bundle --platform=node --format=esm --outfile=out/refs.mjs", |
|
| 478 | + | "pretest": "esbuild src/hub.ts --bundle --platform=node --format=esm --outfile=out/hub.mjs && esbuild src/provider.ts --bundle --platform=node --format=esm --outfile=out/provider.mjs && esbuild src/refs.ts --bundle --platform=node --format=esm --outfile=out/refs.mjs && esbuild src/flowview.ts --bundle --platform=node --format=esm --outfile=out/flowview.mjs", |
|
| 371 | 479 | "test": "node --test \"test/**/*.test.mjs\"", |
|
| 372 | 480 | "check": "npm run typecheck && npm run build && npm test", |
|
| 373 | 481 | "package": "vsce package --no-dependencies" |
|
| … | 9 unchanged lines |
modified src/extension.ts +18 −0
| … | 4 unchanged lines |
||
| 5 | 5 | import { HubAuth } from "./auth"; |
|
| 6 | 6 | import { ChecksBar } from "./checks"; |
|
| 7 | 7 | import { ProposalContentProvider, SCHEME, openAllChanges, openFileDiff, refOf } from "./diff"; |
|
| 8 | + | import { FlowLogs, FlowProvider, JobNode, LOG_SCHEME, RunNode, openLog, useLogs } from "./flow"; |
|
| 8 | 9 | import { hubUrl } from "./hub"; |
|
| 9 | 10 | import { InboxBar } from "./inbox"; |
|
| 10 | 11 | import { draftRelease, ReleasesProvider } from "./releases"; |
|
| … | 15 unchanged lines |
||
| 26 | 27 | let inbox: InboxBar; |
|
| 27 | 28 | let references: ReferenceIndex; |
|
| 28 | 29 | let releases: ReleasesProvider; |
|
| 30 | + | let flow: FlowProvider; |
|
| 29 | 31 | ||
| 30 | 32 | export async function activate(context: vscode.ExtensionContext): Promise<void> { |
|
| 31 | 33 | auth = new HubAuth(context.secrets); |
|
| … | 30 unchanged lines |
||
| 62 | 64 | context.subscriptions.push(inbox); |
|
| 63 | 65 | releases = new ReleasesProvider(() => proposals.repos(), auth); |
|
| 64 | 66 | context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.releases", releases)); |
|
| 67 | + | flow = new FlowProvider(() => proposals.repos(), auth); |
|
| 68 | + | const flowLogs = new FlowLogs(auth); |
|
| 69 | + | useLogs(flowLogs); |
|
| 70 | + | context.subscriptions.push( |
|
| 71 | + | flow, |
|
| 72 | + | flowLogs, |
|
| 73 | + | vscode.window.registerTreeDataProvider("oxygenhub.flow", flow), |
|
| 74 | + | vscode.workspace.registerTextDocumentContentProvider(LOG_SCHEME, flowLogs), |
|
| 75 | + | vscode.commands.registerCommand("oxygenhub.flowRefresh", () => flow.refresh()), |
|
| 76 | + | vscode.commands.registerCommand("oxygenhub.runPipeline", (node?: { repo?: HubRepo }) => flow.start(node?.repo)), |
|
| 77 | + | vscode.commands.registerCommand("oxygenhub.rerunFlow", (node: RunNode) => flow.rerun(node)), |
|
| 78 | + | vscode.commands.registerCommand("oxygenhub.flowLog", (node: JobNode) => openLog(node.repo, node.run, node.job)), |
|
| 79 | + | vscode.commands.registerCommand("oxygenhub.openFlowRun", (node: RunNode) => vscode.env.openExternal(vscode.Uri.parse(node.run.url))), |
|
| 80 | + | vscode.commands.registerCommand("oxygenhub.flowRecent", (repo?: HubRepo) => flow.recent(repo)), |
|
| 81 | + | ); |
|
| 65 | 82 | references = new ReferenceIndex(auth, () => proposals.repos()); |
|
| 66 | 83 | context.subscriptions.push( |
|
| 67 | 84 | vscode.languages.registerCompletionItemProvider(SELECTOR, new ReferenceCompletions(references), "#", "@"), |
|
| … | 114 unchanged lines |
||
| 182 | 199 | refreshOpenDiffs(); |
|
| 183 | 200 | issues.refresh(); |
|
| 184 | 201 | releases.refresh(); |
|
| 202 | + | void flow.refresh(); |
|
| 185 | 203 | void bar.refresh(); |
|
| 186 | 204 | void inbox.refresh(); |
|
| 187 | 205 | } |
|
| … | 237 unchanged lines |
modified src/hub.ts +24 −0
| … | 84 unchanged lines |
||
| 85 | 85 | return this.request("POST", "/notifications/read"); |
|
| 86 | 86 | } |
|
| 87 | 87 | ||
| 88 | + | /** Recent OxygenFlow runs, newest first. */ |
|
| 89 | + | flowRuns(owner: string, name: string, limit = 15): Promise<api.FlowRun[]> { |
|
| 90 | + | return this.request("GET", `/repos/${owner}/${name}/flow/runs?limit=${limit}`); |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | /** A run with its jobs. */ |
|
| 94 | + | flowRun(owner: string, name: string, number: number): Promise<api.FlowRun> { |
|
| 95 | + | return this.request("GET", `/repos/${owner}/${name}/flow/runs/${number}`); |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | /** A job's log lines after `after` (a line's seq). */ |
|
| 99 | + | flowLog(owner: string, name: string, number: number, job: string, after = 0): Promise<api.FlowLogLine[]> { |
|
| 100 | + | return this.request("GET", `/repos/${owner}/${name}/flow/runs/${number}/jobs/${encodeURIComponent(job)}/log?after=${after}&limit=2000`); |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /** Run the pipeline of a bookmark's current state, or of one change. */ |
|
| 104 | + | startFlowRun(owner: string, name: string, target: { bookmark?: string; change?: string }): Promise<{ number: number; url: string }> { |
|
| 105 | + | return this.request("POST", `/repos/${owner}/${name}/flow/runs`, target); |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | rerunFlowRun(owner: string, name: string, number: number): Promise<{ number: number; url: string }> { |
|
| 109 | + | return this.request("POST", `/repos/${owner}/${name}/flow/runs/${number}/rerun`); |
|
| 110 | + | } |
|
| 111 | + | ||
| 88 | 112 | repositories(): Promise<api.Repository[]> { |
|
| 89 | 113 | return this.request("GET", "/repos"); |
|
| 90 | 114 | } |
|
| … | 250 unchanged lines |
modified src/hubapi.d.ts +52 −0
| … | 32 unchanged lines |
||
| 33 | 33 | email: string; |
|
| 34 | 34 | key: string; |
|
| 35 | 35 | name: string; |
|
| 36 | + | note?: string | null; |
|
| 36 | 37 | }; |
|
| 37 | 38 | ||
| 38 | 39 | export type Signature = { |
|
| … | 317 unchanged lines |
||
| 356 | 357 | name: string; |
|
| 357 | 358 | }; |
|
| 358 | 359 | ||
| 360 | + | export type RequiredChecks = { |
|
| 361 | + | names: string[]; |
|
| 362 | + | }; |
|
| 363 | + | ||
| 359 | 364 | export type Project = { |
|
| 360 | 365 | closed: boolean; |
|
| 361 | 366 | description: string; |
|
| … | 84 unchanged lines |
||
| 446 | 451 | check?: Check | null; |
|
| 447 | 452 | kind: string; |
|
| 448 | 453 | number: number; |
|
| 454 | + | }; |
|
| 455 | + | ||
| 456 | + | export type AssetBuild = { |
|
| 457 | + | artifact: string; |
|
| 458 | + | job: string; |
|
| 459 | + | receipt?: string | null; |
|
| 460 | + | receipt_url?: string | null; |
|
| 461 | + | run: number; |
|
| 462 | + | run_url: string; |
|
| 463 | + | signer?: string | null; |
|
| 449 | 464 | }; |
|
| 450 | 465 | ||
| 451 | 466 | export type ReleaseAsset = { |
|
| 467 | + | built_by?: AssetBuild | null; |
|
| 452 | 468 | content_type: string; |
|
| 453 | 469 | downloads: number; |
|
| 454 | 470 | hash: string; |
|
| … | 14 unchanged lines |
||
| 469 | 485 | signer?: string | null; |
|
| 470 | 486 | tag: string; |
|
| 471 | 487 | title: string; |
|
| 488 | + | url: string; |
|
| 489 | + | }; |
|
| 490 | + | ||
| 491 | + | export type FlowJob = { |
|
| 492 | + | depends_on: string[]; |
|
| 493 | + | error: string; |
|
| 494 | + | exit_code?: number | null; |
|
| 495 | + | finished_ms?: number | null; |
|
| 496 | + | log_lines: number; |
|
| 497 | + | name: string; |
|
| 498 | + | runner: string; |
|
| 499 | + | started_ms?: number | null; |
|
| 500 | + | status: string; |
|
| 501 | + | }; |
|
| 502 | + | ||
| 503 | + | export type FlowRun = { |
|
| 504 | + | actor: string; |
|
| 505 | + | bookmark: string; |
|
| 506 | + | created_ms: number; |
|
| 507 | + | error: string; |
|
| 508 | + | finished_ms?: number | null; |
|
| 509 | + | heads: string[]; |
|
| 510 | + | jobs?: FlowJob[]; |
|
| 511 | + | number: number; |
|
| 512 | + | pipeline_hash: string; |
|
| 513 | + | queue_position?: number | null; |
|
| 514 | + | state: string; |
|
| 515 | + | status: string; |
|
| 516 | + | trigger: string; |
|
| 472 | 517 | url: string; |
|
| 518 | + | }; |
|
| 519 | + | ||
| 520 | + | export type FlowLogLine = { |
|
| 521 | + | seq: number; |
|
| 522 | + | stream: string; |
|
| 523 | + | t: number; |
|
| 524 | + | text: string; |
|
| 473 | 525 | }; |
|
| 474 | 526 | ||
| 475 | 527 | export type NewRelease = { |
|
| … | 6 unchanged lines |