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 |