OXYGENHUB

Coverage from a workstation: oh coverage upload, shown as uploaded 40dd3f86379a

POST /repos/{o}/{n}/coverage (repo:write) keeps an LCOV or Cobertura
report measured off the hub as the coverage of a bookmark's state or a
change's, read against that state's files as a job's artifact is and
kept with the uploader's name; it sets no check, since no runner
measured it. coverage_reports gains uploader, with run and job optional
(migration 0052 rebuilds the two tables, checked on a copy of the live
database: 24 reports and 1679 files kept). The Coverage page, its
history, the file view and the Reports card say 'uploaded by NAME' where
a run number would be. oh coverage upload FILE [--bookmark | --change].

closes #136
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 18:43 UTC
Signature
✔ signed by Andy McCall
Hash
40dd3f86379a920b8caa667001e63f08a0834286721ecc7ab8d486b3862103ff
Depends on
0650d94ede82 2291f8cc9c96 42fa95540468 554ccacfc123 5c6e92fd8d71 5ea4872f23e8 617c2840446c 7edaff01d506 7fff30c4f5e2 92a797b20912 93be9ce4653e b1e92a6233ee b5668108487f c8df33a6829f c987c86513df d23464c0ac60 f3e5e2bc78e9
Depended on by
fac6853c4e46 17dc8de4fca8 a7aa76ac52d7 5d90e0e045e2 e8fa53694f68 2fb70d019851
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check main oh-comment-body-file proposal-list-fast
In tags
v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
42 atoms, 1 tree operation, 15 files

Browse the tree at this change

added crates/hub/migrations/0052_coverage_uploads.sql +52 −0
1 +
-- Coverage uploaded from a workstation (oxygenhub#136): a report with no
2 +
-- Flow run or job behind it, made by `oh coverage upload`, naming who
3 +
-- uploaded it, so a runner's figure and an upload's cannot be mistaken
4 +
-- for each other. SQLite cannot drop NOT NULL, so both tables are rebuilt
5 +
-- (migrations run with foreign keys off).
6 +
CREATE TABLE coverage_reports_new (
7 +
    id               INTEGER PRIMARY KEY,
8 +
    repo_id          INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
9 +
    run_id           INTEGER REFERENCES flow_runs(id) ON DELETE CASCADE,
10 +
    job_id           INTEGER UNIQUE,
11 +
    job              TEXT NOT NULL,
12 +
    run_number       INTEGER NOT NULL,
13 +
    bookmark         TEXT NOT NULL,
14 +
    state            TEXT NOT NULL,
15 +
    heads            TEXT NOT NULL,
16 +
    blob             TEXT NOT NULL,
17 +
    format           TEXT NOT NULL,
18 +
    lines_found      INTEGER NOT NULL,
19 +
    lines_hit        INTEGER NOT NULL,
20 +
    functions_found  INTEGER NOT NULL,
21 +
    functions_hit    INTEGER NOT NULL,
22 +
    branches_found   INTEGER NOT NULL,
23 +
    branches_hit     INTEGER NOT NULL,
24 +
    hits             INTEGER NOT NULL DEFAULT 0,
25 +
    files            INTEGER NOT NULL,
26 +
    dropped          INTEGER NOT NULL,
27 +
    created_ms       INTEGER NOT NULL,
28 +
    -- Who uploaded it; empty for a Flow job's report.
29 +
    uploader         TEXT NOT NULL DEFAULT ''
30 +
);
31 +
INSERT INTO coverage_reports_new (id, repo_id, run_id, job_id, job, run_number, bookmark, state, heads, blob, format,
32 +
    lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit, hits, files, dropped, created_ms)
33 +
SELECT id, repo_id, run_id, job_id, job, run_number, bookmark, state, heads, blob, format,
34 +
    lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit, hits, files, dropped, created_ms
35 +
FROM coverage_reports;
36 +
CREATE TABLE coverage_files_new (
37 +
    report_id        INTEGER NOT NULL REFERENCES coverage_reports_new(id) ON DELETE CASCADE,
38 +
    path             TEXT NOT NULL,
39 +
    lines_found      INTEGER NOT NULL,
40 +
    lines_hit        INTEGER NOT NULL,
41 +
    functions_found  INTEGER NOT NULL,
42 +
    functions_hit    INTEGER NOT NULL,
43 +
    branches_found   INTEGER NOT NULL,
44 +
    branches_hit     INTEGER NOT NULL,
45 +
    PRIMARY KEY (report_id, path)
46 +
);
47 +
INSERT INTO coverage_files_new SELECT * FROM coverage_files;
48 +
DROP TABLE coverage_files;
49 +
DROP TABLE coverage_reports;
50 +
ALTER TABLE coverage_reports_new RENAME TO coverage_reports;
51 +
ALTER TABLE coverage_files_new RENAME TO coverage_files;
52 +
CREATE INDEX coverage_reports_bookmark ON coverage_reports (repo_id, bookmark, created_ms);
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Coverage from a workstation (#136).** `oh coverage upload FILE [--bookmark B | --change HASH]` keeps an LCOV or Cobertura report measured off the hub as the coverage of that state, for suites a runner cannot run in full. The Coverage page, its history and the Reports card say "uploaded by NAME" where a run number would be, so a runner's figure and an upload's are never mistaken for each other, and an upload sets no check.
8 +
7 9
- **Images by hash (oxygenflow#39).** Rootfs images live on the hub: `flow rootfs push` sends only the chunks the hub lacks (a rebuilt image costs its changes, runs of zeros cost nothing), the hub checks the whole image against its hash, and a runner that lacks a job's image fetches it instead of anyone copying it between machines. The hub offers such a job to runners that fetch, and says when neither the runner nor the hub holds the image.
8 10
9 11
- **Reports (#127).** A Flow job's `report "NAME" { path … }` output is kept file by file with every run, and a repository with reports gets one **Reports** tab in place of Coverage and Tests: each report's latest run with its kind, summary and metrics, and how each moved against the run before. A report's page shows its metrics with sparklines across runs, its thresholds, its `[context]`, its runs, and its own page (`index.html`) in a sandboxed frame that carries no session. Each metric with `fail_above` or `fail_below` sets a check named `report/NAME/METRIC` on the change.
…
155 unchanged lines
modified crates/hub-api/src/lib.rs +4 −0
…
1171 unchanged lines
1172 1172
    pub created_ms: i64,
1173 1173
    /// The report on the hub's Coverage tab, absolute.
1174 1174
    pub url: String,
1175 +
    /// Who uploaded it from a workstation (#136), when no Flow job made it;
1176 +
    /// `run` and `job` are then 0 and "upload".
1177 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1178 +
    pub uploaded_by: Option<String>,
1175 1179
}
1176 1180
1177 1181
/// One file of a coverage report.
…
60 unchanged lines
modified crates/hub/src/api/mod.rs +6 −1
…
109 unchanged lines
110 110
        .route("/repos/{owner}/{name}/labels", get(labels).post(create_label))
111 111
        .route("/repos/{owner}/{name}/labels/{label}", axum::routing::delete(delete_label))
112 112
        .route("/repos/{owner}/{name}/milestones", get(milestones).post(create_milestone))
113 -
        .route("/repos/{owner}/{name}/coverage", get(reports::coverage_list))
113 +
        .route(
114 +
            "/repos/{owner}/{name}/coverage",
115 +
            get(reports::coverage_list)
116 +
                .post(reports::coverage_upload)
117 +
                .layer(axum::extract::DefaultBodyLimit::max(crate::coverage::MAX_REPORT + (1 << 20))),
118 +
        )
114 119
        .route("/repos/{owner}/{name}/coverage/{id}", get(reports::coverage_one))
115 120
        .route("/repos/{owner}/{name}/tests", get(reports::tests_list))
116 121
        .route("/repos/{owner}/{name}/tests/{id}", get(reports::tests_one))
…
2279 unchanged lines
modified crates/hub/src/api/reports.rs +92 −0
…
42 unchanged lines
43 43
        dropped: r.dropped,
44 44
        created_ms: r.created_ms,
45 45
        url: format!("{}{base}/coverage?report={}", app.config.base_url().trim_end_matches('/'), r.id),
46 +
        uploaded_by: r.uploaded().then(|| r.uploader.clone()),
46 47
    }
47 48
}
48 49
…
30 unchanged lines
79 80
    Ok(Json(list.iter().map(|r| coverage_of(&app, &base, r)).collect()))
80 81
}
81 82
83 +
#[derive(Deserialize)]
84 +
pub struct Upload {
85 +
    /// The bookmark whose current state it measured (default: the default
86 +
    /// bookmark), or `change`: the state whose only head is that change.
87 +
    #[serde(default)]
88 +
    bookmark: Option<String>,
89 +
    #[serde(default)]
90 +
    change: Option<String>,
91 +
    /// The report: LCOV or Cobertura XML.
92 +
    content: String,
93 +
}
94 +
95 +
/// `POST /repos/{o}/{n}/coverage` (repo:write, #136): coverage measured off
96 +
/// the hub, as a workstation's, kept for a state with the uploader's name.
97 +
/// It is read against that state's files as a job's artifact is, and sets
98 +
/// no check: no runner measured it.
99 +
pub async fn coverage_upload(
100 +
    State(app): State<Shared>,
101 +
    headers: HeaderMap,
102 +
    Path((owner, name)): Path<(String, String)>,
103 +
    Json(u): Json<Upload>,
104 +
) -> Result<(axum::http::StatusCode, Json<CoverageReport>), ApiError> {
105 +
    use axum::http::StatusCode;
106 +
    use oxy_core::hash::ChangeHash;
107 +
    let c = caller(&app, &headers).await?;
108 +
    let me = c.require("repo:write")?.clone();
109 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
110 +
    if u.content.len() > coverage::MAX_REPORT {
111 +
        return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("at most {} MiB of coverage is read", coverage::MAX_REPORT >> 20)));
112 +
    }
113 +
    let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m);
114 +
    // The state it measured: a change's, or a bookmark's now.
115 +
    let (bookmark, heads) = match u.change.as_deref().map(str::trim).filter(|c| !c.is_empty()) {
116 +
        Some(ch) => {
117 +
            let h = ChangeHash::from_hex(ch.trim_start_matches('#')).ok_or_else(|| bad("give the full hash of a change".into()))?;
118 +
            (String::new(), vec![h])
119 +
        }
120 +
        None => {
121 +
            let b = u.bookmark.clone().filter(|b| !b.is_empty()).unwrap_or_else(|| row.default_bookmark.clone());
122 +
            let bb = b.clone();
123 +
            let heads = app
124 +
                .repos
125 +
                .read(&repo, move |r, _| match r.view()?.bookmarks.get(&bb) {
126 +
                    Some(st) => Ok(Some(r.store().get_state(st)?.heads)),
127 +
                    None => Ok(None),
128 +
                })
129 +
                .await
130 +
                .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
131 +
                .ok_or_else(|| bad(format!("no bookmark {b}")))?;
132 +
            (b, heads)
133 +
        }
134 +
    };
135 +
    let state =
136 +
        oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads.clone()).encode());
137 +
    let text = u.content;
138 +
    let (files, outside, format) = tokio::task::spawn_blocking({
139 +
        let t = text.clone();
140 +
        move || coverage::parse(&t)
141 +
    })
142 +
    .await
143 +
    .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
144 +
    .map_err(|e| bad(format!("coverage: {e}")))?;
145 +
    let h = heads.clone();
146 +
    let (blob, in_state) = app
147 +
        .repos
148 +
        .write(&repo, move |r, cache| {
149 +
            let files: std::collections::HashSet<String> = cache.load(r, &h)?.snapshot.files.keys().cloned().collect();
150 +
            Ok((r.store_blob(text.as_bytes())?, files))
151 +
        })
152 +
        .await
153 +
        .map_err(|e| bad(format!("that state's files could not be read: {e}")))?;
154 +
    let hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
155 +
    let origin = coverage::Origin {
156 +
        repo_id: row.id,
157 +
        run_id: None,
158 +
        run_number: 0,
159 +
        job_id: None,
160 +
        uploader: &me.username,
161 +
        job: "upload",
162 +
        bookmark: &bookmark,
163 +
        state: &state.to_hex(),
164 +
        heads: &hex,
165 +
        blob: &blob.to_hex(),
166 +
        format,
167 +
    };
168 +
    let id = coverage::keep(&app, &origin, files, outside, &in_state).await?;
169 +
    let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("the report was not kept"))?;
170 +
    crate::db::accounts::audit(&app.db, Some(&me), "coverage.upload", &format!("{owner}/{name}"), &r.source()).await.ok();
171 +
    Ok((StatusCode::CREATED, Json(coverage_of(&app, &format!("/{owner}/{name}"), &r))))
172 +
}
173 +
82 174
/// `GET /repos/{o}/{n}/coverage/{id}`.
83 175
pub async fn coverage_one(
84 176
    State(app): State<Shared>,
…
67 unchanged lines
modified crates/hub/src/coverage.rs +32 −9
…
331 unchanged lines
332 332
pub struct Report {
333 333
    pub id: i64,
334 334
    pub repo_id: i64,
335 -
    pub run_id: i64,
336 -
    pub job_id: i64,
335 +
    /// The Flow run and job it came from; none for an upload (#136).
336 +
    pub run_id: Option<i64>,
337 +
    pub job_id: Option<i64>,
337 338
    pub job: String,
338 339
    pub run_number: i64,
339 340
    pub bookmark: String,
…
11 unchanged lines
351 352
    pub files: i64,
352 353
    pub dropped: i64,
353 354
    pub created_ms: i64,
355 +
    /// Who uploaded it from a workstation (#136); empty for a job's report.
356 +
    pub uploader: String,
354 357
}
355 358
356 359
impl Report {
360 +
    /// Whether it was uploaded rather than measured by a Flow job.
361 +
    pub fn uploaded(&self) -> bool {
362 +
        !self.uploader.is_empty()
363 +
    }
364 +
365 +
    /// Where its figure came from: "run #61", or "uploaded by NAME".
366 +
    pub fn source(&self) -> String {
367 +
        if self.uploaded() {
368 +
            format!("uploaded by {}", self.uploader)
369 +
        } else {
370 +
            format!("run #{}", self.run_number)
371 +
        }
372 +
    }
373 +
357 374
    /// How often an instrumented line ran, on average.
358 375
    pub fn hits_per_line(&self) -> Option<f64> {
359 376
        (self.lines_found > 0).then(|| self.hits as f64 / self.lines_found as f64)
…
67 unchanged lines
427 444
/// What a run's report is kept with.
428 445
pub struct Origin<'a> {
429 446
    pub repo_id: i64,
430 -
    pub run_id: i64,
447 +
    /// The Flow run and job; none for an upload, which names its uploader.
448 +
    pub run_id: Option<i64>,
431 449
    pub run_number: i64,
432 -
    pub job_id: i64,
450 +
    pub job_id: Option<i64>,
451 +
    pub uploader: &'a str,
433 452
    pub job: &'a str,
434 453
    pub bookmark: &'a str,
435 454
    pub state: &'a str,
…
9 unchanged lines
445 464
    let (kept, missing): (Vec<FileCoverage>, Vec<FileCoverage>) = files.into_iter().partition(|f| in_state.contains(&f.path));
446 465
    let sum = |f: &dyn Fn(&FileCoverage) -> u64| kept.iter().map(f).sum::<u64>() as i64;
447 466
    let mut tx = crate::db::begin_write(&app.db).await?;
448 -
    sqlx::query("DELETE FROM coverage_reports WHERE job_id = ?").bind(o.job_id).execute(&mut *tx).await?;
467 +
    if let Some(job) = o.job_id {
468 +
        sqlx::query("DELETE FROM coverage_reports WHERE job_id = ?").bind(job).execute(&mut *tx).await?;
469 +
    }
449 470
    let id: i64 = sqlx::query_scalar(
450 471
        "INSERT INTO coverage_reports (repo_id, run_id, job_id, job, run_number, bookmark, state, heads, blob, format, \
451 -
         lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit, hits, files, dropped, created_ms) \
452 -
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
472 +
         lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit, hits, files, dropped, created_ms, uploader) \
473 +
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
453 474
    )
454 475
    .bind(o.repo_id)
455 476
    .bind(o.run_id)
…
15 unchanged lines
471 492
    .bind(kept.len() as i64)
472 493
    .bind((missing.len() + outside) as i64)
473 494
    .bind(now_ms())
495 +
    .bind(o.uploader)
474 496
    .fetch_one(&mut *tx)
475 497
    .await?;
476 498
    for f in &kept {
…
151 unchanged lines
628 650
        let report = |pct_hit: i64| Report {
629 651
            id: 2,
630 652
            repo_id: 1,
631 -
            run_id: 1,
632 -
            job_id: 1,
653 +
            run_id: Some(1),
654 +
            job_id: Some(1),
633 655
            job: "test".into(),
634 656
            run_number: 1,
635 657
            bookmark: "feature".into(),
…
11 unchanged lines
647 669
            files: 1,
648 670
            dropped: 0,
649 671
            created_ms: 0,
672 +
            uploader: String::new(),
650 673
        };
651 674
        let (now, before) = (report(819), report(822));
652 675
        let none = Rules::default();
…
66 unchanged lines
modified crates/hub/src/flow.rs +3 −2
…
1242 unchanged lines
1243 1243
    let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect();
1244 1244
    let origin = crate::coverage::Origin {
1245 1245
        repo_id: row.id,
1246 -
        run_id: run.id,
1246 +
        run_id: Some(run.id),
1247 1247
        run_number: run.number,
1248 -
        job_id: job.id,
1248 +
        job_id: Some(job.id),
1249 +
        uploader: "",
1249 1250
        job: &job.name,
1250 1251
        bookmark: &run.bookmark,
1251 1252
        state: &run.state,
…
477 unchanged lines
modified crates/hub/src/web/coverage.rs +3 −2
…
311 unchanged lines
312 312
    );
313 313
    let tip = |r: &Report| {
314 314
        format!(
315 -
            "Run #{} · {} · {} of {} lines",
316 -
            r.run_number,
315 +
            "{} · {} · {} of {} lines",
316 +
            r.source(),
317 317
            r.percent().map(|p| format!("{p:.1}%")).unwrap_or_default(),
318 318
            r.lines_hit,
319 319
            r.lines_found
320 320
        )
321 +
        .replacen("run #", "Run #", 1)
321 322
    };
322 323
    let points: Vec<Point> = oldest_first
323 324
        .iter()
…
178 unchanged lines
modified crates/hub/src/web/flow.rs +1 −1
…
340 unchanged lines
341 341
        let artifacts = fdb::artifacts(&app.db, j.id).await.unwrap_or_default();
342 342
        let coverage = reports
343 343
            .iter()
344 -
            .find(|r| r.job_id == j.id)
344 +
            .find(|r| r.job_id == Some(j.id))
345 345
            .map(|r| (r.id, r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into())));
346 346
        let tests = test_reports.iter().find(|r| r.job_id == j.id).cloned();
347 347
        let failures = match &tests {
…
400 unchanged lines
modified crates/hub/src/web/reports.rs +1 −1
…
203 unchanged lines
204 204
    let coverage = match pick(crate::coverage::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.coverage) {
205 205
        Some(b) => crate::coverage::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|c| Builtin {
206 206
            big: c.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()),
207 -
            sub: format!("{} of {} lines covered", c.lines_hit, c.lines_found),
207 +
            sub: format!("{} of {} lines covered · {}", c.lines_hit, c.lines_found, c.source()),
208 208
            health: match c.percent() {
209 209
                Some(p) if p >= 75.0 => "good",
210 210
                Some(p) if p < 50.0 => "bad",
…
183 unchanged lines
modified crates/hub/tests/flow.rs +47 −0
…
2674 unchanged lines
2675 2675
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(ask(true))).await;
2676 2676
    assert_eq!(st, StatusCode::OK, "it fetches, and the hub holds the image: {body}");
2677 2677
}
2678 +
2679 +
/// oxygenhub#136: coverage measured on a workstation is uploaded for a
2680 +
/// bookmark's state, kept as uploaded by its account, shown so, and sets
2681 +
/// no check.
2682 +
#[tokio::test(flavor = "multi_thread")]
2683 +
async fn coverage_uploaded_from_a_workstation_says_so() {
2684 +
    let h = hub().await;
2685 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
2686 +
    create(&h.app, "andy", "vk").await;
2687 +
    let url = format!("{}/andy/vk", h.base);
2688 +
    let work = tempfile::tempdir().unwrap();
2689 +
    let c = client(&url, work.path());
2690 +
    write(work.path(), "src/game.c", "int a;\nint b;\nint c;\nint d;\n");
2691 +
    c.commit("Game", &[]).unwrap();
2692 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2693 +
2694 +
    let lcov = "SF:src/game.c\nDA:1,3\nDA:2,1\nDA:3,0\nDA:4,5\nend_of_record\nSF:reference/rom.asm\nDA:1,1\nend_of_record\n";
2695 +
    let body = serde_json::json!({ "bookmark": "main", "content": lcov }).to_string();
2696 +
    let (st, out) = call(&h.app, "POST", "/api/v1/repos/andy/vk/coverage", Some("site"), Some(body)).await;
2697 +
    assert_eq!(st, StatusCode::CREATED, "{out}");
2698 +
    let v: serde_json::Value = serde_json::from_str(&out).unwrap();
2699 +
    assert_eq!((v["uploaded_by"].as_str(), v["lines_hit"].as_i64(), v["lines_found"].as_i64()), (Some("andy"), Some(3), Some(4)));
2700 +
    assert_eq!(v["dropped"], 1, "a file outside the state is left out");
2701 +
    assert_eq!(v["bookmark"], "main");
2702 +
2703 +
    let (_, page) = call(&h.app, "GET", "/andy/vk/coverage", None, None).await;
2704 +
    assert!(page.contains("uploaded by andy") && page.contains("75.0%"), "the Coverage page says where it came from");
2705 +
    let (_, page) = call(&h.app, "GET", "/andy/vk/reports", None, None).await;
2706 +
    assert!(page.contains("uploaded by andy"), "and the Reports card");
2707 +
    let row = db::repos::get(&h.app.db, "andy", "vk").await.unwrap().unwrap();
2708 +
    let state = v["state"].as_str().unwrap();
2709 +
    let checks = db::hooks::checks_for_state(&h.app.db, row.id, state).await.unwrap();
2710 +
    assert!(checks.iter().all(|c| c.name != "coverage"), "an upload sets no check: {checks:?}");
2711 +
2712 +
    // Readers cannot upload; bad reports and bookmarks are refused.
2713 +
    let (st, _) = call(&h.app, "POST", "/api/v1/repos/andy/vk/coverage", None, Some(r#"{"content":"x"}"#.into())).await;
2714 +
    assert_ne!(st, StatusCode::CREATED);
2715 +
    let (st, _) = call(
2716 +
        &h.app,
2717 +
        "POST",
2718 +
        "/api/v1/repos/andy/vk/coverage",
2719 +
        Some("site"),
2720 +
        Some(r#"{"bookmark":"nope","content":"SF:a\nend_of_record\n"}"#.into()),
2721 +
    )
2722 +
    .await;
2723 +
    assert_eq!(st, StatusCode::BAD_REQUEST);
2724 +
}
modified crates/oh/src/main.rs +51 −0
…
56 unchanged lines
57 57
        #[command(subcommand)]
58 58
        cmd: ProposalCmd,
59 59
    },
60 +
    /// Coverage: upload a report measured off the hub (oxygenhub#136)
61 +
    Coverage {
62 +
        #[command(subcommand)]
63 +
        cmd: CoverageCmd,
64 +
    },
60 65
    /// OxygenFlow runs
61 66
    Run {
62 67
        #[command(subcommand)]
…
383 unchanged lines
446 451
    } else {
447 452
        labels.iter().map(|l| format!("[{l}]")).collect::<Vec<_>>().join(" ")
448 453
    }
454 +
}
455 +
456 +
#[derive(Subcommand)]
457 +
enum CoverageCmd {
458 +
    /// Upload an LCOV or Cobertura report measured on this machine, as the
459 +
    /// coverage of a bookmark's current state (default: the default
460 +
    /// bookmark) or of one change's. The hub shows it as uploaded by you,
461 +
    /// not measured by a runner, and sets no check from it
462 +
    Upload {
463 +
        file: std::path::PathBuf,
464 +
        #[arg(long)]
465 +
        bookmark: Option<String>,
466 +
        /// A change's full hash
467 +
        #[arg(long, conflicts_with = "bookmark")]
468 +
        change: Option<String>,
469 +
    },
449 470
}
450 471
451 472
#[derive(Subcommand)]
…
144 unchanged lines
596 617
        Cmd::Issue { cmd } => issue(&c, &t, cmd, json),
597 618
        Cmd::Proposal { cmd } => proposal(&c, &t, cmd, json),
598 619
        Cmd::Run { cmd } => flow(&c, &t, cmd, json),
620 +
        Cmd::Coverage { cmd: CoverageCmd::Upload { file, bookmark, change } } => {
621 +
            let content = std::fs::read_to_string(&file).with_context(|| format!("read {}", file.display()))?;
622 +
            let mut body = serde_json::json!({ "content": content });
623 +
            if let Some(b) = bookmark {
624 +
                body["bookmark"] = b.into();
625 +
            }
626 +
            if let Some(ch) = change {
627 +
                body["change"] = ch.into();
628 +
            }
629 +
            let base = t.repo_path()?;
630 +
            let r: hub_api::CoverageReport = c.send("POST", &format!("{base}/coverage"), &body)?;
631 +
            if json {
632 +
                println!("{}", serde_json::to_string(&r)?);
633 +
            } else {
634 +
                let pct = r.percent.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
635 +
                let on = if r.bookmark.is_empty() { format!("state {}", &r.state[..12.min(r.state.len())]) } else { r.bookmark.clone() };
636 +
                println!(
637 +
                    "{} uploaded coverage {pct} ({} of {} lines, {} files) for {on} {}",
638 +
                    ui::ok("✔"),
639 +
                    r.lines_hit,
640 +
                    r.lines_found,
641 +
                    r.files,
642 +
                    ui::dim(&r.url)
643 +
                );
644 +
                if r.dropped > 0 {
645 +
                    println!("  {}", ui::dim(&format!("{} files it named are not in that state and were left out", r.dropped)));
646 +
                }
647 +
            }
648 +
            Ok(0)
649 +
        }
599 650
        Cmd::Release { cmd } => release(&c, &t, cmd, json),
600 651
        Cmd::Label { cmd } => label(&c, &t, cmd, json),
601 652
        Cmd::Milestone { cmd } => milestone(&c, &t, cmd, json),
…
890 unchanged lines
modified docs/spec.md +1 −1
…
459 unchanged lines
460 460
461 461
### 12.1 API
462 462
463 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
463 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
464 464
465 465
### 12.2 Webhooks
466 466
…
286 unchanged lines
modified templates/coverage.html +5 −5
…
8 unchanged lines
9 9
    <div>
10 10
      <h1>Coverage</h1>
11 11
      <div class="cov-sub">{% if bookmarks.len() > 1 %}<form class="inline" method="get" action="{{ repo.url }}/coverage"><label for="cov-bm" class="mute">bookmark</label> <select id="cov-bm" name="bookmark" onchange="this.form.submit()">{% for b in bookmarks %}<option value="{{ b }}"{% if b.as_str() == bookmark.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <noscript><button>Show</button></noscript></form>{% else %}<span class="mute">on</span> <strong>{{ bookmark }}</strong>{% endif %}
12 -
        · <a href="{{ repo.url }}/flow/{{ r.run_number }}">run #{{ r.run_number }}</a> · <time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></div>
12 +
        · {% if r.uploaded() %}<span class="tag warn" title="Uploaded with oh coverage upload: measured off the hub, not by a Flow job">uploaded by {{ r.uploader }}</span>{% else %}<a href="{{ repo.url }}/flow/{{ r.run_number }}">run #{{ r.run_number }}</a>{% endif %} · <time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></div>
13 13
    </div>
14 14
    <div class="cov-score {{ self.class_of(r.percent()) }}">
15 15
      <div class="cov-big">{{ self.pct(r.percent()) }}</div>
16 -
      {% if let Some(d) = delta %}<div class="cov-change {{ self.delta_class(d) }}" title="Against run #{% if let Some(p) = previous %}{{ p.run_number }}{% endif %}">{{ self.signed(d) }} since the run before</div>{% else %}<div class="cov-change same">the first report here</div>{% endif %}
16 +
      {% if let Some(d) = delta %}<div class="cov-change {{ self.delta_class(d) }}" title="Against {% if let Some(p) = previous %}{{ p.source() }}{% endif %}">{{ self.signed(d) }} since the run before</div>{% else %}<div class="cov-change same">the first report here</div>{% endif %}
17 17
    </div>
18 18
  </div>
19 19
  {% if runs > 1 %}<div class="cov-hero-chart">{{ coverage_chart|safe }}</div>{% endif %}
…
14 unchanged lines
34 34
    <h2>This run</h2>
35 35
    <ul class="cov-stats">
36 36
      <li><strong>{{ r.lines_hit }}</strong> of <strong>{{ r.lines_found }}</strong> relevant lines covered <span class="{{ self.class_of(r.percent()) }}">({{ self.pct(r.percent()) }})</span></li>
37 -
      {% if let Some((found, hit)) = self.lines_delta() %}<li>{% if *found >= 0 %}+{% endif %}{{ found }} relevant lines, {% if *hit >= 0 %}+{% endif %}{{ hit }} covered, against run #{% if let Some(p) = previous %}{{ p.run_number }}{% endif %}</li>{% endif %}
37 +
      {% if let Some((found, hit)) = self.lines_delta() %}<li>{% if *found >= 0 %}+{% endif %}{{ found }} relevant lines, {% if *hit >= 0 %}+{% endif %}{{ hit }} covered, against {% if let Some(p) = previous %}{{ p.source() }}{% endif %}</li>{% endif %}
38 38
      <li><strong>{{ self.hits(r) }}</strong> hits per line</li>
39 -
      <li>Job <strong>{{ r.job }}</strong>{% if r.dropped > 0 %}; {{ r.dropped }} files the report named are not in this state and are left out{% endif %}</li>
39 +
      <li>{% if r.uploaded() %}Uploaded by <strong>{{ r.uploader }}</strong>, measured off the hub{% else %}Job <strong>{{ r.job }}</strong>{% endif %}{% if r.dropped > 0 %}; {{ r.dropped }} files the report named are not in this state and are left out{% endif %}</li>
40 40
      {% if added > 0 %}<li>{{ added }} new files</li>{% endif %}
41 41
    </ul>
42 42
    {% if !moved.is_empty() %}<h3>Coverage moved in</h3>
…
15 unchanged lines
58 58
  <div class="table"><table class="cov-runs">
59 59
  <tr><th>Run</th><th>Job</th><th>Coverage</th><th class="num">Change</th><th class="num">Lines</th><th>When</th></tr>
60 60
  {% for x in recent %}<tr{% if x.report.id == r.id %} class="on"{% endif %}>
61 -
    <td><a href="{{ repo.url }}/coverage?report={{ x.report.id }}">#{{ x.report.run_number }}</a></td>
61 +
    <td><a href="{{ repo.url }}/coverage?report={{ x.report.id }}">{% if x.report.uploaded() %}upload by {{ x.report.uploader }}{% else %}#{{ x.report.run_number }}{% endif %}</a></td>
62 62
    <td>{{ x.report.job }}</td>
63 63
    <td><span class="cov-bar {{ self.class_of(x.report.percent()) }}"><span style="width: {% if let Some(p) = x.report.percent() %}{{ p }}{% else %}0{% endif %}%"></span></span> {{ self.pct(x.report.percent()) }}</td>
64 64
    <td class="num">{% if let Some(d) = x.delta %}<span class="cov-change {{ self.delta_class(d) }}">{{ self.signed(d) }}</span>{% endif %}</td>
…
35 unchanged lines
modified templates/coverage_file.html +1 −1
…
3 unchanged lines
4 4
{% block tabs %}{% call m::tabs(repo, "coverage") %}{% endblock %}
5 5
{% block content %}
6 6
<div class="head"><h1><a href="{{ repo.url }}/coverage?report={{ report.id }}">Coverage</a> / {{ file.path }} <small class="{{ self.class_of(file.percent()) }}">{{ self.pct(file.percent()) }}</small></h1>
7 -
<p class="small mute">{{ file.lines_hit }} of {{ file.lines_found }} lines run{% if file.functions_found > 0 %}, {{ file.functions_hit }} of {{ file.functions_found }} functions{% endif %}{% if file.branches_found > 0 %}, {{ file.branches_hit }} of {{ file.branches_found }} branches{% endif %} · <a href="{{ repo.url }}/flow/{{ report.run_number }}">run #{{ report.run_number }}</a> on {{ report.bookmark }} · <span class="cov-key cov-hit">run</span> <span class="cov-key cov-miss">missed</span></p></div>
7 +
<p class="small mute">{{ file.lines_hit }} of {{ file.lines_found }} lines run{% if file.functions_found > 0 %}, {{ file.functions_hit }} of {{ file.functions_found }} functions{% endif %}{% if file.branches_found > 0 %}, {{ file.branches_hit }} of {{ file.branches_found }} branches{% endif %} · {% if report.uploaded() %}uploaded by {{ report.uploader }}{% else %}<a href="{{ repo.url }}/flow/{{ report.run_number }}">run #{{ report.run_number }}</a>{% endif %} on {{ report.bookmark }} · <span class="cov-key cov-hit">run</span> <span class="cov-key cov-miss">missed</span></p></div>
8 8
{% if let Some(n) = note %}<p class="mute">{{ n }}</p>{% endif %}
9 9
{% if !lines.is_empty() %}
10 10
<table class="code cov-code">
…
4 unchanged lines