OXYGENHUB

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