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 |