OXYGENHUB
# 0ee5c3fb32eb oxygenhub / crates / hub / src / web / flow.rs
42.5 KiB · Rust · last changed Approvals through an agent token say so (closes #229) 18 days ago source blame history raw
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.
47
pub fn why_not_run(enabled: bool, signed_in: bool, role: Role) -> Option<String> {
48
    if !enabled {
49
        Some("Flow is off for this repository; a site administrator can turn it on in Settings → Flow.".into())
50
    } else if !signed_in {
51
        Some("Sign in with write access to run the pipeline.".into())
52
    } else if role < Role::Write {
53
        Some("Running the pipeline needs write access to this repository.".into())
54
    } else {
55
        None
56
    }
57
}
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).
74
pub async fn deployments(
75
    State(app): State<Shared>,
76
    viewer: Viewer,
77
    Path((owner, name)): Path<(String, String)>,
78
) -> Result<Page<DeploymentsPage>, Error> {
79
    let shell = Shell::new(&app, &format!("/{owner}/{name}/deployments"), &viewer);
80
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
81
    let history = fdb::deployments(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
82
    let mut current: Vec<fdb::Deployment> = Vec::new();
83
    for d in &history {
84
        if !current.iter().any(|c| c.environment == d.environment) {
85
            current.push(d.clone());
86
        }
87
    }
88
    current.sort_by(|a, b| a.environment.cmp(&b.environment));
89
    Ok(Page(DeploymentsPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), current, history }))
90
}
91
92
pub async fn runs(
93
    State(app): State<Shared>,
94
    viewer: Viewer,
95
    Path((owner, name)): Path<(String, String)>,
96
    Query(q): Query<RunsQuery>,
97
) -> Result<Page<RunsPage>, Error> {
98
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer);
99
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
100
    let list = fdb::runs(&app.db, row.id, PER_PAGE + 1, q.before).await.map_err(|e| Error::from_db(shell.clone(), e))?;
101
    let older = (list.len() as i64 > PER_PAGE).then(|| list[PER_PAGE as usize - 1].id);
102
    let queue = if list.iter().any(|r| r.live()) { fdb::queue(&app.db).await.unwrap_or_default() } else { vec![] };
103
    let mut runs = Vec::new();
104
    for r in list.into_iter().take(PER_PAGE as usize) {
105
        let place = if r.live() { crate::flow::queue_info(&queue, row.id, r.id).place() } else { None };
106
        let jobs = fdb::jobs(&app.db, r.id).await.unwrap_or_default();
107
        let duration = match r.finished_ms {
108
            Some(f) => crate::flow::human_ms(f - r.created_ms),
109
            None if r.live() => crate::flow::human_ms(db::now_ms() - r.created_ms),
110
            None => "–".into(),
111
        };
112
        runs.push(RunRow { jobs: jobs.into_iter().map(|j| (j.name, j.status)).collect(), duration, r, place });
113
    }
114
    let enabled = crate::flow::enabled(&app, &row);
115
    let why_not = why_not_run(enabled, viewer.account().is_some(), role);
116
    let can_run = why_not.is_none();
117
    let bookmarks = if can_run { bookmark_names(&app, &row).await } else { vec![] };
118
    Ok(Page(RunsPage {
119
        shell,
120
        repo: RepoShell::new(&app, &row, "flow").with_role(role),
121
        runs,
122
        enabled,
123
        older,
124
        can_run,
125
        bookmarks,
126
        error: q.error,
127
        why_not,
128
    }))
129
}
130
131
/// The repository's bookmarks, its default first.
132
async fn bookmark_names(app: &Shared, row: &db::repos::RepoRow) -> Vec<String> {
133
    let Some(repo) = crate::repos::RepoName::new(&row.owner, &row.name) else { return vec![] };
134
    let mut names: Vec<String> = app.repos.read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect())).await.unwrap_or_default();
135
    names.sort_by_key(|n| (n != &row.default_bookmark, n.clone()));
136
    names
137
}
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.
152
pub async fn start(
153
    State(app): State<Shared>,
154
    viewer: Viewer,
155
    Path((owner, name)): Path<(String, String)>,
156
    Form(f): Form<StartForm>,
157
) -> Result<Response, Error> {
158
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer);
159
    let me = require(&viewer, &shell)?;
160
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
161
    csrf(&viewer, &f.csrf_token, &shell)?;
162
    let (started, what) = if f.change.trim().is_empty() {
163
        (crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username, &[]).await, f.bookmark.trim().to_string())
164
    } else {
165
        (crate::flow::run_change(&app, &row, &f.change, &me.account.username, &[]).await, format!("change {}", f.change.trim()))
166
    };
167
    Ok(match started {
168
        Ok(n) => {
169
            db::accounts::audit(&app.db, Some(&me.account), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {what}")).await.ok();
170
            Redirect::to(&format!("/{owner}/{name}/flow/{n}")).into_response()
171
        }
172
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
173
    })
174
}
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`.
183
pub async fn rerun(
184
    State(app): State<Shared>,
185
    viewer: Viewer,
186
    Path((owner, name, number)): Path<(String, String, i64)>,
187
    Form(f): Form<RerunForm>,
188
) -> Result<Response, Error> {
189
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
190
    let me = require(&viewer, &shell)?;
191
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
192
    csrf(&viewer, &f.csrf_token, &shell)?;
193
    let run = fdb::run(&app.db, row.id, number)
194
        .await
195
        .map_err(|e| Error::from_db(shell.clone(), e))?
196
        .ok_or_else(|| Error::not_found(shell.clone()))?;
197
    Ok(match crate::flow::rerun(&app, &row, &run, &me.account.username).await {
198
        Ok(n) => {
199
            db::accounts::audit(&app.db, Some(&me.account), "flow.rerun", &format!("{owner}/{name}"), &format!("#{n} of #{number}"))
200
                .await
201
                .ok();
202
            Redirect::to(&format!("/{owner}/{name}/flow/{n}")).into_response()
203
        }
204
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
205
    })
206
}
207
208
/// `POST /{owner}/{name}/flow/{number}/jobs/{job}/rerun` (oxygenflow#31).
209
pub async fn rerun_job(
210
    State(app): State<Shared>,
211
    viewer: Viewer,
212
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
213
    Form(f): Form<RerunForm>,
214
) -> Result<Response, Error> {
215
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
216
    let me = require(&viewer, &shell)?;
217
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
218
    csrf(&viewer, &f.csrf_token, &shell)?;
219
    let run = fdb::run(&app.db, row.id, number)
220
        .await
221
        .map_err(|e| Error::from_db(shell.clone(), e))?
222
        .ok_or_else(|| Error::not_found(shell.clone()))?;
223
    Ok(match crate::flow::rerun_job(&app, &row, &run, &job, &me.account.username).await {
224
        Ok(_) => {
225
            db::accounts::audit(&app.db, Some(&me.account), "flow.rerun", &format!("{owner}/{name}"), &format!("{job} of #{number}"))
226
                .await
227
                .ok();
228
            Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response()
229
        }
230
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
231
    })
232
}
233
234
/// `POST /{owner}/{name}/flow/{number}/cancel` (oxygenflow#32).
235
pub async fn cancel(
236
    State(app): State<Shared>,
237
    viewer: Viewer,
238
    Path((owner, name, number)): Path<(String, String, i64)>,
239
    Form(f): Form<RerunForm>,
240
) -> Result<Response, Error> {
241
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
242
    let me = require(&viewer, &shell)?;
243
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
244
    csrf(&viewer, &f.csrf_token, &shell)?;
245
    let run = fdb::run(&app.db, row.id, number)
246
        .await
247
        .map_err(|e| Error::from_db(shell.clone(), e))?
248
        .ok_or_else(|| Error::not_found(shell.clone()))?;
249
    Ok(match crate::flow::cancel(&app, &row, &run, &me.account.username).await {
250
        Ok(()) => {
251
            db::accounts::audit(&app.db, Some(&me.account), "flow.cancel", &format!("{owner}/{name}"), &format!("#{number}")).await.ok();
252
            Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response()
253
        }
254
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
255
    })
256
}
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.
269
pub async fn expedite(
270
    State(app): State<Shared>,
271
    viewer: Viewer,
272
    Path((owner, name, number)): Path<(String, String, i64)>,
273
    Form(f): Form<ExpediteForm>,
274
) -> Result<Response, Error> {
275
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
276
    let me = require(&viewer, &shell)?;
277
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
278
    csrf(&viewer, &f.csrf_token, &shell)?;
279
    let run = fdb::run(&app.db, row.id, number)
280
        .await
281
        .map_err(|e| Error::from_db(shell.clone(), e))?
282
        .ok_or_else(|| Error::not_found(shell.clone()))?;
283
    let reason: String = f.reason.chars().filter(|c| !c.is_control()).take(200).collect();
284
    let reason = reason.trim();
285
    if reason.is_empty() {
286
        return Ok(Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding("Say why the run is urgent."))).into_response());
287
    }
288
    let note = format!("expedited by {}: {reason}", me.account.username);
289
    if fdb::expedite(&app.db, run.id, &note).await.map_err(|e| Error::from_db(shell.clone(), e))? {
290
        db::accounts::audit(&app.db, Some(&me.account), "flow.expedite", &format!("{owner}/{name}"), &format!("#{number}: {reason}"))
291
            .await
292
            .ok();
293
    }
294
    Ok(Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response())
295
}
296
297
/// Percent-encode a message for a query string.
298
fn urlencoding(s: &str) -> String {
299
    s.bytes()
300
        .map(|b| match b {
301
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(),
302
            _ => format!("%{b:02X}"),
303
        })
304
        .collect()
305
}
306
307
/// A job placed in the graph.
308
pub struct Node {
309
    pub j: fdb::Job,
310
    pub x: i64,
311
    pub y: i64,
312
    pub duration: String,
313
}
314
315
pub struct Edge {
316
    pub d: String,
317
    pub status: String,
318
}
319
320
pub struct Graph {
321
    pub nodes: Vec<Node>,
322
    pub edges: Vec<Edge>,
323
    pub width: i64,
324
    pub height: i64,
325
}
326
327
pub const NODE_W: i64 = 200;
328
pub const NODE_H: i64 = 48;
329
const COL_GAP: i64 = 72;
330
const ROW_GAP: i64 = 18;
331
const PAD: i64 = 12;
332
333
/// Columns by dependency depth, jobs in each column in name order.
334
pub fn layout(jobs: Vec<fdb::Job>) -> Graph {
335
    let mut by_depth: std::collections::BTreeMap<i64, Vec<fdb::Job>> = Default::default();
336
    for j in jobs {
337
        by_depth.entry(j.depth).or_default().push(j);
338
    }
339
    let rows = by_depth.values().map(Vec::len).max().unwrap_or(0) as i64;
340
    let height = PAD * 2 + rows * NODE_H + (rows - 1).max(0) * ROW_GAP;
341
    let mut nodes = Vec::new();
342
    for (col, (_, mut js)) in by_depth.into_iter().enumerate() {
343
        js.sort_by(|a, b| a.name.cmp(&b.name));
344
        let n = js.len() as i64;
345
        let column_h = n * NODE_H + (n - 1).max(0) * ROW_GAP;
346
        let top = (height - column_h) / 2;
347
        for (i, j) in js.into_iter().enumerate() {
348
            let duration = crate::flow::duration(&j);
349
            nodes.push(Node { x: PAD + col as i64 * (NODE_W + COL_GAP), y: top + i as i64 * (NODE_H + ROW_GAP), duration, j });
350
        }
351
    }
352
    let mut edges = Vec::new();
353
    for n in &nodes {
354
        for d in n.j.depends_on() {
355
            if let Some(from) = nodes.iter().find(|m| m.j.name == d) {
356
                let (x1, y1) = (from.x + NODE_W, from.y + NODE_H / 2);
357
                let (x2, y2) = (n.x, n.y + NODE_H / 2);
358
                let mid = (x1 + x2) / 2;
359
                edges.push(Edge { d: format!("M{x1},{y1} C{mid},{y1} {mid},{y2} {x2},{y2}"), status: from.j.status.clone() });
360
            }
361
        }
362
    }
363
    let cols = nodes.iter().map(|n| n.x).max().map(|x| x + NODE_W + PAD).unwrap_or(PAD * 2);
364
    Graph { nodes, edges, width: cols, height: height.max(NODE_H + PAD * 2) }
365
}
366
367
pub fn glyph(status: &str) -> &'static str {
368
    match status {
369
        "passed" => "✓",
370
        "failed" => "✕",
371
        "running" => "●",
372
        "skipped" => "⤼",
373
        "cancelled" => "⊘",
374
        "error" => "!",
375
        _ => "○",
376
    }
377
}
378
379
/// Failed tests a run page lists per job; the rest are counted.
380
const SHOWN_FAILURES: i64 = 50;
381
382
pub struct JobLog {
383
    pub j: fdb::Job,
384
    pub artifacts: Vec<fdb::Artifact>,
385
    pub lines: Vec<fdb::LogLine>,
386
    pub duration: String,
387
    /// The last line shown; the live view asks for what follows.
388
    pub last_seq: i64,
389
    /// The job's coverage report, when it uploaded one (#113): its id and percentage.
390
    pub coverage: Option<(i64, String)>,
391
    /// The job's test report, when it uploaded one (#114), and its first
392
    /// failures.
393
    pub tests: Option<crate::junit::Report>,
394
    pub failures: Vec<crate::junit::Case>,
395
}
396
397
#[derive(Template)]
398
#[template(path = "flow_run.html")]
399
pub struct RunPage {
400
    shell: Shell,
401
    repo: RepoShell,
402
    run: fdb::Run,
403
    graph: Graph,
404
    logs: Vec<JobLog>,
405
    duration: String,
406
    /// Whether the viewer may re-run it.
407
    can_rerun: bool,
408
    /// Whether the viewer may cancel it.
409
    can_cancel: bool,
410
    /// May raise the run to urgent (oxygenhub#244): maintainers, while it runs.
411
    can_expedite: bool,
412
    /// Where a live run stands among the runners' work.
413
    queue: Option<crate::flow::QueueInfo>,
414
    /// Releases a writer may attach this run's artifacts to.
415
    releases: Vec<db::releases::Release>,
416
    /// Jobs that wait, or waited, for a person (oxygenflow#57).
417
    approvals: Vec<ApprovalView>,
418
}
419
420
/// A job's approval as the run page shows it.
421
pub struct ApprovalView {
422
    pub job: String,
423
    pub by: String,
424
    pub others_only: bool,
425
    /// `waiting`, `approved`, `rejected` or `expired`.
426
    pub state: &'static str,
427
    pub who: String,
428
    pub when_ms: i64,
429
    /// Whether the viewer may approve or reject it now, else why not.
430
    pub may: Result<(), String>,
431
    /// It takes an approval signed with the approver's key (#235): the
432
    /// command that signs it, and the approval's hash once sealed.
433
    pub sealed: bool,
434
    pub command: String,
435
    pub seal: String,
436
}
437
438
impl ApprovalView {
439
    pub fn may_decide(&self) -> bool {
440
        self.may.is_ok()
441
    }
442
443
    pub fn why_not(&self) -> String {
444
        self.may.clone().err().unwrap_or_default()
445
    }
446
}
447
448
/// Lines of a job's log a page shows before "the rest is loaded as it comes".
449
const PAGE_LINES: i64 = 5000;
450
451
pub async fn run(
452
    State(app): State<Shared>,
453
    viewer: Viewer,
454
    Path((owner, name, id)): Path<(String, String, i64)>,
455
) -> Result<Page<RunPage>, Error> {
456
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}"), &viewer);
457
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
458
    let run = fdb::run(&app.db, row.id, id)
459
        .await
460
        .map_err(|e| Error::from_db(shell.clone(), e))?
461
        .ok_or_else(|| Error::not_found(shell.clone()))?;
462
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
463
    let reports = crate::coverage::for_run(&app.db, run.id).await.unwrap_or_default();
464
    let test_reports = crate::junit::for_run(&app.db, run.id).await.unwrap_or_default();
465
    let mut logs = Vec::new();
466
    for j in &jobs {
467
        let lines = fdb::log(&app.db, j.id, 0, PAGE_LINES).await.unwrap_or_default();
468
        let last_seq = lines.last().map(|l| l.seq).unwrap_or(0);
469
        let artifacts = fdb::artifacts(&app.db, j.id).await.unwrap_or_default();
470
        let coverage = reports
471
            .iter()
472
            .find(|r| r.job_id == Some(j.id))
473
            .map(|r| (r.id, r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into())));
474
        let tests = test_reports.iter().find(|r| r.job_id == j.id).cloned();
475
        let failures = match &tests {
476
            Some(r) if r.broken() > 0 => crate::junit::failures(&app.db, r.id, SHOWN_FAILURES).await.unwrap_or_default(),
477
            _ => vec![],
478
        };
479
        logs.push(JobLog { duration: crate::flow::duration(j), j: j.clone(), artifacts, lines, last_seq, coverage, tests, failures });
480
    }
481
    let duration = match run.finished_ms {
482
        Some(f) => crate::flow::human_ms(f - run.created_ms),
483
        None => crate::flow::human_ms(db::now_ms() - run.created_ms),
484
    };
485
    let mut approvals = Vec::new();
486
    for j in jobs.iter().filter(|j| !j.approval.is_empty()) {
487
        let Some(a) = serde_json::from_str::<flow_core::Job>(&j.spec).ok().and_then(|s| s.approval) else { continue };
488
        let state = match (j.approval.as_str(), j.status.as_str()) {
489
            ("waiting", "queued") => "waiting",
490
            ("approved", _) => "approved",
491
            (_, "cancelled") if !j.approved_by.is_empty() => "rejected",
492
            (_, "cancelled") if j.error.starts_with("no approval within") => "expired",
493
            _ => continue,
494
        };
495
        let may = match (state, viewer.account()) {
496
            ("waiting", Some(me)) => crate::flow::may_decide(&app, &row, &run, j, me).await.map(|_| ()),
497
            ("waiting", None) => Err("sign in to approve".into()),
498
            _ => Err(String::new()),
499
        };
500
        approvals.push(ApprovalView {
501
            sealed: a.sealed,
502
            command: format!("oxy seal --flow {}/{owner}/{name}/flow/{} --job {}", app.config.base_url(), run.number, j.name),
503
            seal: j.approval_seal.clone(),
504
            job: j.name.clone(),
505
            by: a.by,
506
            others_only: a.others_only,
507
            state,
508
            who: if j.approved_via.is_empty() { j.approved_by.clone() } else { format!("{} via {}", j.approved_by, j.approved_via) },
509
            when_ms: j.approved_ms.unwrap_or(0),
510
            may,
511
        });
512
    }
513
    let graph = layout(jobs);
514
    let can_write = viewer.account().is_some() && role >= Role::Write;
515
    let can_rerun = !run.live() && can_write && crate::flow::enabled(&app, &row);
516
    let can_cancel = run.live() && can_write;
517
    let can_expedite = run.live() && run.priority.is_empty() && viewer.account().is_some() && role >= Role::Maintain;
518
    let queue = if run.live() {
519
        let q = fdb::queue(&app.db).await.unwrap_or_default();
520
        Some(crate::flow::queue_info(&q, row.id, run.id)).filter(|i| i.position.is_some())
521
    } else {
522
        None
523
    };
524
    let releases = if viewer.account().is_some() && role >= Role::Write && logs.iter().any(|l| !l.artifacts.is_empty()) {
525
        db::releases::list(&app.db, row.id).await.unwrap_or_default()
526
    } else {
527
        vec![]
528
    };
529
    Ok(Page(RunPage {
530
        shell,
531
        repo: RepoShell::new(&app, &row, "flow").with_role(role),
532
        run,
533
        graph,
534
        logs,
535
        duration,
536
        can_rerun,
537
        can_cancel,
538
        can_expedite,
539
        queue,
540
        releases,
541
        approvals,
542
    }))
543
}
544
545
#[derive(Deserialize)]
546
pub struct DecideForm {
547
    #[serde(rename = "_csrf")]
548
    csrf_token: String,
549
    /// `approve` or `reject`.
550
    action: String,
551
}
552
553
/// `POST /{owner}/{name}/flow/{id}/jobs/{job}/decide`: approve or reject
554
/// a job that waits for a person (oxygenflow#57).
555
pub async fn decide(
556
    State(app): State<Shared>,
557
    viewer: Viewer,
558
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
559
    Form(f): Form<DecideForm>,
560
) -> Result<Response, Error> {
561
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
562
    let me = require(&viewer, &shell)?;
563
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
564
    csrf(&viewer, &f.csrf_token, &shell)?;
565
    let run = fdb::run(&app.db, row.id, number)
566
        .await
567
        .map_err(|e| Error::from_db(shell.clone(), e))?
568
        .ok_or_else(|| Error::not_found(shell.clone()))?;
569
    let approve = match f.action.as_str() {
570
        "approve" => true,
571
        "reject" => false,
572
        _ => return Err(Error::bad(shell, "approve or reject")),
573
    };
574
    match crate::flow::decide(&app, &row, &run, &job, &me.account, approve, None).await {
575
        Ok(_) => Ok(Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response()),
576
        Err(why) => Err(Error::bad(shell, why)),
577
    }
578
}
579
580
#[derive(Deserialize)]
581
pub struct LiveQuery {
582
    /// `job:seq,job:seq`: the last log line each open job already shows.
583
    after: Option<String>,
584
}
585
586
/// `GET /{owner}/{name}/flow/{id}/live`: statuses and new log lines, for the
587
/// run page while it is live.
588
pub async fn live(
589
    State(app): State<Shared>,
590
    viewer: Viewer,
591
    Path((owner, name, id)): Path<(String, String, i64)>,
592
    Query(q): Query<LiveQuery>,
593
) -> Result<Response, Error> {
594
    let shell = Shell::new(&app, "", &viewer);
595
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
596
    let run = fdb::run(&app.db, row.id, id)
597
        .await
598
        .map_err(|e| Error::from_db(shell.clone(), e))?
599
        .ok_or_else(|| Error::not_found(shell.clone()))?;
600
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
601
    let after: std::collections::HashMap<i64, i64> = q
602
        .after
603
        .unwrap_or_default()
604
        .split(',')
605
        .filter_map(|p| p.split_once(':'))
606
        .filter_map(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))
607
        .collect();
608
    let mut out = Vec::new();
609
    for j in &jobs {
610
        let lines = match after.get(&j.id) {
611
            Some(seq) => fdb::log(&app.db, j.id, *seq, 1000).await.unwrap_or_default(),
612
            None => vec![],
613
        };
614
        out.push(serde_json::json!({
615
            "id": j.id,
616
            "name": j.name,
617
            "status": j.status,
618
            "glyph": glyph(&j.status),
619
            "duration": crate::flow::duration(j),
620
            "exit_code": j.exit_code,
621
            "lines": lines.iter().map(|l| serde_json::json!({ "seq": l.seq, "t": l.t, "stream": l.stream, "text": l.text })).collect::<Vec<_>>(),
622
        }));
623
    }
624
    Ok(Json(serde_json::json!({ "status": run.status, "live": run.live(), "jobs": out })).into_response())
625
}
626
627
// ------------------------------------------------------------ state stamps
628
629
/// In rendered Markdown, the image path that is the viewed state's stamp.
630
pub const STAMP_IMAGE: &str = ".flow/stamp";
631
/// In rendered Markdown, the link path to the viewed state's run.
632
pub const RUN_LINK: &str = ".flow/run";
633
634
/// Resolve a ref to its state address, and whether the ref can move.
635
async fn state_of(app: &Shared, owner: &str, name: &str, r: &str) -> Result<(String, bool), crate::repos::RepoError> {
636
    let repo = crate::repos::RepoName::new(owner, name).ok_or(crate::repos::RepoError::NotFound)?;
637
    let spec = r.to_string();
638
    app.repos
639
        .read(&repo, move |repo, cache| {
640
            let t = crate::repos::view::resolve(repo, cache, &spec)?;
641
            let moves = matches!(t.kind, crate::repos::view::RefKind::Bookmark | crate::repos::view::RefKind::Tag);
642
            Ok((t.permalink().unwrap_or_default(), moves))
643
        })
644
        .await
645
}
646
647
/// `GET /{owner}/{name}/flow/stamp/{ref}` (hub spec §13.2).
648
pub async fn stamp(
649
    State(app): State<Shared>,
650
    viewer: Viewer,
651
    Path((owner, name, r)): Path<(String, String, String)>,
652
) -> Result<Response, Error> {
653
    let shell = Shell::new(&app, "", &viewer);
654
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
655
    let (heads, moves) = state_of(&app, &owner, &name, &r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
656
    if moves && !heads.is_empty() {
657
        let mut res = axum::response::Redirect::to(&format!("/{owner}/{name}/flow/stamp/{heads}")).into_response();
658
        res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache"));
659
        return Ok(res);
660
    }
661
    let run = fdb::latest_for_heads(&app.db, row.id, &heads).await.map_err(|e| Error::from_db(shell.clone(), e))?;
662
    let jobs = match &run {
663
        Some(r) => fdb::jobs(&app.db, r.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
664
        None => vec![],
665
    };
666
    let svg = stamp_svg(&heads, run.as_ref(), &jobs);
667
    let cache = if run.as_ref().is_some_and(|r| !r.live()) { "public, max-age=3600" } else { "public, max-age=60" };
668
    Ok((
669
        [
670
            (axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"),
671
            (axum::http::header::CACHE_CONTROL, cache),
672
            (axum::http::header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'"),
673
        ],
674
        svg,
675
    )
676
        .into_response())
677
}
678
679
/// `GET /{owner}/{name}/flow/at/{ref}`: the run of the ref's state, or the Flow tab.
680
pub async fn at(
681
    State(app): State<Shared>,
682
    viewer: Viewer,
683
    Path((owner, name, r)): Path<(String, String, String)>,
684
) -> Result<Response, Error> {
685
    let shell = Shell::new(&app, "", &viewer);
686
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
687
    let (heads, _) = state_of(&app, &owner, &name, &r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
688
    let run = fdb::latest_for_heads(&app.db, row.id, &heads).await.map_err(|e| Error::from_db(shell.clone(), e))?;
689
    let to = match run {
690
        Some(r) => format!("/{owner}/{name}/flow/{}", r.number),
691
        None => format!("/{owner}/{name}/flow"),
692
    };
693
    Ok(axum::response::Redirect::to(&to).into_response())
694
}
695
696
fn xml(s: &str) -> String {
697
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
698
}
699
700
/// The stamp: the mark, one glyph per job, the status, the state and the
701
/// date; the same facts machine-readable in `<metadata>`. Fixed colours,
702
/// because an image does not follow the page's theme.
703
pub fn stamp_svg(heads: &str, run: Option<&fdb::Run>, jobs: &[fdb::Job]) -> String {
704
    const CH: f64 = 6.6; // IBM Plex Mono at 11px
705
    let colour = |s: &str| match s {
706
        "passed" => "#5fe3a1",
707
        "failed" | "error" => "#ff5e7a",
708
        "running" => "#00e0f0",
709
        _ => "#8592ad",
710
    };
711
    let short: String = heads.chars().take(12).collect();
712
    let (status, date) = match run {
713
        Some(r) => (r.status.as_str(), crate::render::time::absolute(r.created_ms as u64).chars().take(10).collect::<String>()),
714
        None => ("no run", String::new()),
715
    };
716
    let mut ordered: Vec<&fdb::Job> = jobs.iter().collect();
717
    ordered.sort_by(|a, b| (a.depth, &a.name).cmp(&(b.depth, &b.name)));
718
    let label = "OXYGENFLOW";
719
    let label_w = 14.0 + label.len() as f64 * 6.2 + 8.0;
720
    let glyphs_w = if ordered.is_empty() { 0.0 } else { ordered.len() as f64 * 10.0 + 6.0 };
721
    let tail = if date.is_empty() { format!("{status} · {short}") } else { format!("{status} · {short} · {date}") };
722
    let right_w = 8.0 + glyphs_w + tail.chars().count() as f64 * CH + 8.0;
723
    let w = (label_w + right_w).ceil();
724
    let mut glyphs = String::new();
725
    for (i, j) in ordered.iter().enumerate() {
726
        let x = label_w + 8.0 + i as f64 * 10.0 + 4.0;
727
        glyphs.push_str(&format!(
728
            r#"<text x="{x:.1}" y="15" fill="{}" text-anchor="middle">{}</text>"#,
729
            colour(&j.status),
730
            glyph(&j.status)
731
        ));
732
    }
733
    let meta = serde_json::json!({
734
        "heads": heads.split('+').filter(|h| !h.is_empty()).collect::<Vec<_>>(),
735
        "run": run.map(|r| r.number),
736
        "status": status,
737
        "jobs": ordered.iter().map(|j| serde_json::json!({ "name": j.name, "status": j.status, "receipt": j.receipt_hash })).collect::<Vec<_>>(),
738
    });
739
    let title = match run {
740
        Some(r) => format!("OxygenFlow run #{} {status} on state {short}", r.number),
741
        None => format!("OxygenFlow: no run for state {short}"),
742
    };
743
    format!(
744
        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>"##,
745
        t = xml(&title),
746
        m = xml(&meta.to_string()),
747
        lw = label_w,
748
        lx = label_w - 4.0,
749
        tx = label_w + 8.0 + glyphs_w,
750
        sc = colour(status),
751
        tail = xml(&tail),
752
        wi = w - 1.0,
753
    )
754
}
755
756
/// A receipt kept in a repository (Oxygen spec §14.6), as pages show it.
757
#[derive(Clone, Debug)]
758
pub struct ReceiptRow {
759
    pub attest: String,
760
    pub job: String,
761
    pub exit_code: u32,
762
    pub duration: String,
763
    pub runner: String,
764
    pub run: String,
765
    /// Why it does not verify, or that its runner's key was revoked.
766
    pub problem: String,
767
    pub made_ms: i64,
768
}
769
770
impl ReceiptRow {
771
    pub fn passed(&self) -> bool {
772
        self.exit_code == 0
773
    }
774
}
775
776
/// The receipts kept for the state of `heads`.
777
pub async fn receipts_of(app: &Shared, name: &crate::repos::RepoName, heads: Vec<oxy_core::hash::ChangeHash>) -> Vec<ReceiptRow> {
778
    let base = app.config.base_url();
779
    let found = app.repos.read(name, move |repo, _| Ok(repo.receipts(&heads)?)).await.unwrap_or_default();
780
    found
781
        .into_iter()
782
        .map(|i| {
783
            let r = &i.signed.receipt;
784
            let run = i.signed.run().to_string();
785
            ReceiptRow {
786
                attest: i.attest.to_hex(),
787
                job: r.job_name.clone(),
788
                exit_code: r.exit_code,
789
                duration: crate::flow::human_ms(r.duration_ms as i64),
790
                runner: i.signed.runner.name.clone(),
791
                // Only links to this hub are followed from the page.
792
                run: if run.starts_with(base) { run } else { String::new() },
793
                problem: match (i.problem, i.revoked) {
794
                    (Some(p), _) => p,
795
                    (None, true) => "the runner's key was revoked".into(),
796
                    (None, false) => String::new(),
797
                },
798
                made_ms: i.signed.attest.hlc.wall_ms as i64,
799
            }
800
        })
801
        .collect()
802
}
803
804
/// `GET /{owner}/{name}/receipts/{attest}`: a signed receipt, for
805
/// `oxy receipt verify` or `oxy receipt import`.
806
pub async fn receipt_file(
807
    State(app): State<Shared>,
808
    viewer: Viewer,
809
    Path((owner, name, attest)): Path<(String, String, String)>,
810
) -> Result<Response, Error> {
811
    let shell = Shell::new(&app, &format!("/{owner}/{name}/receipts/{attest}"), &viewer);
812
    let (repo, _row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
813
    let Some(r) = oxy_core::hash::ObjectRef::from_hex(attest.trim_end_matches(".receipt")) else {
814
        return Err(Error::not_found(shell));
815
    };
816
    let (job, bytes) = app
817
        .repos
818
        .read(&repo, move |repo, _| {
819
            let i = repo.receipt(&r)?;
820
            Ok((i.signed.receipt.job_name.clone(), i.signed.encode()))
821
        })
822
        .await
823
        .map_err(|_| Error::not_found(shell.clone()))?;
824
    let file: String = job.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect();
825
    Ok((
826
        [
827
            (axum::http::header::CONTENT_TYPE, "application/oxy-receipt".to_string()),
828
            (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{file}-{}.receipt\"", r.short(12))),
829
        ],
830
        bytes,
831
    )
832
        .into_response())
833
}
834
835
/// `GET /{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}`: an artifact
836
/// of a run's job, read from the repository.
837
pub async fn artifact_file(
838
    State(app): State<Shared>,
839
    viewer: Viewer,
840
    Path((owner, name, id, job, artifact)): Path<(String, String, i64, String, String)>,
841
) -> Result<Response, Error> {
842
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}"), &viewer);
843
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
844
    let run = fdb::run(&app.db, row.id, id)
845
        .await
846
        .map_err(|e| Error::from_db(shell.clone(), e))?
847
        .ok_or_else(|| Error::not_found(shell.clone()))?;
848
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
849
    let Some(j) = jobs.iter().find(|j| j.name == job) else { return Err(Error::not_found(shell)) };
850
    let arts = fdb::artifacts(&app.db, j.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
851
    let Some(a) = arts.into_iter().find(|a| a.name == artifact) else { return Err(Error::not_found(shell)) };
852
    let Some(blob) = oxy_core::hash::ObjectRef::from_hex(&a.blob) else { return Err(Error::not_found(shell)) };
853
    let bytes = app.repos.read(&repo, move |r, _| Ok(r.read_blob(&blob)?)).await.map_err(|_| Error::not_found(shell.clone()))?;
854
    let file = a.path.rsplit('/').next().unwrap_or(&a.name).to_string();
855
    Ok((
856
        [
857
            (axum::http::header::CONTENT_TYPE, "application/octet-stream".to_string()),
858
            (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", file.replace('"', ""))),
859
        ],
860
        bytes,
861
    )
862
        .into_response())
863
}
864
865
/// `GET …/artifacts/{job}/{artifact}/provenance` (oxygenhub#239): the
866
/// artifact's SLSA provenance in a DSSE envelope signed by the hub, for
867
/// `cosign verify-blob-attestation --key oxyhub-signing.pem --type
868
/// slsaprovenance1`. Only for a job whose signed receipt the hub kept.
869
pub async fn provenance(
870
    State(app): State<Shared>,
871
    viewer: Viewer,
872
    Path((owner, name, id, job, artifact)): Path<(String, String, i64, String, String)>,
873
) -> Result<Response, Error> {
874
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/provenance"), &viewer);
875
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
876
    let run = fdb::run(&app.db, row.id, id)
877
        .await
878
        .map_err(|e| Error::from_db(shell.clone(), e))?
879
        .ok_or_else(|| Error::not_found(shell.clone()))?;
880
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
881
    let Some(j) = jobs.iter().find(|j| j.name == job) else { return Err(Error::not_found(shell)) };
882
    if j.receipt_attest.is_empty() {
883
        return Err(Error::bad(shell, "This job has no signed receipt the hub kept, so the hub states no provenance for it.".to_string()));
884
    }
885
    let arts = fdb::artifacts(&app.db, j.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
886
    let Some(a) = arts.into_iter().find(|a| a.name == artifact) else { return Err(Error::not_found(shell)) };
887
    let Some(blob) = oxy_core::hash::ObjectRef::from_hex(&a.blob) else { return Err(Error::not_found(shell)) };
888
    let bytes = app.repos.read(&repo, move |r, _| Ok(r.read_blob(&blob)?)).await.map_err(|_| Error::not_found(shell.clone()))?;
889
    let file = a.path.rsplit('/').next().unwrap_or(&a.name).to_string();
890
    let base = app.config.base_url();
891
    let subject = crate::provenance::Subject {
892
        name: &file,
893
        bytes: &bytes,
894
        repo: &format!("{owner}/{name}"),
895
        run_url: &format!("{base}/{owner}/{name}/flow/{id}"),
896
        bookmark: &run.bookmark,
897
        state: &run.state,
898
        heads: &run.heads,
899
        pipeline_hash: &run.pipeline_hash,
900
        job: &j.name,
901
        runner: &j.runner,
902
        receipt_signer: &j.receipt_signer,
903
        receipt_attest: &j.receipt_attest,
904
        started_ms: j.started_ms,
905
        finished_ms: j.finished_ms,
906
    };
907
    let statement = crate::provenance::statement(base, &subject);
908
    let envelope = crate::provenance::envelope(&app, &statement).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
909
    Ok((
910
        [
911
            (axum::http::header::CONTENT_TYPE, "application/json".to_string()),
912
            (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}.intoto.json\"", file.replace('"', ""))),
913
        ],
914
        serde_json::to_string(&envelope).unwrap_or_default(),
915
    )
916
        .into_response())
917
}
918
919
/// `GET /.well-known/oxyhub-signing.pem`: the hub's Ed25519 public key, as
920
/// cosign and other verifiers take it (oxygenhub#239).
921
pub async fn signing_key(State(app): State<Shared>) -> Response {
922
    ([(axum::http::header::CONTENT_TYPE, "application/x-pem-file")], crate::provenance::public_pem(&app)).into_response()
923
}
924
925
#[derive(Deserialize)]
926
pub struct AttachForm {
927
    _csrf: String,
928
    release: i64,
929
}
930
931
/// `POST /{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/attach`: add
932
/// an artifact to a release as an asset, named after its path; the release
933
/// page then shows the run and receipt that built it.
934
pub async fn attach(
935
    State(app): State<Shared>,
936
    viewer: Viewer,
937
    Path((owner, name, id, job, artifact)): Path<(String, String, i64, String, String)>,
938
    Form(f): Form<AttachForm>,
939
) -> Result<Response, Error> {
940
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}"), &viewer);
941
    require(&viewer, &shell)?;
942
    csrf(&viewer, &f._csrf, &shell)?;
943
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
944
    let run = fdb::run(&app.db, row.id, id)
945
        .await
946
        .map_err(|e| Error::from_db(shell.clone(), e))?
947
        .ok_or_else(|| Error::not_found(shell.clone()))?;
948
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
949
    let Some(j) = jobs.iter().find(|j| j.name == job) else { return Err(Error::not_found(shell)) };
950
    // A failing job's `when "always"` artifacts are not in its receipt, so
951
    // they cannot stand for a release (oxygenflow#37).
952
    if j.status != "passed" {
953
        return Err(Error::bad(shell, "only a passing job's artifacts can be attached to a release"));
954
    }
955
    let arts = fdb::artifacts(&app.db, j.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
956
    let Some(a) = arts.into_iter().find(|a| a.name == artifact) else { return Err(Error::not_found(shell)) };
957
    let releases = db::releases::list(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
958
    let Some(rel) = releases.into_iter().find(|r| r.id == f.release) else { return Err(Error::not_found(shell)) };
959
    let Some(blob) = oxy_core::hash::ObjectRef::from_hex(&a.blob) else { return Err(Error::not_found(shell)) };
960
    let bytes = app.repos.read(&repo, move |r, _| Ok(r.read_blob(&blob)?)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
961
    let file = a.path.rsplit('/').next().unwrap_or(&a.name).to_string();
962
    super::releases::store_asset(&app, rel.id, &file, "application/octet-stream", &bytes)
963
        .await
964
        .map_err(|m| Error::bad(shell.clone(), m))?;
965
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", rel.tag)).into_response())
966
}
967
968
/// `/{owner}/{name}/flow/badge.svg`: the latest run on the default
969
/// bookmark as a small SVG for a README, for whoever may read the
970
/// repository. `passed` green, `failed` or `error` red, a run still going
971
/// blue, none grey.
972
pub async fn badge(
973
    State(app): State<Shared>,
974
    viewer: Viewer,
975
    Path((owner, name)): Path<(String, String)>,
976
) -> Result<axum::response::Response, Error> {
977
    use axum::response::IntoResponse;
978
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer);
979
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
980
    let latest = fdb::latest_on(&app.db, row.id, &row.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?;
981
    let (text, colour) = match latest.as_ref().map(|r| r.status.as_str()) {
982
        Some("passed") => ("passing", "#2da44e"),
983
        Some("failed") | Some("error") => ("failing", "#cf222e"),
984
        Some("cancelled") => ("cancelled", "#6e7781"),
985
        Some(_) => ("running", "#0969da"),
986
        None => ("no runs", "#6e7781"),
987
    };
988
    let label = "OxygenFlow";
989
    let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10);
990
    let svg = format!(
991
        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>"##,
992
        w = lw + tw,
993
        lx = lw / 2,
994
        tx = lw + tw / 2,
995
    );
996
    Ok((
997
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")],
998
        svg,
999
    )
1000
        .into_response())
1001
}