OXYGENHUB

A coverage check, and rules for it 7edaff01d506

Each coverage report sets a coverage check on its run's state, measured
against the default bookmark (its previous report there, or for another
bookmark such as a proposal's, its latest), linked to the report.
Settings → Coverage sets a minimum and a largest drop (migration 0047);
a broken rule fails the check with the reason, and as a required check
it holds a proposal back. A bookmark's first report shows its moved files
against the default bookmark. refs #113
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 17:02 UTC
Signature
✔ signed by Andy McCall
Hash
7edaff01d506aa28be6cf21f559e333e3281d48ef0d97b71774ae834184189d8
Depends on
19bb31ff1f5c 1f78655a0466 42fa95540468 554ccacfc123 64caed18236a 749d9a5fcd9f 7974adb0be66 9008bf6c2b8c 93be9ce4653e b5668108487f c987c86513df d23464c0ac60
Depended on by
c7067c7bda56 33d98147511b 5d90e0e045e2 d3b048b27277 40dd3f86379a 8aafd2023dec 617c2840446c 7fff30c4f5e2
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
18 atoms, 1 tree operation, 10 files

Browse the tree at this change

added crates/hub/migrations/0047_coverage_settings.sql +8 −0
1 +
-- A repository's coverage rules (oxygenhub#113): the `coverage` check
2 +
-- fails below `min` percent, or when coverage falls more than `max_drop`
3 +
-- points against the default bookmark. NULL: no rule.
4 +
CREATE TABLE coverage_settings (
5 +
    repo_id   INTEGER PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
6 +
    min       REAL,
7 +
    max_drop  REAL
8 +
);
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A coverage check (#113).** Each coverage report sets a `coverage` check, such as `81.9% (-0.3 against main)`, measured against the default bookmark. Settings › Coverage can set a minimum and a largest drop. The check then fails and says why, and as a required check it holds a proposal back. A proposal's report shows the files whose coverage moved against the default bookmark.
8 +
7 9
- **Coverage reports, built in (#113).** A Flow job's artifact named `coverage` in LCOV format is read when uploaded, and the job's log says what it found. A Coverage tab shows the latest percentage and its change over a chart of every run, a strip of facts (lines covered, hits per line, functions, branches, files, badge with its Markdown), what this run changed and the files whose coverage moved, how files spread over coverage bands, a chart of covered and missed lines, the recent runs, every file with a bar, and each file's lines marked run or missed, as Coveralls does. A badge is at `/OWNER/NAME/coverage/badge.svg`, and the run page shows each job's coverage. Totals match `cargo llvm-cov`'s own.
8 10
9 11
- **A wiki can start with a push.** Someone who may write to a repository can push the first pages of its wiki to `OWNER/NAME.wiki`, as well as writing them on the web: the hub makes the empty wiki when a writer first reaches that address, and the push gives it its id. Readers still find nothing there until it has pages.
…
129 unchanged lines
modified crates/hub/src/coverage.rs +105 −0
…
364 unchanged lines
365 365
        .await
366 366
}
367 367
368 +
/// A repository's coverage rules.
369 +
#[derive(Clone, Copy, Debug, Default, PartialEq)]
370 +
pub struct Rules {
371 +
    /// The check fails below this percentage.
372 +
    pub min: Option<f64>,
373 +
    /// The check fails when coverage falls more than this many points.
374 +
    pub max_drop: Option<f64>,
375 +
}
376 +
377 +
pub async fn rules(pool: &Pool, repo_id: i64) -> sqlx::Result<Rules> {
378 +
    let row: Option<(Option<f64>, Option<f64>)> =
379 +
        sqlx::query_as("SELECT min, max_drop FROM coverage_settings WHERE repo_id = ?").bind(repo_id).fetch_optional(pool).await?;
380 +
    Ok(row.map(|(min, max_drop)| Rules { min, max_drop }).unwrap_or_default())
381 +
}
382 +
383 +
pub async fn set_rules(pool: &Pool, repo_id: i64, r: Rules) -> sqlx::Result<()> {
384 +
    sqlx::query(
385 +
        "INSERT INTO coverage_settings (repo_id, min, max_drop) VALUES (?, ?, ?) \
386 +
         ON CONFLICT (repo_id) DO UPDATE SET min = excluded.min, max_drop = excluded.max_drop",
387 +
    )
388 +
    .bind(repo_id)
389 +
    .bind(r.min)
390 +
    .bind(r.max_drop)
391 +
    .execute(pool)
392 +
    .await?;
393 +
    Ok(())
394 +
}
395 +
396 +
/// What a report is measured against: on the default bookmark, the report
397 +
/// before it there; on any other, the default bookmark's latest before it.
398 +
pub async fn baseline(pool: &Pool, r: &Report, default_bookmark: &str) -> sqlx::Result<Option<Report>> {
399 +
    sqlx::query_as(
400 +
        "SELECT * FROM coverage_reports WHERE repo_id = ? AND bookmark = ? AND id <> ? AND (created_ms < ? OR (created_ms = ? AND id < ?)) \
401 +
         ORDER BY created_ms DESC, id DESC LIMIT 1",
402 +
    )
403 +
    .bind(r.repo_id)
404 +
    .bind(default_bookmark)
405 +
    .bind(r.id)
406 +
    .bind(r.created_ms)
407 +
    .bind(r.created_ms)
408 +
    .bind(r.id)
409 +
    .fetch_optional(pool)
410 +
    .await
411 +
}
412 +
413 +
/// The `coverage` check for a report: passed or failed, and what to say.
414 +
pub fn verdict(r: &Report, base: Option<&Report>, base_name: &str, rules: Rules) -> (&'static str, String) {
415 +
    let Some(p) = r.percent() else { return ("passed", "no instrumented lines".into()) };
416 +
    let delta = base.and_then(|b| Some(p - b.percent()?));
417 +
    let against = match delta {
418 +
        Some(d) if d.abs() < 0.05 => format!(", unchanged against {base_name}"),
419 +
        Some(d) => format!(" ({d:+.1} against {base_name})"),
420 +
        None => String::new(),
421 +
    };
422 +
    if let Some(min) = rules.min.filter(|m| p < *m) {
423 +
        return ("failed", format!("{p:.1}%{against}: below the minimum of {min}%"));
424 +
    }
425 +
    if let (Some(max), Some(d)) = (rules.max_drop, delta) {
426 +
        if d < -max {
427 +
            return ("failed", format!("{p:.1}%{against}: falls more than the {max} points allowed"));
428 +
        }
429 +
    }
430 +
    ("passed", format!("{p:.1}%{against}"))
431 +
}
432 +
368 433
pub async fn for_run(pool: &Pool, run_id: i64) -> sqlx::Result<Vec<Report>> {
369 434
    sqlx::query_as("SELECT * FROM coverage_reports WHERE run_id = ? ORDER BY job").bind(run_id).fetch_all(pool).await
370 435
}
…
29 unchanged lines
400 465
        let (one, _) = parse_lcov("SF:/src/a.rs\nDA:1,1\nDA:2,0\nLF:5\nLH:3\nend_of_record\n").unwrap();
401 466
        assert_eq!((one[0].lines_found(), one[0].lines_hit()), (5, 3));
402 467
        assert!(parse_lcov("not a report\n").unwrap_err().contains("not an LCOV report"));
468 +
    }
469 +
470 +
    #[test]
471 +
    fn the_check_follows_the_rules() {
472 +
        let report = |pct_hit: i64| Report {
473 +
            id: 2,
474 +
            repo_id: 1,
475 +
            run_id: 1,
476 +
            job_id: 1,
477 +
            job: "test".into(),
478 +
            run_number: 1,
479 +
            bookmark: "feature".into(),
480 +
            state: String::new(),
481 +
            heads: String::new(),
482 +
            blob: String::new(),
483 +
            format: "lcov".into(),
484 +
            lines_found: 1000,
485 +
            lines_hit: pct_hit,
486 +
            functions_found: 0,
487 +
            functions_hit: 0,
488 +
            branches_found: 0,
489 +
            branches_hit: 0,
490 +
            hits: 0,
491 +
            files: 1,
492 +
            dropped: 0,
493 +
            created_ms: 0,
494 +
        };
495 +
        let (now, before) = (report(819), report(822));
496 +
        let none = Rules::default();
497 +
        assert_eq!(verdict(&now, Some(&before), "main", none), ("passed", "81.9% (-0.3 against main)".to_string()));
498 +
        assert_eq!(verdict(&now, None, "main", none), ("passed", "81.9%".to_string()));
499 +
        assert_eq!(verdict(&now, Some(&now), "main", none).1, "81.9%, unchanged against main");
500 +
        let strict = Rules { min: Some(85.0), max_drop: None };
501 +
        assert_eq!(
502 +
            verdict(&now, Some(&before), "main", strict),
503 +
            ("failed", "81.9% (-0.3 against main): below the minimum of 85%".to_string())
504 +
        );
505 +
        let drop = Rules { min: None, max_drop: Some(0.2) };
506 +
        assert_eq!(verdict(&now, Some(&before), "main", drop).0, "failed");
507 +
        assert_eq!(verdict(&now, Some(&before), "main", Rules { min: None, max_drop: Some(0.5) }).0, "passed");
403 508
    }
404 509
405 510
    #[test]
…
11 unchanged lines
modified crates/hub/src/flow.rs +17 −0
…
1005 unchanged lines
1006 1006
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
1007 1007
        return Err("coverage: the report was not kept".into());
1008 1008
    };
1009 +
    // The `coverage` check on the run's state (#113): against the default
1010 +
    // bookmark, under the repository's rules.
1011 +
    let base = crate::coverage::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("coverage: {e}"))?;
1012 +
    let rules = crate::coverage::rules(&app.db, row.id).await.map_err(|e| format!("coverage: {e}"))?;
1013 +
    let (status, summary) = crate::coverage::verdict(&r, base.as_ref(), &row.default_bookmark, rules);
1014 +
    let url = format!("{}/{}/{}/coverage?report={}", app.config.base_url(), row.owner, row.name, r.id);
1015 +
    if let Ok(c) = db::hooks::upsert_check(
1016 +
        &app.db,
1017 +
        row.id,
1018 +
        db::hooks::CheckReport { state: &run.state, name: "coverage", status, summary: &summary, url: &url, reporter: None, seal: "" },
1019 +
    )
1020 +
    .await
1021 +
    {
1022 +
        let api = crate::api::check_of(&c);
1023 +
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1024 +
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1025 +
    }
1009 1026
    let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
1010 1027
    let dropped =
1011 1028
        if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() };
…
444 unchanged lines
modified crates/hub/src/web/coverage.rs +8 −2
…
244 unchanged lines
245 245
    let bookmarks = coverage::bookmarks(&app.db, row.id).await.map_err(e)?;
246 246
    let history = coverage::on_bookmark(&app.db, row.id, &bookmark, HISTORY).await.map_err(e)?;
247 247
    let report = picked.or_else(|| history.first().cloned());
248 -
    // The report before the one shown, on the same bookmark.
249 -
    let previous = report
248 +
    // The report before the one shown, on the same bookmark; for a
249 +
    // bookmark's first report, the default bookmark's latest before it.
250 +
    let mut previous = report
250 251
        .as_ref()
251 252
        .and_then(|r| history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id)).cloned());
253 +
    if let (None, Some(r)) = (&previous, &report) {
254 +
        if r.bookmark != row.default_bookmark {
255 +
            previous = coverage::baseline(&app.db, r, &row.default_bookmark).await.map_err(e)?;
256 +
        }
257 +
    }
252 258
    let delta = report.as_ref().zip(previous.as_ref()).and_then(|(r, p)| Some(r.percent()? - p.percent()?));
253 259
    let mut files = match &report {
254 260
        Some(r) => coverage::files(&app.db, r.id).await.map_err(e)?,
…
244 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
247 unchanged lines
248 248
        .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror))
249 249
        .route("/{owner}/{name}/settings/flow", post(repo_settings::flow))
250 250
        .route("/{owner}/{name}/settings/storage", post(repo_settings::storage))
251 +
        .route("/{owner}/{name}/settings/coverage", post(repo_settings::coverage))
251 252
        .route("/{owner}/{name}/settings/runners/token", post(repo_settings::runner_token))
252 253
        .route("/{owner}/{name}/settings/runners/{id}/remove", post(repo_settings::runner_remove))
253 254
        .route("/{owner}/{name}/issues", get(issues::list))
…
461 unchanged lines
modified crates/hub/src/web/repo_settings.rs +51 −0
…
187 unchanged lines
188 188
    secrets: super::secrets::Panel,
189 189
    /// Where its data lives, when in a bucket (oxygenhub#96).
190 190
    storage: Option<crate::storage::Storage>,
191 +
    /// The coverage check's rules (#113).
192 +
    coverage: crate::coverage::Rules,
191 193
}
192 194
193 195
async fn settings_page(
…
37 unchanged lines
231 233
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
232 234
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
233 235
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
236 +
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
234 237
    Ok(Page(RepoSettingsPage {
238 +
        coverage,
235 239
        storage,
236 240
        assistants,
237 241
        variables,
…
397 unchanged lines
635 639
}
636 640
637 641
#[derive(Deserialize)]
642 +
pub struct CoverageForm {
643 +
    #[serde(rename = "_csrf")]
644 +
    csrf_token: String,
645 +
    #[serde(default)]
646 +
    min: String,
647 +
    #[serde(default)]
648 +
    max_drop: String,
649 +
}
650 +
651 +
/// Settings → Coverage (#113): the rules the `coverage` check applies.
652 +
pub async fn coverage(
653 +
    State(app): State<Shared>,
654 +
    viewer: Viewer,
655 +
    Path((owner, name)): Path<(String, String)>,
656 +
    Form(f): Form<CoverageForm>,
657 +
) -> Result<Page<RepoSettingsPage>, Error> {
658 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
659 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
660 +
    csrf(&viewer, &f.csrf_token, &shell)?;
661 +
    let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> {
662 +
        let v = v.trim().trim_end_matches('%').trim();
663 +
        if v.is_empty() {
664 +
            return Ok(None);
665 +
        }
666 +
        match v.parse::<f64>() {
667 +
            Ok(n) if (0.0..=hi).contains(&n) => Ok(Some(n)),
668 +
            _ => Err(format!("{what} is a number from 0 to {hi}, or empty for no rule.")),
669 +
        }
670 +
    };
671 +
    let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) {
672 +
        (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop },
673 +
        (Err(e), _) | (_, Err(e)) => return settings_page(&app, shell, row, role, None, Some(e)).await,
674 +
    };
675 +
    crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?;
676 +
    accounts::audit(
677 +
        &app.db,
678 +
        viewer.account(),
679 +
        "repo.coverage_rules",
680 +
        &format!("{owner}/{name}"),
681 +
        &format!("min {:?}, max drop {:?}", rules.min, rules.max_drop),
682 +
    )
683 +
    .await
684 +
    .ok();
685 +
    settings_page(&app, shell, row, role, Some("Coverage rules saved; they apply from the next report.".into()), None).await
686 +
}
687 +
688 +
#[derive(Deserialize)]
638 689
pub struct StorageForm {
639 690
    #[serde(rename = "_csrf")]
640 691
    csrf_token: String,
…
220 unchanged lines
modified crates/hub/tests/flow.rs +12 −0
…
2184 unchanged lines
2185 2185
    assert!(badge.contains("coverage: 50.0%"), "{badge}");
2186 2186
    let (_, run_page) = call(&h.app, "GET", &format!("/andy/cov/flow/{}", run.number), None, None).await;
2187 2187
    assert!(run_page.contains("coverage 50.0%"), "{run_page}");
2188 +
2189 +
    // The `coverage` check on the run's state: it reports, and with a
2190 +
    // minimum the repository sets, it fails below it.
2191 +
    let row = db::repos::get(&h.app.db, "andy", "cov").await.unwrap().unwrap();
2192 +
    let check = |checks: Vec<db::hooks::Check>| checks.into_iter().find(|c| c.name == "coverage").expect("a coverage check");
2193 +
    let c = check(db::hooks::checks_for_state(&h.app.db, row.id, &run.state).await.unwrap());
2194 +
    assert_eq!((c.status.as_str(), c.summary.as_str()), ("passed", "50.0%"));
2195 +
    assert!(c.url.ends_with(&format!("/andy/cov/coverage?report={report}")), "{}", c.url);
2196 +
    oxygenhub::coverage::set_rules(&h.app.db, row.id, oxygenhub::coverage::Rules { min: Some(60.0), max_drop: None }).await.unwrap();
2197 +
    assert_eq!(upload(&h.app, id, "coverage", lcov.as_bytes()).await.0, StatusCode::OK);
2198 +
    let c = check(db::hooks::checks_for_state(&h.app.db, row.id, &run.state).await.unwrap());
2199 +
    assert_eq!((c.status.as_str(), c.summary.as_str()), ("failed", "50.0%: below the minimum of 60%"));
2188 2200
}
2189 2201
2190 2202
async fn crate_report(app: &Shared, run_id: i64) -> i64 {
…
2 unchanged lines
modified docs/spec.md +3 −1
…
587 unchanged lines
588 588
- **Counting.** Paths are taken relative to the job's `/src`; a path outside the repository, or a file the run's state does not hold, is left out and counted. Records for one file are added together. A file with a single record keeps the tool's own `LF`/`LH` totals, since `llvm-cov` declares lines its `DA` lines leave out; the totals then match what the tool printed.
589 589
- **Stored.** `coverage_reports` keeps each report's totals (lines, functions, branches) with its run, job, bookmark and state; `coverage_files` each file's counts. The per-line counts stay in the artifact's blob and are read again for the file view.
590 590
- **Pages.** A Coverage tab, shown once a repository has a report: the bookmark's latest percentage with its change against the report before over a chart of up to 200 runs (each point names its run and opens its report), a strip of facts (lines covered, hits per line, functions, branches, files, the first report, the badge with its Markdown), this run's changes with the files whose coverage moved most, files by coverage band, a chart of covered and missed lines, the recent runs, a bookmark picker, every file with a bar (sorted by lines missed, lowest coverage, or path), and a file view with each line run or missed and how often it ran. `/OWNER/NAME/coverage/badge.svg` is a badge of the default bookmark's latest coverage. The run page shows each job's coverage beside its artifacts.
591 -
- **Next:** coverage of a proposal's projected state against its target, file by file, and a `coverage` check with a minimum and a largest drop set in the repository's settings; Cobertura XML; the API; and a coverage job in Oxygen's own pipeline (cargo-llvm-cov in the Flow build image).
591 +
- **The check.** Each report sets a `coverage` check on its run's state, measured against the default bookmark: its previous report there for a run on it, its latest for a run on any other bookmark (a proposal's). Settings › Coverage can set a minimum percentage and a largest drop in points; broken, the check fails and says why (`50.0%: below the minimum of 60%`), and naming `coverage` among the required checks holds a proposal back. Without rules it only reports. A bookmark's first report shows the files that moved against the default bookmark.
592 +
- **Oxygen itself:** its `test` job runs under cargo-llvm-cov in the build image (oxygenflow 7705d699) and keeps its report as the `coverage` artifact; the README carries the badge.
593 +
- **Next:** Cobertura XML, and the API.
592 594
593 595
## 14. Git interoperability
594 596
…
145 unchanged lines
modified templates/repo_settings.html +11 −0
…
17 unchanged lines
18 18
  <a href="#git-mirror">Git mirror</a>
19 19
  <a href="#checks">Required checks</a>
20 20
  <a href="#flow">OxygenFlow</a>
21 +
  <a href="#coverage">Coverage</a>
21 22
  {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a>
22 23
  <a href="#variables">Variables</a>
23 24
  <a href="#secrets">Secrets</a>
…
59 unchanged lines
83 84
  {% endif %}
84 85
</form>
85 86
{% if role == crate::auth::Role::Admin %}
87 +
<form class="card wide" id="coverage" method="post" action="{{ repo.url }}/settings/coverage">
88 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
89 +
  <h2>Coverage</h2>
90 +
  <p class="small mute">A job's <code>coverage</code> artifact (LCOV) gives each run a <code>coverage</code> check, measured against <strong>{{ row.default_bookmark }}</strong>. Without rules it only reports; with them it fails when they are broken, and a required check named <code>coverage</code> then holds a proposal back.</p>
91 +
  <div class="row">
92 +
    <div><label for="cov_min">Fail below this percentage</label><input type="number" id="cov_min" name="min" min="0" max="100" step="0.1" value="{% if let Some(m) = coverage.min %}{{ m }}{% endif %}" placeholder="e.g. 80"></div>
93 +
    <div><label for="cov_drop">Fail when it falls by more than (points)</label><input type="number" id="cov_drop" name="max_drop" min="0" max="100" step="0.1" value="{% if let Some(m) = coverage.max_drop %}{{ m }}{% endif %}" placeholder="e.g. 0.5"></div>
94 +
  </div>
95 +
  <button type="submit">SAVE</button>
96 +
</form>
86 97
<section class="panel" id="runners">
87 98
  <h2>Runners</h2>
88 99
  <p class="small">The OxygenFlow runners that may take this repository's jobs: the site's, {{ row.owner }}'s, and this repository's own.</p>
…
112 unchanged lines