Number Flow runs per repository a0d4bbfbedd1
Runs were shown and linked by the hub-wide row id, so a repository's first
run could be #37. Each run now has a number within its repository, from 1,
used by the run list, the run page (/{owner}/{name}/flow/{number}), its
live updates, the state stamp and check links.
Migration 0019 numbers existing runs per repository in creation order and
rewrites the links of existing flow checks to the new numbers; tried on a
copy of Earth's runs and checks first: every link names a run of its own
repository.
closes #23
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 22:19 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
a0d4bbfbedd151cb19737a2893e56a5b8b1b19b286c3da4f59cc16b1bc7c9cb7- Depends on
- 42fa95540468 736f6813543d 9008bf6c2b8c 93be9ce4653e af9c6ca11c49 d23464c0ac60
- Depended on by
- 5588bb3631d2 a9f1c0bf403a 55673c650986 5d90e0e045e2 1c8276b90314 070a7b64b368 4f6925337848 bf3d07541368 90326c9601f9 1edac4b68c6d
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 31 atoms, 1 tree operation, 8 files
Browse the tree at this change
added crates/hub/migrations/0019_flow_run_numbers.sql +10 −0
| 1 | + | -- Runs are numbered per repository (1, 2, 3 …), not by the hub-wide row id. |
|
| 2 | + | ALTER TABLE flow_runs ADD COLUMN number INTEGER NOT NULL DEFAULT 0; |
|
| 3 | + | UPDATE flow_runs SET number = ( |
|
| 4 | + | SELECT COUNT(*) FROM flow_runs r2 WHERE r2.repo_id = flow_runs.repo_id AND r2.id <= flow_runs.id |
|
| 5 | + | ); |
|
| 6 | + | CREATE UNIQUE INDEX flow_runs_number ON flow_runs (repo_id, number); |
|
| 7 | + | -- Checks link to their run's page by number now. |
|
| 8 | + | UPDATE checks SET url = substr(checks.url, 1, length(checks.url) - length(CAST(r.id AS TEXT))) || r.number |
|
| 9 | + | FROM flow_runs r |
|
| 10 | + | WHERE checks.repo_id = r.repo_id AND checks.name LIKE 'flow/%' AND checks.url LIKE '%/flow/' || r.id; |
modified crates/hub/src/db/flow.rs +9 −3
| … | 6 unchanged lines |
||
| 7 | 7 | pub struct Run { |
|
| 8 | 8 | pub id: i64, |
|
| 9 | 9 | pub repo_id: i64, |
|
| 10 | + | /// The run's number within its repository, from 1 (migration 0019). |
|
| 11 | + | #[sqlx(default)] |
|
| 12 | + | pub number: i64, |
|
| 10 | 13 | pub bookmark: String, |
|
| 11 | 14 | pub state: String, |
|
| 12 | 15 | pub heads: String, |
|
| … | 83 unchanged lines |
||
| 96 | 99 | let now = now_ms(); |
|
| 97 | 100 | let status = if r.error.is_some() { "error" } else { "queued" }; |
|
| 98 | 101 | let id: i64 = sqlx::query_scalar( |
|
| 99 | - | "INSERT INTO flow_runs (repo_id, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", |
|
| 102 | + | "INSERT INTO flow_runs (repo_id, number, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms) \ |
|
| 103 | + | VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM flow_runs WHERE repo_id = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", |
|
| 100 | 104 | ) |
|
| 101 | 105 | .bind(r.repo_id) |
|
| 106 | + | .bind(r.repo_id) |
|
| 102 | 107 | .bind(r.bookmark) |
|
| 103 | 108 | .bind(r.state) |
|
| 104 | 109 | .bind(r.heads) |
|
| … | 32 unchanged lines |
||
| 137 | 142 | .await |
|
| 138 | 143 | } |
|
| 139 | 144 | ||
| 140 | - | pub async fn run(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Run>> { |
|
| 141 | - | sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND id = ?").bind(repo_id).bind(id).fetch_optional(pool).await |
|
| 145 | + | /// A repository's run by its number there. |
|
| 146 | + | pub async fn run(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Option<Run>> { |
|
| 147 | + | sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND number = ?").bind(repo_id).bind(number).fetch_optional(pool).await |
|
| 142 | 148 | } |
|
| 143 | 149 | ||
| 144 | 150 | pub async fn run_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Run>> { |
|
| … | 219 unchanged lines |
modified crates/hub/src/flow.rs +10 −6
| … | 82 unchanged lines |
||
| 83 | 83 | .collect(); |
|
| 84 | 84 | let hash = hex::encode(p.hash()); |
|
| 85 | 85 | let id = db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await; |
|
| 86 | - | if let Ok(id) = id { |
|
| 86 | + | if let Ok(Some(run)) = match id { |
|
| 87 | + | Ok(id) => fdb::run_by_id(&app.db, id).await, |
|
| 88 | + | Err(_) => Ok(None), |
|
| 89 | + | } { |
|
| 87 | 90 | for j in &jobs { |
|
| 88 | - | report_check(app, row, state, id, &j.name, "queued", "waiting for a runner").await; |
|
| 91 | + | report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await; |
|
| 89 | 92 | } |
|
| 90 | 93 | } |
|
| 91 | 94 | id |
|
| … | 4 unchanged lines |
||
| 96 | 99 | } |
|
| 97 | 100 | } |
|
| 98 | 101 | ||
| 99 | - | async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_id: i64, job: &str, status: &str, summary: &str) { |
|
| 100 | - | let url = format!("{}/{}/{}/flow/{run_id}", app.config.base_url(), row.owner, row.name); |
|
| 102 | + | /// Record a job's check; it links to the run's page by the run's number. |
|
| 103 | + | async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) { |
|
| 104 | + | let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name); |
|
| 101 | 105 | let name = format!("flow/{job}"); |
|
| 102 | 106 | let c = db::hooks::upsert_check( |
|
| 103 | 107 | &app.db, |
|
| … | 42 unchanged lines |
||
| 146 | 150 | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 147 | 151 | let row = |
|
| 148 | 152 | db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
|
| 149 | - | report_check(&app, &row, &run.state, run.id, &job.name, "running", &format!("running on {runner}")).await; |
|
| 153 | + | report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await; |
|
| 150 | 154 | let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")); |
|
| 151 | 155 | let claim = Claim { |
|
| 152 | 156 | job_id: job.id, |
|
| … | 171 unchanged lines |
||
| 324 | 328 | "skipped" => "skipped: a dependency did not pass".into(), |
|
| 325 | 329 | _ => format!("could not run: {}", j.error), |
|
| 326 | 330 | }; |
|
| 327 | - | report_check(&app, &row, &run.state, run.id, &j.name, check_status(&j.status), &summary).await; |
|
| 331 | + | report_check(&app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await; |
|
| 328 | 332 | } |
|
| 329 | 333 | } |
|
| 330 | 334 | } |
|
| … | 22 unchanged lines |
modified crates/hub/src/web/flow.rs +3 −3
| … | 286 unchanged lines |
||
| 287 | 287 | let (heads, _) = state_of(&app, &owner, &name, &r).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 288 | 288 | let run = fdb::latest_for_heads(&app.db, row.id, &heads).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 289 | 289 | let to = match run { |
|
| 290 | - | Some(r) => format!("/{owner}/{name}/flow/{}", r.id), |
|
| 290 | + | Some(r) => format!("/{owner}/{name}/flow/{}", r.number), |
|
| 291 | 291 | None => format!("/{owner}/{name}/flow"), |
|
| 292 | 292 | }; |
|
| 293 | 293 | Ok(axum::response::Redirect::to(&to).into_response()) |
|
| … | 38 unchanged lines |
||
| 332 | 332 | } |
|
| 333 | 333 | let meta = serde_json::json!({ |
|
| 334 | 334 | "heads": heads.split('+').filter(|h| !h.is_empty()).collect::<Vec<_>>(), |
|
| 335 | - | "run": run.map(|r| r.id), |
|
| 335 | + | "run": run.map(|r| r.number), |
|
| 336 | 336 | "status": status, |
|
| 337 | 337 | "jobs": ordered.iter().map(|j| serde_json::json!({ "name": j.name, "status": j.status, "receipt": j.receipt_hash })).collect::<Vec<_>>(), |
|
| 338 | 338 | }); |
|
| 339 | 339 | let title = match run { |
|
| 340 | - | Some(r) => format!("OxygenFlow run #{} {status} on state {short}", r.id), |
|
| 340 | + | Some(r) => format!("OxygenFlow run #{} {status} on state {short}", r.number), |
|
| 341 | 341 | None => format!("OxygenFlow: no run for state {short}"), |
|
| 342 | 342 | }; |
|
| 343 | 343 | format!( |
|
| … | 11 unchanged lines |
modified crates/hub/tests/flow.rs +25 −6
| … | 256 unchanged lines |
||
| 257 | 257 | checks.iter().map(|c| (c.name.as_str(), c.status.as_str())).collect::<Vec<_>>(), |
|
| 258 | 258 | [("flow/build", "passed"), ("flow/lint", "passed"), ("flow/test", "failed")] |
|
| 259 | 259 | ); |
|
| 260 | - | assert!(checks[2].summary.contains("exit 3") && checks[2].url.ends_with(&format!("/andy/demo/flow/{}", run.id))); |
|
| 260 | + | assert!(checks[2].summary.contains("exit 3") && checks[2].url.ends_with(&format!("/andy/demo/flow/{}", run.number))); |
|
| 261 | 261 | ||
| 262 | 262 | // Pages: the list, the run with its graph and logs, and the live view. |
|
| 263 | 263 | let (st, list) = call(&h.app, "GET", "/andy/demo/flow", None, None).await; |
|
| 264 | 264 | assert_eq!(st, StatusCode::OK); |
|
| 265 | - | assert!(list.contains(&format!("/andy/demo/flow/{}", run.id)) && list.contains("st-failed"), "{list}"); |
|
| 266 | - | let (st, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", run.id), None, None).await; |
|
| 265 | + | assert!(list.contains(&format!("/andy/demo/flow/{}", run.number)) && list.contains("st-failed"), "{list}"); |
|
| 266 | + | let (st, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", run.number), None, None).await; |
|
| 267 | 267 | assert_eq!(st, StatusCode::OK); |
|
| 268 | 268 | assert!(page.contains("<svg class=\"flow-graph\""), "graph"); |
|
| 269 | 269 | assert_eq!(page.matches("data-job-node=").count(), 3); |
|
| 270 | 270 | assert_eq!(page.matches("<path class=\"edge").count(), 1, "one dependency edge"); |
|
| 271 | 271 | assert!(page.contains("cc -o demo demo.c"), "log shown"); |
|
| 272 | 272 | assert!(!page.contains("data-flow-live"), "a finished run does not poll"); |
|
| 273 | - | let (st, live) = call(&h.app, "GET", &format!("/andy/demo/flow/{}/live?after={a_id}:1", run.id), None, None).await; |
|
| 273 | + | let (st, live) = call(&h.app, "GET", &format!("/andy/demo/flow/{}/live?after={a_id}:1", run.number), None, None).await; |
|
| 274 | 274 | assert_eq!(st, StatusCode::OK); |
|
| 275 | 275 | let live: serde_json::Value = serde_json::from_str(&live).unwrap(); |
|
| 276 | 276 | assert_eq!(live["status"], "failed"); |
|
| … | 55 unchanged lines |
||
| 332 | 332 | let third = third.expect("a third run"); |
|
| 333 | 333 | assert_eq!(third.status, "error"); |
|
| 334 | 334 | assert!(third.error.contains("line 18") && third.error.contains("\"missing\""), "{}", third.error); |
|
| 335 | - | let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", third.id), None, None).await; |
|
| 335 | + | let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", third.number), None, None).await; |
|
| 336 | 336 | assert!(page.contains("The pipeline is not valid")); |
|
| 337 | 337 | ||
| 338 | 338 | // State stamps (hub spec §13.2) and images in READMEs. |
|
| … | 164 unchanged lines |
||
| 503 | 503 | let h = hub().await; |
|
| 504 | 504 | create(&h.app, "other", "plain").await; |
|
| 505 | 505 | create(&h.app, "andy", "quiet").await; |
|
| 506 | + | // A run elsewhere first, so hub-wide ids and per-repository numbers differ. |
|
| 507 | + | create(&h.app, "andy", "first").await; |
|
| 508 | + | { |
|
| 509 | + | let work = tempfile::tempdir().unwrap(); |
|
| 510 | + | let c = client(&format!("{}/andy/first", h.base), work.path()); |
|
| 511 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 512 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 513 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 514 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 515 | + | assert_eq!(runs_of(&h.app, "andy", "first").await.len(), 1); |
|
| 516 | + | } |
|
| 506 | 517 | let row = db::repos::get(&h.app.db, "other", "plain").await.unwrap().unwrap(); |
|
| 507 | 518 | db::repos::set_flow(&h.app.db, row.id, "on").await.unwrap(); |
|
| 508 | 519 | let quiet = db::repos::get(&h.app.db, "andy", "quiet").await.unwrap().unwrap(); |
|
| … | 6 unchanged lines |
||
| 515 | 526 | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 516 | 527 | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 517 | 528 | } |
|
| 518 | - | assert_eq!(runs_of(&h.app, "other", "plain").await.len(), 1, "turned on"); |
|
| 529 | + | let plain = runs_of(&h.app, "other", "plain").await; |
|
| 530 | + | assert_eq!(plain.len(), 1, "turned on"); |
|
| 531 | + | // Runs are numbered per repository: this is plain's first, whatever |
|
| 532 | + | // other repositories ran before. |
|
| 533 | + | assert_eq!(plain[0].number, 1); |
|
| 534 | + | let checks = db::hooks::checks_for_state(&h.app.db, row.id, &plain[0].state).await.unwrap(); |
|
| 535 | + | assert!(checks.iter().all(|c| c.url.ends_with("/other/plain/flow/1")), "{checks:?}"); |
|
| 536 | + | let (st, page) = call(&h.app, "GET", "/other/plain/flow/1", None, None).await; |
|
| 537 | + | assert!(st == StatusCode::OK && page.contains("Run #1"), "{st}"); |
|
| 519 | 538 | assert!(runs_of(&h.app, "andy", "quiet").await.is_empty(), "turned off despite andy/*"); |
|
| 520 | 539 | } |
modified docs/spec.md +1 −1
| … | 419 unchanged lines |
||
| 420 | 420 | ||
| 421 | 421 | - **Configuration.** `[flow] runner_tokens` (bearer tokens for runners, compared in constant time), `repos` (patterns such as `owner/*` or `owner/name`: the repositories whose pushes start runs by default; empty means none) and `job_timeout_minutes` (60). |
|
| 422 | 422 | - **Per repository.** A site administrator turns OxygenFlow on or off for one repository under Settings → OxygenFlow, which overrides `repos` in either direction; "site default" follows it again. Runs use the site's runners, so repository administrators see the setting but cannot change it. Changes are audited (`repo.flow`) and apply from the next push, with no restart. |
|
| 423 | - | - **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state: the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs. |
|
| 423 | + | - **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state, numbered within its repository from 1 (the run's page is `/{owner}/{name}/flow/{number}`): the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs. |
|
| 424 | 424 | - **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`. |
|
| 425 | 425 | - **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it. |
|
| 426 | 426 | - **Job tokens.** Random, stored as a hash, valid only for reading the job's repository over the wire protocol while the job is running and for at most two hours. They cannot push, call the API or read other repositories. |
|
| … | 158 unchanged lines |
modified templates/flow_run.html +4 −4
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "repo_tabs.html" as m %} |
|
| 3 | - | {% block title %}Run #{{ run.id }} · Flow · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | - | {% block bodyattrs %} data-repo="{{ repo.url }}"{% if run.live() %} data-flow-live="{{ repo.url }}/flow/{{ run.id }}/live"{% endif %}{% endblock %} |
|
| 3 | + | {% block title %}Run #{{ run.number }} · Flow · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% if run.live() %} data-flow-live="{{ repo.url }}/flow/{{ run.number }}/live"{% endif %}{% endblock %} |
|
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %} |
|
| 6 | 6 | {% block mainclass %} class="wide"{% endblock %} |
|
| 7 | 7 | {% block content %} |
|
| 8 | 8 | <div class="head"> |
|
| 9 | - | <h1><span class="flow-glyph st-{{ run.status }}" data-run-glyph>{{ crate::web::flow::glyph(run.status) }}</span> Run #{{ run.id }} <small data-run-status>{{ run.status }}</small></h1> |
|
| 9 | + | <h1><span class="flow-glyph st-{{ run.status }}" data-run-glyph>{{ crate::web::flow::glyph(run.status) }}</span> Run #{{ run.number }} <small data-run-status>{{ run.status }}</small></h1> |
|
| 10 | 10 | <p class="small mute"><span class="lbl lbl-bm">{{ run.bookmark }}</span> at <a href="{{ repo.url }}/tree/{{ run.heads }}" title="state {{ run.state }}"><code>{{ run.state|short }}</code></a> · pushed by {{ run.actor }} <time title="{{ run.created_ms|when_i }}">{{ run.created_ms|ago_i }}</time> · {{ duration }}{% if !run.pipeline_hash.is_empty() %} · pipeline <code title="{{ run.pipeline_hash }}">{{ run.pipeline_hash|short }}</code>{% endif %} · <a href="{{ repo.url }}/file/{{ run.heads }}/.flow/pipeline.kdl">.flow/pipeline.kdl</a> · <a href="{{ repo.url }}/flow">all runs</a></p> |
|
| 11 | 11 | </div> |
|
| 12 | 12 | {% if run.status == "error" %} |
|
| 13 | 13 | <section class="panel"><div class="strip bad">The pipeline is not valid, so nothing ran.</div><pre class="snippet">{{ run.error }}</pre><p class="small mute">Run <code>flow check</code> locally to see the same problems.</p></section> |
|
| 14 | 14 | {% else %} |
|
| 15 | 15 | <section class="panel flow-graph-box"> |
|
| 16 | - | <svg class="flow-graph" viewBox="0 0 {{ graph.width }} {{ graph.height }}" width="{{ graph.width }}" height="{{ graph.height }}" role="img" aria-label="Job graph of run {{ run.id }}"> |
|
| 16 | + | <svg class="flow-graph" viewBox="0 0 {{ graph.width }} {{ graph.height }}" width="{{ graph.width }}" height="{{ graph.height }}" role="img" aria-label="Job graph of run {{ run.number }}"> |
|
| 17 | 17 | {% for e in graph.edges %}<path class="edge st-{{ e.status }}" d="{{ e.d }}"/>{% endfor %} |
|
| 18 | 18 | {% for n in graph.nodes %} |
|
| 19 | 19 | <a href="#job-{{ n.j.id }}" data-job-node="{{ n.j.id }}" class="node st-{{ n.j.status }}"> |
|
| … | 20 unchanged lines |
modified templates/flow_runs.html +1 −1
| … | 31 unchanged lines |
||
| 32 | 32 | {% for row in runs %} |
|
| 33 | 33 | <tr class="st-{{ row.r.status }}"> |
|
| 34 | 34 | <td class="st"><span class="flow-glyph st-{{ row.r.status }}" title="{{ row.r.status }}">{{ crate::web::flow::glyph(row.r.status) }}</span></td> |
|
| 35 | - | <td><a href="{{ repo.url }}/flow/{{ row.r.id }}"><strong>#{{ row.r.id }}</strong></a> <span class="small mute">{{ row.r.status }}</span></td> |
|
| 35 | + | <td><a href="{{ repo.url }}/flow/{{ row.r.number }}"><strong>#{{ row.r.number }}</strong></a> <span class="small mute">{{ row.r.status }}</span></td> |
|
| 36 | 36 | <td class="flow-dots">{% for (n, s) in row.jobs %}<span class="flow-glyph st-{{ s }}" title="{{ n }}: {{ s }}">{{ crate::web::flow::glyph(s) }}</span>{% endfor %}{% if row.r.status == "error" %}<span class="small bad">pipeline invalid</span>{% endif %}</td> |
|
| 37 | 37 | <td><span class="lbl lbl-bm">{{ row.r.bookmark }}</span></td> |
|
| 38 | 38 | <td><a href="{{ repo.url }}/tree/{{ row.r.heads }}" title="state {{ row.r.state }}"><code>{{ row.r.state|short }}</code></a></td> |
|
| … | 10 unchanged lines |