OXYGENHUB

Coverage / crates/hub/src/web/flow.rs 78.2%

639 of 817 lines run, 85 of 282 functions · run #521 on main · run missed

1
//! The Flow tab (hub spec §13.2): runs of a repository, and a run's job
2
//! graph with live status and logs.
3
4
use super::settings::{csrf, require};
5
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
6
use crate::auth::{Access, Role, Viewer};
7
use crate::db::{self, flow as fdb};
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, Path, Query, State};
11
use axum::response::{IntoResponse, Redirect, Response};
12
use axum::Json;
13
use serde::Deserialize;
14
15
pub struct RunRow {
16
    pub r: fdb::Run,
17
    pub duration: String,
18
    pub jobs: Vec<(String, String)>,
19
    /// "3rd in line" for a run still waiting for a runner.
20
    pub place: Option<String>,
21
}
22
23
#[derive(Template)]
24
#[template(path = "flow_runs.html")]
25
pub struct RunsPage {
26
    shell: Shell,
27
    repo: RepoShell,
28
    runs: Vec<RunRow>,
29
    enabled: bool,
30
    older: Option<i64>,
31
    /// Writers may start runs: the bookmarks to offer, default first.
32
    can_run: bool,
33
    bookmarks: Vec<String>,
34
    error: Option<String>,
35
    /// Why this viewer is not offered "Run pipeline", when they are not.
36
    why_not: Option<String>,
37
}
38
39
#[derive(Deserialize)]
40
pub struct RunsQuery {
41
    before: Option<i64>,
42
    /// Why a "Run pipeline" or "Re-run" did not start.
43
    error: Option<String>,
44
}
45
46
/// Why `viewer` may not start runs here, or `None` when they may.
4736×
pub fn why_not_run(enabled: bool, signed_in: bool, role: Role) -> Option<String> {
4836×
    if !enabled {
4930×
        Some("Flow is off for this repository; a site administrator can turn it on in Settings › Pipelines.".into())
506×
    } else if !signed_in {
515×
        Some("Sign in with write access to run the pipeline.".into())
521×
    } else if role < Role::Write {
530×
        Some("Running the pipeline needs write access to this repository.".into())
54
    } else {
551×
        None
56
    }
5736×
}
58
59
const PER_PAGE: i64 = 30;
60
61
#[derive(Template)]
62
#[template(path = "deployments.html")]
63
pub struct DeploymentsPage {
64
    shell: Shell,
65
    repo: RepoShell,
66
    /// Each environment's newest deployment, by name.
67
    current: Vec<fdb::Deployment>,
68
    /// Every recent deployment, newest first.
69
    history: Vec<fdb::Deployment>,
70
}
71
72
/// `GET /{owner}/{name}/deployments`: what each environment runs
73
/// (oxygenflow#59).
741×
pub async fn deployments(
751×
    State(app): State<Shared>,
761×
    viewer: Viewer,
771×
    Path((owner, name)): Path<(String, String)>,
781×
) -> Result<Page<DeploymentsPage>, Error> {
791×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/deployments"), &viewer);
801×
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
811×
    let history = fdb::deployments(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
821×
    let mut current: Vec<fdb::Deployment> = Vec::new();
831×
    for d in &history {
841×
        if !current.iter().any(|c| c.environment == d.environment) {
851×
            current.push(d.clone());
861×
        }
87
    }
881×
    current.sort_by(|a, b| a.environment.cmp(&b.environment));
891×
    Ok(Page(DeploymentsPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), current, history }))
901×
}
91
927×
pub async fn runs(
937×
    State(app): State<Shared>,
947×
    viewer: Viewer,
957×
    Path((owner, name)): Path<(String, String)>,
967×
    Query(q): Query<RunsQuery>,
977×
) -> Result<Page<RunsPage>, Error> {
987×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer);
997×
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1007×
    let list = fdb::runs(&app.db, row.id, PER_PAGE + 1, q.before).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1017×
    let older = (list.len() as i64 > PER_PAGE).then(|| list[PER_PAGE as usize - 1].id);
1027×
    let queue = if list.iter().any(|r| r.live()) { fdb::queue(&app.db).await.unwrap_or_default() } else { vec![] };
1037×
    let mut runs = Vec::new();
10410×
    for r in list.into_iter().take(PER_PAGE as usize) {
10510×
        let place = if r.live() { crate::flow::queue_info(&queue, row.id, r.id).place() } else { None };
10610×
        let jobs = fdb::jobs(&app.db, r.id).await.unwrap_or_default();
10710×
        let duration = match r.finished_ms {
1083×
            Some(f) => crate::flow::human_ms(f - r.created_ms),
1097×
            None if r.live() => crate::flow::human_ms(db::now_ms() - r.created_ms),
1100×
            None => "–".into(),
111
        };
11229×
        runs.push(RunRow { jobs: jobs.into_iter().map(|j| (j.name, j.status)).collect(), duration, r, place });
113
    }
1147×
    let enabled = crate::flow::enabled(&app, &row);
1157×
    let why_not = why_not_run(enabled, viewer.account().is_some(), role);
1167×
    let can_run = why_not.is_none();
1177×
    let bookmarks = if can_run { bookmark_names(&app, &row).await } else { vec![] };
1187×
    Ok(Page(RunsPage {
1197×
        shell,
1207×
        repo: RepoShell::new(&app, &row, "flow").with_role(role),
1217×
        runs,
1227×
        enabled,
1237×
        older,
1247×
        can_run,
1257×
        bookmarks,
1267×
        error: q.error,
1277×
        why_not,
1287×
    }))
1297×
}
130
131
/// The repository's bookmarks, its default first.
1321×
async fn bookmark_names(app: &Shared, row: &db::repos::RepoRow) -> Vec<String> {
1331×
    let Some(repo) = crate::repos::RepoName::new(&row.owner, &row.name) else { return vec![] };
1341×
    let mut names: Vec<String> = app.repos.read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect())).await.unwrap_or_default();
1351×
    names.sort_by_key(|n| (n != &row.default_bookmark, n.clone()));
1361×
    names
1371×
}
138
139
#[derive(Deserialize)]
140
pub struct StartForm {
141
    #[serde(rename = "_csrf")]
142
    csrf_token: String,
143
    #[serde(default)]
144
    bookmark: String,
145
    /// A change's full hash: run the state whose only head it is.
146
    #[serde(default)]
147
    change: String,
148
}
149
150
/// `POST /{owner}/{name}/flow/start`: "Run pipeline" on a bookmark's
151
/// current state, or on one committed change.
1520×
pub async fn start(
1530×
    State(app): State<Shared>,
1540×
    viewer: Viewer,
1550×
    Path((owner, name)): Path<(String, String)>,
1560×
    Form(f): Form<StartForm>,
1570×
) -> Result<Response, Error> {
1580×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer);
1590×
    let me = require(&viewer, &shell)?;
1600×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
1610×
    csrf(&viewer, &f.csrf_token, &shell)?;
1620×
    let (started, what) = if f.change.trim().is_empty() {
1630×
        (crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username, &[]).await, f.bookmark.trim().to_string())
164
    } else {
1650×
        (crate::flow::run_change(&app, &row, &f.change, &me.account.username, &[]).await, format!("change {}", f.change.trim()))
166
    };
1670×
    Ok(match started {
1680×
        Ok(n) => {
1690×
            db::accounts::audit(&app.db, Some(&me.account), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {what}")).await.ok();
1700×
            Redirect::to(&format!("/{owner}/{name}/flow/{n}")).into_response()
171
        }
1720×
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
173
    })
1740×
}
175
176
#[derive(Deserialize)]
177
pub struct RerunForm {
178
    #[serde(rename = "_csrf")]
179
    csrf_token: String,
180
}
181
182
/// `POST /{owner}/{name}/flow/{number}/rerun`.
1830×
pub async fn rerun(
1840×
    State(app): State<Shared>,
1850×
    viewer: Viewer,
1860×
    Path((owner, name, number)): Path<(String, String, i64)>,
1870×
    Form(f): Form<RerunForm>,
1880×
) -> Result<Response, Error> {
1890×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
1900×
    let me = require(&viewer, &shell)?;
1910×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
1920×
    csrf(&viewer, &f.csrf_token, &shell)?;
1930×
    let run = fdb::run(&app.db, row.id, number)
1940×
        .await
1950×
        .map_err(|e| Error::from_db(shell.clone(), e))?
1960×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1970×
    Ok(match crate::flow::rerun(&app, &row, &run, &me.account.username).await {
1980×
        Ok(n) => {
1990×
            db::accounts::audit(&app.db, Some(&me.account), "flow.rerun", &format!("{owner}/{name}"), &format!("#{n} of #{number}"))
2000×
                .await
2010×
                .ok();
2020×
            Redirect::to(&format!("/{owner}/{name}/flow/{n}")).into_response()
203
        }
2040×
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
205
    })
2060×
}
207
208
/// `POST /{owner}/{name}/flow/{number}/jobs/{job}/rerun` (oxygenflow#31).
2090×
pub async fn rerun_job(
2100×
    State(app): State<Shared>,
2110×
    viewer: Viewer,
2120×
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
2130×
    Form(f): Form<RerunForm>,
2140×
) -> Result<Response, Error> {
2150×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
2160×
    let me = require(&viewer, &shell)?;
2170×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
2180×
    csrf(&viewer, &f.csrf_token, &shell)?;
2190×
    let run = fdb::run(&app.db, row.id, number)
2200×
        .await
2210×
        .map_err(|e| Error::from_db(shell.clone(), e))?
2220×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
2230×
    Ok(match crate::flow::rerun_job(&app, &row, &run, &job, &me.account.username).await {
224
        Ok(_) => {
2250×
            db::accounts::audit(&app.db, Some(&me.account), "flow.rerun", &format!("{owner}/{name}"), &format!("{job} of #{number}"))
2260×
                .await
2270×
                .ok();
2280×
            Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response()
229
        }
2300×
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
231
    })
2320×
}
233
234
/// `POST /{owner}/{name}/flow/{number}/cancel` (oxygenflow#32).
2350×
pub async fn cancel(
2360×
    State(app): State<Shared>,
2370×
    viewer: Viewer,
2380×
    Path((owner, name, number)): Path<(String, String, i64)>,
2390×
    Form(f): Form<RerunForm>,
2400×
) -> Result<Response, Error> {
2410×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
2420×
    let me = require(&viewer, &shell)?;
2430×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
2440×
    csrf(&viewer, &f.csrf_token, &shell)?;
2450×
    let run = fdb::run(&app.db, row.id, number)
2460×
        .await
2470×
        .map_err(|e| Error::from_db(shell.clone(), e))?
2480×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
2490×
    Ok(match crate::flow::cancel(&app, &row, &run, &me.account.username).await {
250
        Ok(()) => {
2510×
            db::accounts::audit(&app.db, Some(&me.account), "flow.cancel", &format!("{owner}/{name}"), &format!("#{number}")).await.ok();
2520×
            Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response()
253
        }
2540×
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
255
    })
2560×
}
257
258
#[derive(Deserialize)]
259
pub struct ExpediteForm {
260
    #[serde(rename = "_csrf")]
261
    csrf_token: String,
262
    #[serde(default)]
263
    reason: String,
264
}
265
266
/// `POST /{owner}/{repo}/flow/{n}/expedite` (oxygenhub#244): a maintainer
267
/// raises a running or queued run to urgent, saying why (an incident's
268
/// fix, say). Audited and shown on the run; it lapses when the run ends.
2691×
pub async fn expedite(
2701×
    State(app): State<Shared>,
2711×
    viewer: Viewer,
2721×
    Path((owner, name, number)): Path<(String, String, i64)>,
2731×
    Form(f): Form<ExpediteForm>,
2741×
) -> Result<Response, Error> {
2751×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
2761×
    let me = require(&viewer, &shell)?;
2771×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
2781×
    csrf(&viewer, &f.csrf_token, &shell)?;
2791×
    let run = fdb::run(&app.db, row.id, number)
2801×
        .await
2811×
        .map_err(|e| Error::from_db(shell.clone(), e))?
2821×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
2831×
    if let Err(msg) = crate::flow::expedite(&app, &row, &run, &me.account, &f.reason).await {
2840×
        return Ok(Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response());
2851×
    }
2861×
    Ok(Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response())
2871×
}
288
289
/// Percent-encode a message for a query string.
2900×
fn urlencoding(s: &str) -> String {
2910×
    s.bytes()
2920×
        .map(|b| match b {
2930×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(),
2940×
            _ => format!("%{b:02X}"),
2950×
        })
2960×
        .collect()
2970×
}
298
299
/// A job placed in the graph.
300
pub struct Node {
301
    pub j: fdb::Job,
302
    pub x: i64,
303
    pub y: i64,
304
    pub duration: String,
305
}
306
307
pub struct Edge {
308
    pub d: String,
309
    pub status: String,
310
}
311
312
pub struct Graph {
313
    pub nodes: Vec<Node>,
314
    pub edges: Vec<Edge>,
315
    pub width: i64,
316
    pub height: i64,
317
}
318
319
pub const NODE_W: i64 = 200;
320
pub const NODE_H: i64 = 48;
321
const COL_GAP: i64 = 72;
322
const ROW_GAP: i64 = 18;
323
const PAD: i64 = 12;
324
325
/// Columns by dependency depth, jobs in each column in name order.
32623×
pub fn layout(jobs: Vec<fdb::Job>) -> Graph {
32723×
    let mut by_depth: std::collections::BTreeMap<i64, Vec<fdb::Job>> = Default::default();
32854×
    for j in jobs {
32954×
        by_depth.entry(j.depth).or_default().push(j);
33054×
    }
33123×
    let rows = by_depth.values().map(Vec::len).max().unwrap_or(0) as i64;
33223×
    let height = PAD * 2 + rows * NODE_H + (rows - 1).max(0) * ROW_GAP;
33323×
    let mut nodes = Vec::new();
33439×
    for (col, (_, mut js)) in by_depth.into_iter().enumerate() {
33539×
        js.sort_by(|a, b| a.name.cmp(&b.name));
33639×
        let n = js.len() as i64;
33739×
        let column_h = n * NODE_H + (n - 1).max(0) * ROW_GAP;
33839×
        let top = (height - column_h) / 2;
33954×
        for (i, j) in js.into_iter().enumerate() {
34054×
            let duration = crate::flow::duration(&j);
34154×
            nodes.push(Node { x: PAD + col as i64 * (NODE_W + COL_GAP), y: top + i as i64 * (NODE_H + ROW_GAP), duration, j });
34254×
        }
343
    }
34423×
    let mut edges = Vec::new();
34554×
    for n in &nodes {
34654×
        for d in n.j.depends_on() {
34720×
            if let Some(from) = nodes.iter().find(|m| m.j.name == d) {
34818×
                let (x1, y1) = (from.x + NODE_W, from.y + NODE_H / 2);
34918×
                let (x2, y2) = (n.x, n.y + NODE_H / 2);
35018×
                let mid = (x1 + x2) / 2;
35118×
                edges.push(Edge { d: format!("M{x1},{y1} C{mid},{y1} {mid},{y2} {x2},{y2}"), status: from.j.status.clone() });
35218×
            }
353
        }
354
    }
35523×
    let cols = nodes.iter().map(|n| n.x).max().map(|x| x + NODE_W + PAD).unwrap_or(PAD * 2);
35623×
    Graph { nodes, edges, width: cols, height: height.max(NODE_H + PAD * 2) }
35723×
}
358
359179×
pub fn glyph(status: &str) -> &'static str {
360179×
    match status {
361179×
        "passed" => "✓",
362122×
        "failed" => "✕",
363109×
        "running" => "●",
36496×
        "skipped" => "⤼",
36595×
        "cancelled" => "⊘",
36686×
        "error" => "!",
36785×
        _ => "○",
368
    }
369179×
}
370
371
/// Failed tests a run page lists per job; the rest are counted.
372
const SHOWN_FAILURES: i64 = 50;
373
374
/// A stretch of a job's log as the run page shows it: a line, or a
375
/// section the job marked with `::group::TITLE` … `::endgroup::`
376
/// (oxygenflow#31), shown folded with how long it took.
377
pub enum Segment {
378
    Line(fdb::LogLine),
379
    Group(Section),
380
}
381
382
pub struct Section {
383
    pub title: String,
384
    /// When it began, and ended (`None` while it is still going).
385
    pub from_t: i64,
386
    pub to_t: Option<i64>,
387
    pub lines: Vec<fdb::LogLine>,
388
}
389
390
/// A log's lines in sections. A section left open is still going; a new
391
/// `::group::` ends the one before (sections do not nest).
39255×
pub fn sections(lines: &[fdb::LogLine]) -> Vec<Segment> {
39355×
    let mut out: Vec<Segment> = Vec::new();
39455×
    let mut open: Option<Section> = None;
39555×
    for l in lines {
39633×
        if let Some(title) = l.text.strip_prefix("::group::") {
3976×
            if let Some(g) = open.take() {
3981×
                out.push(Segment::Group(Section { to_t: Some(l.t), ..g }));
3995×
            }
4006×
            open = Some(Section { title: title.trim().to_string(), from_t: l.t, to_t: None, lines: Vec::new() });
40127×
        } else if l.text.trim() == "::endgroup::" {
4024×
            if let Some(g) = open.take() {
4034×
                out.push(Segment::Group(Section { to_t: Some(l.t), ..g }));
4044×
            }
405
        } else {
40623×
            match open.as_mut() {
4077×
                Some(g) => g.lines.push(l.clone()),
40816×
                None => out.push(Segment::Line(l.clone())),
409
            }
410
        }
411
    }
41255×
    if let Some(g) = open {
4131×
        out.push(Segment::Group(g));
41454×
    }
41555×
    out
41655×
}
417
418
/// Milliseconds as the log shows them: `12.3s`.
41929×
pub fn secs(ms: impl std::borrow::Borrow<i64>) -> String {
42029×
    let ms = *ms.borrow();
42129×
    format!("{}.{}s", ms / 1000, ms % 1000 / 100)
42229×
}
423
424
pub struct JobLog {
425
    pub j: fdb::Job,
426
    pub artifacts: Vec<fdb::Artifact>,
427
    pub lines: Vec<fdb::LogLine>,
428
    /// The lines in sections (oxygenflow#31).
429
    pub segments: Vec<Segment>,
430
    pub duration: String,
431
    /// The last line shown; the live view asks for what follows.
432
    pub last_seq: i64,
433
    /// The job's coverage report, when it uploaded one (#113): its id and percentage.
434
    pub coverage: Option<(i64, String)>,
435
    /// The job's test report, when it uploaded one (#114), and its first
436
    /// failures.
437
    pub tests: Option<crate::junit::Report>,
438
    pub failures: Vec<crate::junit::Case>,
439
}
440
441
#[derive(Template)]
442
#[template(path = "flow_run.html")]
443
pub struct RunPage {
444
    shell: Shell,
445
    repo: RepoShell,
446
    run: fdb::Run,
447
    graph: Graph,
448
    logs: Vec<JobLog>,
449
    duration: String,
450
    /// Whether the viewer may re-run it.
451
    can_rerun: bool,
452
    /// Whether the viewer may cancel it.
453
    can_cancel: bool,
454
    /// May raise the run to urgent (oxygenhub#244): maintainers, while it runs.
455
    can_expedite: bool,
456
    /// Where a live run stands among the runners' work.
457
    queue: Option<crate::flow::QueueInfo>,
458
    /// Releases a writer may attach this run's artifacts to.
459
    releases: Vec<db::releases::Release>,
460
    /// Jobs that wait, or waited, for a person (oxygenflow#57).
461
    approvals: Vec<ApprovalView>,
462
}
463
464
/// A job's approval as the run page shows it.
465
pub struct ApprovalView {
466
    pub job: String,
467
    pub by: String,
468
    pub others_only: bool,
469
    /// `waiting`, `approved`, `rejected` or `expired`.
470
    pub state: &'static str,
471
    pub who: String,
472
    pub when_ms: i64,
473
    /// Whether the viewer may approve or reject it now, else why not.
474
    pub may: Result<(), String>,
475
    /// It takes an approval signed with the approver's key (#235): the
476
    /// command that signs it, and the approval's hash once sealed.
477
    pub sealed: bool,
478
    pub command: String,
479
    pub seal: String,
480
}
481
482
impl ApprovalView {
4832×
    pub fn may_decide(&self) -> bool {
4842×
        self.may.is_ok()
4852×
    }
486
4872×
    pub fn why_not(&self) -> String {
4882×
        self.may.clone().err().unwrap_or_default()
4892×
    }
490
}
491
492
/// Lines of a job's log a page shows before "the rest is loaded as it comes".
493
const PAGE_LINES: i64 = 5000;
494
49524×
pub async fn run(
49624×
    State(app): State<Shared>,
49724×
    viewer: Viewer,
49824×
    Path((owner, name, id)): Path<(String, String, i64)>,
49924×
) -> Result<Page<RunPage>, Error> {
50024×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}"), &viewer);
50124×
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
50224×
    let run = fdb::run(&app.db, row.id, id)
50324×
        .await
50424×
        .map_err(|e| Error::from_db(shell.clone(), e))?
50524×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
50623×
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
50723×
    let reports = crate::coverage::for_run(&app.db, run.id).await.unwrap_or_default();
50823×
    let test_reports = crate::junit::for_run(&app.db, run.id).await.unwrap_or_default();
50923×
    let mut logs = Vec::new();
51054×
    for j in &jobs {
51154×
        let lines = fdb::log(&app.db, j.id, 0, PAGE_LINES).await.unwrap_or_default();
51254×
        let last_seq = lines.last().map(|l| l.seq).unwrap_or(0);
51354×
        let artifacts = fdb::artifacts(&app.db, j.id).await.unwrap_or_default();
51454×
        let coverage = reports
51554×
            .iter()
51654×
            .find(|r| r.job_id == Some(j.id))
51754×
            .map(|r| (r.id, r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into())));
51854×
        let tests = test_reports.iter().find(|r| r.job_id == j.id).cloned();
51954×
        let failures = match &tests {
5201×
            Some(r) if r.broken() > 0 => crate::junit::failures(&app.db, r.id, SHOWN_FAILURES).await.unwrap_or_default(),
52153×
            _ => vec![],
522
        };
52354×
        let segments = sections(&lines);
52454×
        logs.push(JobLog {
52554×
            duration: crate::flow::duration(j),
52654×
            j: j.clone(),
52754×
            artifacts,
52854×
            lines,
52954×
            segments,
53054×
            last_seq,
53154×
            coverage,
53254×
            tests,
53354×
            failures,
53454×
        });
535
    }
53623×
    let duration = match run.finished_ms {
53711×
        Some(f) => crate::flow::human_ms(f - run.created_ms),
53812×
        None => crate::flow::human_ms(db::now_ms() - run.created_ms),
539
    };
54023×
    let mut approvals = Vec::new();
54154×
    for j in jobs.iter().filter(|j| !j.approval.is_empty()) {
5424×
        let Some(a) = serde_json::from_str::<flow_core::Job>(&j.spec).ok().and_then(|s| s.approval) else { continue };
5434×
        let state = match (j.approval.as_str(), j.status.as_str()) {
5444×
            ("waiting", "queued") => "waiting",
5452×
            ("approved", _) => "approved",
5460×
            (_, "cancelled") if !j.approved_by.is_empty() => "rejected",
5470×
            (_, "cancelled") if j.error.starts_with("no approval within") => "expired",
5480×
            _ => continue,
549
        };
5504×
        let may = match (state, viewer.account()) {
5514×
            ("waiting", Some(me)) => crate::flow::may_decide(&app, &row, &run, j, me).await.map(|_| ()),
5522×
            ("waiting", None) => Err("sign in to approve".into()),
5532×
            _ => Err(String::new()),
554
        };
5554×
        approvals.push(ApprovalView {
5564×
            sealed: a.sealed,
5574×
            command: format!("oxy seal --flow {}/{owner}/{name}/flow/{} --job {}", app.config.base_url(), run.number, j.name),
5584×
            seal: j.approval_seal.clone(),
5594×
            job: j.name.clone(),
5604×
            by: a.by,
5614×
            others_only: a.others_only,
5624×
            state,
5634×
            who: if j.approved_via.is_empty() { j.approved_by.clone() } else { format!("{} via {}", j.approved_by, j.approved_via) },
5644×
            when_ms: j.approved_ms.unwrap_or(0),
5654×
            may,
566
        });
567
    }
56823×
    let graph = layout(jobs);
56923×
    let can_write = viewer.account().is_some() && role >= Role::Write;
57023×
    let can_rerun = !run.live() && can_write && crate::flow::enabled(&app, &row);
57123×
    let can_cancel = run.live() && can_write;
57223×
    let can_expedite = run.live() && run.priority.is_empty() && viewer.account().is_some() && role >= Role::Maintain;
57323×
    let queue = if run.live() {
57412×
        let q = fdb::queue(&app.db).await.unwrap_or_default();
57512×
        Some(crate::flow::queue_info(&q, row.id, run.id)).filter(|i| i.position.is_some())
576
    } else {
57711×
        None
578
    };
57923×
    let releases = if viewer.account().is_some() && role >= Role::Write && logs.iter().any(|l| !l.artifacts.is_empty()) {
5801×
        db::releases::list(&app.db, row.id).await.unwrap_or_default()
581
    } else {
58222×
        vec![]
583
    };
58423×
    Ok(Page(RunPage {
58523×
        shell,
58623×
        repo: RepoShell::new(&app, &row, "flow").with_role(role),
58723×
        run,
58823×
        graph,
58923×
        logs,
59023×
        duration,
59123×
        can_rerun,
59223×
        can_cancel,
59323×
        can_expedite,
59423×
        queue,
59523×
        releases,
59623×
        approvals,
59723×
    }))
59824×
}
599
600
#[derive(Deserialize)]
601
pub struct DecideForm {
602
    #[serde(rename = "_csrf")]
603
    csrf_token: String,
604
    /// `approve` or `reject`.
605
    action: String,
606
}
607
608
/// `POST /{owner}/{name}/flow/{id}/jobs/{job}/decide`: approve or reject
609
/// a job that waits for a person (oxygenflow#57).
6100×
pub async fn decide(
6110×
    State(app): State<Shared>,
6120×
    viewer: Viewer,
6130×
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
6140×
    Form(f): Form<DecideForm>,
6150×
) -> Result<Response, Error> {
6160×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
6170×
    let me = require(&viewer, &shell)?;
6180×
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
6190×
    csrf(&viewer, &f.csrf_token, &shell)?;
6200×
    let run = fdb::run(&app.db, row.id, number)
6210×
        .await
6220×
        .map_err(|e| Error::from_db(shell.clone(), e))?
6230×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
6240×
    let approve = match f.action.as_str() {
6250×
        "approve" => true,
6260×
        "reject" => false,
6270×
        _ => return Err(Error::bad(shell, "approve or reject")),
628
    };
6290×
    match crate::flow::decide(&app, &row, &run, &job, &me.account, approve, None).await {
6300×
        Ok(_) => Ok(Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response()),
6310×
        Err(why) => Err(Error::bad(shell, why)),
632
    }
6330×
}
634
635
#[derive(Deserialize)]
636
pub struct LiveQuery {
637
    /// `job:seq,job:seq`: the last log line each open job already shows.
638
    after: Option<String>,
639
}
640
641
/// `GET /{owner}/{name}/flow/{id}/live`: statuses and new log lines, for the
642
/// run page while it is live.
6431×
pub async fn live(
6441×
    State(app): State<Shared>,
6451×
    viewer: Viewer,
6461×
    Path((owner, name, id)): Path<(String, String, i64)>,
6471×
    Query(q): Query<LiveQuery>,
6481×
) -> Result<Response, Error> {
6491×
    let shell = Shell::new(&app, "", &viewer);
6501×
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
6511×
    let run = fdb::run(&app.db, row.id, id)
6521×
        .await
6531×
        .map_err(|e| Error::from_db(shell.clone(), e))?
6541×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
6551×
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6561×
    let after: std::collections::HashMap<i64, i64> = q
6571×
        .after
6581×
        .unwrap_or_default()
6591×
        .split(',')
6601×
        .filter_map(|p| p.split_once(':'))
6611×
        .filter_map(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))
6621×
        .collect();
6631×
    let mut out = Vec::new();
6643×
    for j in &jobs {
6653×
        let lines = match after.get(&j.id) {
6661×
            Some(seq) => fdb::log(&app.db, j.id, *seq, 1000).await.unwrap_or_default(),
6672×
            None => vec![],
668
        };
6693×
        out.push(serde_json::json!({
6703×
            "id": j.id,
6713×
            "name": j.name,
6723×
            "status": j.status,
6733×
            "glyph": glyph(&j.status),
6743×
            "duration": crate::flow::duration(j),
6753×
            "exit_code": j.exit_code,
6763×
            "lines": lines.iter().map(|l| serde_json::json!({ "seq": l.seq, "t": l.t, "stream": l.stream, "text": l.text })).collect::<Vec<_>>(),
677
        }));
678
    }
6791×
    Ok(Json(serde_json::json!({ "status": run.status, "live": run.live(), "jobs": out })).into_response())
6801×
}
681
682
// ------------------------------------------------------------ state stamps
683
684
/// In rendered Markdown, the image path that is the viewed state's stamp.
685
pub const STAMP_IMAGE: &str = ".flow/stamp";
686
/// In rendered Markdown, the link path to the viewed state's run.
687
pub const RUN_LINK: &str = ".flow/run";
688
689
/// Resolve a ref to its state address, and whether the ref can move.
6908×
async fn state_of(app: &Shared, owner: &str, name: &str, r: &str) -> Result<(String, bool), crate::repos::RepoError> {
6918×
    let repo = crate::repos::RepoName::new(owner, name).ok_or(crate::repos::RepoError::NotFound)?;
6928×
    let spec = r.to_string();
6938×
    app.repos
6948×
        .read(&repo, move |repo, cache| {
6958×
            let t = crate::repos::view::resolve(repo, cache, &spec)?;
6968×
            let moves = matches!(t.kind, crate::repos::view::RefKind::Bookmark | crate::repos::view::RefKind::Tag);
6978×
            Ok((t.permalink().unwrap_or_default(), moves))
6988×
        })
6998×
        .await
7008×
}
701
702
/// `GET /{owner}/{name}/flow/stamp/{ref}` (hub spec §13.2).
7036×
pub async fn stamp(
7046×
    State(app): State<Shared>,
7056×
    viewer: Viewer,
7066×
    Path((owner, name, r)): Path<(String, String, String)>,
7076×
) -> Result<Response, Error> {
7086×
    let shell = Shell::new(&app, "", &viewer);
7096×
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
7106×
    let (heads, moves) = state_of(&app, &owner, &name, &r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
7116×
    if moves && !heads.is_empty() {
7122×
        let mut res = axum::response::Redirect::to(&format!("/{owner}/{name}/flow/stamp/{heads}")).into_response();
7132×
        res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache"));
7142×
        return Ok(res);
7154×
    }
7164×
    let run = fdb::latest_for_heads(&app.db, row.id, &heads).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7174×
    let jobs = match &run {
7183×
        Some(r) => fdb::jobs(&app.db, r.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
7191×
        None => vec![],
720
    };
7214×
    let svg = stamp_svg(&heads, run.as_ref(), &jobs);
7224×
    let cache = if run.as_ref().is_some_and(|r| !r.live()) { "public, max-age=3600" } else { "public, max-age=60" };
7234×
    Ok((
7244×
        [
7254×
            (axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"),
7264×
            (axum::http::header::CACHE_CONTROL, cache),
7274×
            (axum::http::header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'"),
7284×
        ],
7294×
        svg,
7304×
    )
7314×
        .into_response())
7326×
}
733
734
/// `GET /{owner}/{name}/flow/at/{ref}`: the run of the ref's state, or the Flow tab.
7352×
pub async fn at(
7362×
    State(app): State<Shared>,
7372×
    viewer: Viewer,
7382×
    Path((owner, name, r)): Path<(String, String, String)>,
7392×
) -> Result<Response, Error> {
7402×
    let shell = Shell::new(&app, "", &viewer);
7412×
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
7422×
    let (heads, _) = state_of(&app, &owner, &name, &r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
7432×
    let run = fdb::latest_for_heads(&app.db, row.id, &heads).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7442×
    let to = match run {
7451×
        Some(r) => format!("/{owner}/{name}/flow/{}", r.number),
7461×
        None => format!("/{owner}/{name}/flow"),
747
    };
7482×
    Ok(axum::response::Redirect::to(&to).into_response())
7492×
}
750
75112×
fn xml(s: &str) -> String {
75212×
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
75312×
}
754
755
/// The stamp: the mark, one glyph per job, the status, the state and the
756
/// date; the same facts machine-readable in `<metadata>`. Fixed colours,
757
/// because an image does not follow the page's theme.
7584×
pub fn stamp_svg(heads: &str, run: Option<&fdb::Run>, jobs: &[fdb::Job]) -> String {
759
    const CH: f64 = 6.6; // IBM Plex Mono at 11px
7607×
    let colour = |s: &str| match s {
7617×
        "passed" => "#5fe3a1",
7625×
        "failed" | "error" => "#ff5e7a",
7631×
        "running" => "#00e0f0",
7641×
        _ => "#8592ad",
7657×
    };
7664×
    let short: String = heads.chars().take(12).collect();
7674×
    let (status, date) = match run {
7683×
        Some(r) => (r.status.as_str(), crate::render::time::absolute(r.created_ms as u64).chars().take(10).collect::<String>()),
7691×
        None => ("no run", String::new()),
770
    };
7714×
    let mut ordered: Vec<&fdb::Job> = jobs.iter().collect();
7724×
    ordered.sort_by(|a, b| (a.depth, &a.name).cmp(&(b.depth, &b.name)));
7734×
    let label = "OXYGENFLOW";
7744×
    let label_w = 18.0 + label.len() as f64 * 7.4 + 8.0; // Barlow bold 10.5px, spaced
7754×
    let glyphs_w = if ordered.is_empty() { 0.0 } else { ordered.len() as f64 * 10.0 + 6.0 };
7764×
    let tail = if date.is_empty() { format!("{status} · {short}") } else { format!("{status} · {short} · {date}") };
7774×
    let right_w = 8.0 + glyphs_w + tail.chars().count() as f64 * CH + 8.0;
7784×
    let w = (label_w + right_w).ceil();
7794×
    let mut glyphs = String::new();
7804×
    for (i, j) in ordered.iter().enumerate() {
7813×
        let x = label_w + 8.0 + i as f64 * 10.0 + 4.0;
7823×
        glyphs.push_str(&format!(
7833×
            r#"<text x="{x:.1}" y="15" fill="{}" text-anchor="middle">{}</text>"#,
7843×
            colour(&j.status),
7853×
            glyph(&j.status)
7863×
        ));
7873×
    }
7884×
    let meta = serde_json::json!({
78910×
        "heads": heads.split('+').filter(|h| !h.is_empty()).collect::<Vec<_>>(),
7904×
        "run": run.map(|r| r.number),
7914×
        "status": status,
7924×
        "jobs": ordered.iter().map(|j| serde_json::json!({ "name": j.name, "status": j.status, "receipt": j.receipt_hash })).collect::<Vec<_>>(),
793
    });
7944×
    let title = match run {
7953×
        Some(r) => format!("OxygenFlow run #{} {status} on state {short}", r.number),
7961×
        None => format!("OxygenFlow: no run for state {short}"),
797
    };
7984×
    format!(
799
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="22" viewBox="0 0 {w} 22" role="img" aria-label="{t}"><title>{t}</title><metadata>{m}</metadata><rect width="{w}" height="22" rx="4" fill="#0e1526"/><rect width="{lw:.1}" height="22" rx="4" fill="#0b3f9e"/><rect x="{lx:.1}" width="4" height="22" fill="#0b3f9e"/><circle cx="9" cy="11" r="3.2" fill="none" stroke="#00f0ff" stroke-width="1.6"/><text x="16" y="15" fill="#ffffff" font-family="Barlow, Verdana, sans-serif" font-size="10.5" font-weight="700" letter-spacing=".6">{label}</text><g font-family="IBM Plex Mono, ui-monospace, monospace" font-size="11">{glyphs}<text x="{tx:.1}" y="15" fill="{sc}">{tail}</text></g><rect x=".5" y=".5" width="{wi}" height="21" rx="3.5" fill="none" stroke="#1f2b47"/></svg>"##,
8004×
        t = xml(&title),
8014×
        m = xml(&meta.to_string()),
802
        lw = label_w,
8034×
        lx = label_w - 4.0,
8044×
        tx = label_w + 8.0 + glyphs_w,
8054×
        sc = colour(status),
8064×
        tail = xml(&tail),
8074×
        wi = w - 1.0,
808
    )
8094×
}
810
811
/// A receipt kept in a repository (Oxygen spec §14.6), as pages show it.
812
#[derive(Clone, Debug)]
813
pub struct ReceiptRow {
814
    pub attest: String,
815
    pub job: String,
816
    pub exit_code: u32,
817
    pub duration: String,
818
    pub runner: String,
819
    pub run: String,
820
    /// Why it does not verify, or that its runner's key was revoked.
821
    pub problem: String,
822
    pub made_ms: i64,
823
}
824
825
impl ReceiptRow {
8263×
    pub fn passed(&self) -> bool {
8273×
        self.exit_code == 0
8283×
    }
829
}
830
831
/// The receipts kept for the state of `heads`.
83213×
pub async fn receipts_of(app: &Shared, name: &crate::repos::RepoName, heads: Vec<oxy_core::hash::ChangeHash>) -> Vec<ReceiptRow> {
83313×
    let base = app.config.base_url();
83413×
    let found = app.repos.read(name, move |repo, _| Ok(repo.receipts(&heads)?)).await.unwrap_or_default();
83513×
    found
83613×
        .into_iter()
83713×
        .map(|i| {
8381×
            let r = &i.signed.receipt;
8391×
            let run = i.signed.run().to_string();
840
            ReceiptRow {
8411×
                attest: i.attest.to_hex(),
8421×
                job: r.job_name.clone(),
8431×
                exit_code: r.exit_code,
8441×
                duration: crate::flow::human_ms(r.duration_ms as i64),
8451×
                runner: i.signed.runner.name.clone(),
846
                // Only links to this hub are followed from the page.
8471×
                run: if run.starts_with(base) { run } else { String::new() },
8481×
                problem: match (i.problem, i.revoked) {
8490×
                    (Some(p), _) => p,
8500×
                    (None, true) => "the runner's key was revoked".into(),
8511×
                    (None, false) => String::new(),
852
                },
8531×
                made_ms: i.signed.attest.hlc.wall_ms as i64,
854
            }
8551×
        })
85613×
        .collect()
85713×
}
858
859
/// `GET /{owner}/{name}/receipts/{attest}`: a signed receipt, for
860
/// `oxy receipt verify` or `oxy receipt import`.
8612×
pub async fn receipt_file(
8622×
    State(app): State<Shared>,
8632×
    viewer: Viewer,
8642×
    Path((owner, name, attest)): Path<(String, String, String)>,
8652×
) -> Result<Response, Error> {
8662×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/receipts/{attest}"), &viewer);
8672×
    let (repo, _row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
8682×
    let Some(r) = oxy_core::hash::ObjectRef::from_hex(attest.trim_end_matches(".receipt")) else {
8690×
        return Err(Error::not_found(shell));
870
    };
8712×
    let (job, bytes) = app
8722×
        .repos
8732×
        .read(&repo, move |repo, _| {
8742×
            let i = repo.receipt(&r)?;
8751×
            Ok((i.signed.receipt.job_name.clone(), i.signed.encode()))
8762×
        })
8772×
        .await
8782×
        .map_err(|_| Error::not_found(shell.clone()))?;
8795×
    let file: String = job.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect();
8801×
    Ok((
8811×
        [
8821×
            (axum::http::header::CONTENT_TYPE, "application/oxy-receipt".to_string()),
8831×
            (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{file}-{}.receipt\"", r.short(12))),
8841×
        ],
8851×
        bytes,
8861×
    )
8871×
        .into_response())
8882×
}
889
890
/// `GET /{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}`: an artifact
891
/// of a run's job, read from the repository.
8923×
pub async fn artifact_file(
8933×
    State(app): State<Shared>,
8943×
    viewer: Viewer,
8953×
    Path((owner, name, id, job, artifact)): Path<(String, String, i64, String, String)>,
8963×
) -> Result<Response, Error> {
8973×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}"), &viewer);
8983×
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
8993×
    let run = fdb::run(&app.db, row.id, id)
9003×
        .await
9013×
        .map_err(|e| Error::from_db(shell.clone(), e))?
9023×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
9033×
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9043×
    let Some(j) = jobs.iter().find(|j| j.name == job) else { return Err(Error::not_found(shell)) };
9053×
    let arts = fdb::artifacts(&app.db, j.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9063×
    let Some(a) = arts.into_iter().find(|a| a.name == artifact) else { return Err(Error::not_found(shell)) };
9072×
    let Some(blob) = oxy_core::hash::ObjectRef::from_hex(&a.blob) else { return Err(Error::not_found(shell)) };
9082×
    let bytes = app.repos.read(&repo, move |r, _| Ok(r.read_blob(&blob)?)).await.map_err(|_| Error::not_found(shell.clone()))?;
9092×
    let file = a.path.rsplit('/').next().unwrap_or(&a.name).to_string();
9102×
    Ok((
9112×
        [
9122×
            (axum::http::header::CONTENT_TYPE, "application/octet-stream".to_string()),
9132×
            (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", file.replace('"', ""))),
9142×
        ],
9152×
        bytes,
9162×
    )
9172×
        .into_response())
9183×
}
919
920
/// `GET …/artifacts/{job}/{artifact}/provenance` (oxygenhub#239): the
921
/// artifact's SLSA provenance in a DSSE envelope signed by the hub, for
922
/// `cosign verify-blob-attestation --key oxyhub-signing.pem --type
923
/// slsaprovenance1`. Only for a job whose signed receipt the hub kept.
9242×
pub async fn provenance(
9252×
    State(app): State<Shared>,
9262×
    viewer: Viewer,
9272×
    Path((owner, name, id, job, artifact)): Path<(String, String, i64, String, String)>,
9282×
) -> Result<Response, Error> {
9292×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/provenance"), &viewer);
9302×
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
9312×
    let run = fdb::run(&app.db, row.id, id)
9322×
        .await
9332×
        .map_err(|e| Error::from_db(shell.clone(), e))?
9342×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
9352×
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9362×
    let Some(j) = jobs.iter().find(|j| j.name == job) else { return Err(Error::not_found(shell)) };
9372×
    if j.receipt_attest.is_empty() {
9381×
        return Err(Error::bad(shell, "This job has no signed receipt the hub kept, so the hub states no provenance for it.".to_string()));
9391×
    }
9401×
    let arts = fdb::artifacts(&app.db, j.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9411×
    let Some(a) = arts.into_iter().find(|a| a.name == artifact) else { return Err(Error::not_found(shell)) };
9421×
    let Some(blob) = oxy_core::hash::ObjectRef::from_hex(&a.blob) else { return Err(Error::not_found(shell)) };
9431×
    let bytes = app.repos.read(&repo, move |r, _| Ok(r.read_blob(&blob)?)).await.map_err(|_| Error::not_found(shell.clone()))?;
9441×
    let file = a.path.rsplit('/').next().unwrap_or(&a.name).to_string();
9451×
    let base = app.config.base_url();
9461×
    let subject = crate::provenance::Subject {
9471×
        name: &file,
9481×
        bytes: &bytes,
9491×
        repo: &format!("{owner}/{name}"),
9501×
        run_url: &format!("{base}/{owner}/{name}/flow/{id}"),
9511×
        bookmark: &run.bookmark,
9521×
        state: &run.state,
9531×
        heads: &run.heads,
9541×
        pipeline_hash: &run.pipeline_hash,
9551×
        job: &j.name,
9561×
        runner: &j.runner,
9571×
        receipt_signer: &j.receipt_signer,
9581×
        receipt_attest: &j.receipt_attest,
9591×
        started_ms: j.started_ms,
9601×
        finished_ms: j.finished_ms,
9611×
    };
9621×
    let statement = crate::provenance::statement(base, &subject);
9631×
    let envelope = crate::provenance::envelope(&app, &statement).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
9641×
    Ok((
9651×
        [
9661×
            (axum::http::header::CONTENT_TYPE, "application/json".to_string()),
9671×
            (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}.intoto.json\"", file.replace('"', ""))),
9681×
        ],
9691×
        serde_json::to_string(&envelope).unwrap_or_default(),
9701×
    )
9711×
        .into_response())
9722×
}
973
974
/// `GET /.well-known/oxyhub-signing.pem`: the hub's Ed25519 public key, as
975
/// cosign and other verifiers take it (oxygenhub#239).
9761×
pub async fn signing_key(State(app): State<Shared>) -> Response {
9771×
    ([(axum::http::header::CONTENT_TYPE, "application/x-pem-file")], crate::provenance::public_pem(&app)).into_response()
9781×
}
979
980
#[derive(Deserialize)]
981
pub struct AttachForm {
982
    _csrf: String,
983
    release: i64,
984
}
985
986
/// `POST /{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/attach`: add
987
/// an artifact to a release as an asset, named after its path; the release
988
/// page then shows the run and receipt that built it.
9891×
pub async fn attach(
9901×
    State(app): State<Shared>,
9911×
    viewer: Viewer,
9921×
    Path((owner, name, id, job, artifact)): Path<(String, String, i64, String, String)>,
9931×
    Form(f): Form<AttachForm>,
9941×
) -> Result<Response, Error> {
9951×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}"), &viewer);
9961×
    require(&viewer, &shell)?;
9971×
    csrf(&viewer, &f._csrf, &shell)?;
9981×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
9991×
    let run = fdb::run(&app.db, row.id, id)
10001×
        .await
10011×
        .map_err(|e| Error::from_db(shell.clone(), e))?
10021×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
10031×
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10041×
    let Some(j) = jobs.iter().find(|j| j.name == job) else { return Err(Error::not_found(shell)) };
1005
    // A failing job's `when "always"` artifacts are not in its receipt, so
1006
    // they cannot stand for a release (oxygenflow#37).
10071×
    if j.status != "passed" {
10080×
        return Err(Error::bad(shell, "only a passing job's artifacts can be attached to a release"));
10091×
    }
10101×
    let arts = fdb::artifacts(&app.db, j.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10111×
    let Some(a) = arts.into_iter().find(|a| a.name == artifact) else { return Err(Error::not_found(shell)) };
10121×
    let releases = db::releases::list(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10131×
    let Some(rel) = releases.into_iter().find(|r| r.id == f.release) else { return Err(Error::not_found(shell)) };
10141×
    let Some(blob) = oxy_core::hash::ObjectRef::from_hex(&a.blob) else { return Err(Error::not_found(shell)) };
10151×
    let bytes = app.repos.read(&repo, move |r, _| Ok(r.read_blob(&blob)?)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
10161×
    let file = a.path.rsplit('/').next().unwrap_or(&a.name).to_string();
10171×
    super::releases::store_asset(&app, rel.id, &file, "application/octet-stream", &bytes)
10181×
        .await
10191×
        .map_err(|m| Error::bad(shell.clone(), m))?;
10201×
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", rel.tag)).into_response())
10211×
}
1022
1023
/// `/{owner}/{name}/flow/badge.svg`: the latest run on the default
1024
/// bookmark as a small SVG for a README, for whoever may read the
1025
/// repository. `passed` green, `failed` or `error` red, a run still going
1026
/// blue, none grey.
10271×
pub async fn badge(
10281×
    State(app): State<Shared>,
10291×
    viewer: Viewer,
10301×
    Path((owner, name)): Path<(String, String)>,
10311×
) -> Result<axum::response::Response, Error> {
1032
    use axum::response::IntoResponse;
10331×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer);
10341×
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
10351×
    let latest = fdb::latest_on(&app.db, row.id, &row.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10361×
    let (text, colour) = match latest.as_ref().map(|r| r.status.as_str()) {
10370×
        Some("passed") => ("passing", "#2da44e"),
10380×
        Some("failed") | Some("error") => ("failing", "#cf222e"),
10390×
        Some("cancelled") => ("cancelled", "#6e7781"),
10400×
        Some(_) => ("running", "#0969da"),
10411×
        None => ("no runs", "#6e7781"),
1042
    };
10431×
    let label = "OxygenFlow";
10441×
    let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10);
10451×
    let svg = format!(
1046
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" aria-label="{label}: {text}"><title>{label}: {text}</title><rect width="{lw}" height="20" rx="3" fill="#555"/><rect x="{lw}" width="{tw}" height="20" rx="3" fill="{colour}"/><rect x="{lw}" width="4" height="20" fill="{colour}"/><g fill="#fff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle"><text x="{lx}" y="14">{label}</text><text x="{tx}" y="14">{text}</text></g></svg>"##,
10471×
        w = lw + tw,
10481×
        lx = lw / 2,
10491×
        tx = lw + tw / 2,
1050
    );
10511×
    Ok((
10521×
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")],
10531×
        svg,
10541×
    )
10551×
        .into_response())
10561×
}
1057
1058
#[cfg(test)]
1059
mod section_tests {
1060
    use super::*;
1061
10629×
    fn line(seq: i64, t: i64, text: &str) -> fdb::LogLine {
10639×
        fdb::LogLine { seq, t, stream: "out".into(), text: text.into() }
10649×
    }
1065
1066
    #[test]
10671×
    fn marked_lines_fold_into_sections() {
10681×
        let lines = vec![
10691×
            line(1, 0, "booting"),
10701×
            line(2, 100, "::group::cargo build"),
10711×
            line(3, 200, "Compiling a"),
10721×
            line(4, 900, "Compiling b"),
10731×
            line(5, 4_300, "::endgroup::"),
10741×
            line(6, 4_400, "::group::cargo test"),
10751×
            line(7, 4_500, "test x ... ok"),
10761×
            line(8, 5_000, "::group::lint"),
10771×
            line(9, 5_100, "clean"),
1078
        ];
10791×
        let segs = sections(&lines);
10801×
        assert_eq!(segs.len(), 4);
10811×
        assert!(matches!(&segs[0], Segment::Line(l) if l.text == "booting"));
10821×
        let Segment::Group(build) = &segs[1] else { panic!("a section") };
10831×
        assert_eq!((build.title.as_str(), build.from_t, build.to_t, build.lines.len()), ("cargo build", 100, Some(4_300), 2));
10841×
        let Segment::Group(test) = &segs[2] else { panic!("a section") };
10851×
        assert_eq!(test.to_t, Some(5_000), "a new section ends the one before");
10861×
        let Segment::Group(lint) = &segs[3] else { panic!("a section") };
10871×
        assert_eq!((lint.to_t, lint.lines.len()), (None, 1), "still going");
10881×
        assert_eq!(secs(4_200), "4.2s");
10891×
    }
1090
}