Coverage reports, built in 554ccacfc123
A Flow job's artifact named coverage (LCOV) is parsed when uploaded: totals for lines, functions and branches and each file's counts kept per run (migration 0046), paths relative to the job's /src and limited to the run's state, records for one file added together, a single record's own LF/LH kept so totals match the tool's (llvm-cov declares lines its DA lines leave out). The job's log says what was found or why not. A Coverage tab with the latest percentage and its change, a trend of the last 30 runs, every file with a bar, a file view marking each line run or missed, a badge at coverage/badge.svg, and coverage on the run page. Checked against a real cargo llvm-cov report (88.6%, 951 of 1073, as llvm-cov reports). refs #113
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-05 15:04 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
554ccacfc123b0c6b3170f3a6154a80ed3e5488d06809ffe4c5aadeacbf261cb- Depends on
- 10708ab8f54d 1f78655a0466 38833ed78707 42fa95540468 4d2e44d79b03 6e7dfa0a42b9 749d9a5fcd9f 756fe44a0f4d 8475176972e8 8933faff2043 93be9ce4653e a3c8288b0141 b5668108487f b64e2537d702 d1727c573f30 d23464c0ac60 df71c7892d27
- Depended on by
- 17dc8de4fca8 3b65065298b7 ab4df6aae179 e97500b70f9a a7aa76ac52d7 5d90e0e045e2 b644e1757711 40dd3f86379a c8df33a6829f a36bab6b9ffc 7b26e6118bc0 3d5c825aebba 617c2840446c 7fff30c4f5e2 36e5a9c15a84 0b203bbf7cc3 7edaff01d506 c987c86513df
- 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
- 25 atoms, 5 tree operations, 15 files
Browse the tree at this change
added crates/hub/migrations/0046_coverage.sql +39 −0
| 1 | + | -- Coverage reports from OxygenFlow jobs (oxygenhub#113): a job's artifact |
|
| 2 | + | -- named `coverage` (LCOV) is parsed when uploaded. The totals and each |
|
| 3 | + | -- file's counts are kept here; the per-line counts stay in the artifact's |
|
| 4 | + | -- blob and are read again for the file view. |
|
| 5 | + | CREATE TABLE coverage_reports ( |
|
| 6 | + | id INTEGER PRIMARY KEY, |
|
| 7 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 8 | + | run_id INTEGER NOT NULL REFERENCES flow_runs(id) ON DELETE CASCADE, |
|
| 9 | + | job_id INTEGER NOT NULL UNIQUE, |
|
| 10 | + | job TEXT NOT NULL, |
|
| 11 | + | run_number INTEGER NOT NULL, |
|
| 12 | + | bookmark TEXT NOT NULL, |
|
| 13 | + | state TEXT NOT NULL, |
|
| 14 | + | heads TEXT NOT NULL, |
|
| 15 | + | blob TEXT NOT NULL, |
|
| 16 | + | format TEXT NOT NULL, |
|
| 17 | + | lines_found INTEGER NOT NULL, |
|
| 18 | + | lines_hit INTEGER NOT NULL, |
|
| 19 | + | functions_found INTEGER NOT NULL, |
|
| 20 | + | functions_hit INTEGER NOT NULL, |
|
| 21 | + | branches_found INTEGER NOT NULL, |
|
| 22 | + | branches_hit INTEGER NOT NULL, |
|
| 23 | + | files INTEGER NOT NULL, |
|
| 24 | + | -- Files the report named that are not in the run's state. |
|
| 25 | + | dropped INTEGER NOT NULL, |
|
| 26 | + | created_ms INTEGER NOT NULL |
|
| 27 | + | ); |
|
| 28 | + | CREATE INDEX coverage_reports_bookmark ON coverage_reports (repo_id, bookmark, created_ms); |
|
| 29 | + | CREATE TABLE coverage_files ( |
|
| 30 | + | report_id INTEGER NOT NULL REFERENCES coverage_reports(id) ON DELETE CASCADE, |
|
| 31 | + | path TEXT NOT NULL, |
|
| 32 | + | lines_found INTEGER NOT NULL, |
|
| 33 | + | lines_hit INTEGER NOT NULL, |
|
| 34 | + | functions_found INTEGER NOT NULL, |
|
| 35 | + | functions_hit INTEGER NOT NULL, |
|
| 36 | + | branches_found INTEGER NOT NULL, |
|
| 37 | + | branches_hit INTEGER NOT NULL, |
|
| 38 | + | PRIMARY KEY (report_id, path) |
|
| 39 | + | ); |
added crates/hub/src/coverage.rs +391 −0
| 1 | + | //! Coverage reports (oxygenhub#113): Coveralls built into the hub. An |
|
| 2 | + | //! OxygenFlow job that declares an artifact named `coverage` in LCOV |
|
| 3 | + | //! format (what `cargo llvm-cov --lcov`, Istanbul, coverage.py and |
|
| 4 | + | //! gcov/lcov write) has it parsed when it is uploaded. The totals and each |
|
| 5 | + | //! file's counts are kept per run; the per-line counts are read again from |
|
| 6 | + | //! the artifact's blob for the file view. Paths are taken relative to the |
|
| 7 | + | //! job's workspace (`/src`), and only files of the run's state are kept. |
|
| 8 | + | ||
| 9 | + | use crate::db::{now_ms, Pool}; |
|
| 10 | + | use crate::Shared; |
|
| 11 | + | use std::collections::{BTreeMap, HashSet}; |
|
| 12 | + | use std::sync::Mutex; |
|
| 13 | + | ||
| 14 | + | /// The artifact name a pipeline gives its report. |
|
| 15 | + | pub const ARTIFACT: &str = "coverage"; |
|
| 16 | + | /// The largest report parsed. |
|
| 17 | + | pub const MAX_REPORT: usize = 64 << 20; |
|
| 18 | + | ||
| 19 | + | /// One file's coverage, from a report. |
|
| 20 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 21 | + | pub struct FileCoverage { |
|
| 22 | + | /// Relative to the repository's root. |
|
| 23 | + | pub path: String, |
|
| 24 | + | /// Each instrumented line and how often it ran, by line number. |
|
| 25 | + | pub lines: BTreeMap<u32, u64>, |
|
| 26 | + | pub functions_found: u64, |
|
| 27 | + | pub functions_hit: u64, |
|
| 28 | + | pub branches_found: u64, |
|
| 29 | + | pub branches_hit: u64, |
|
| 30 | + | /// The file's own `LF`/`LH` totals, when it had one record: the tool's |
|
| 31 | + | /// own count, which can name lines the `DA` lines leave out (llvm-cov |
|
| 32 | + | /// does), so the hub agrees with what the tool printed. |
|
| 33 | + | pub declared_lines: Option<(u64, u64)>, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | impl FileCoverage { |
|
| 37 | + | pub fn lines_found(&self) -> u64 { |
|
| 38 | + | self.declared_lines.map(|d| d.0).unwrap_or(self.lines.len() as u64) |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | pub fn lines_hit(&self) -> u64 { |
|
| 42 | + | self.declared_lines.map(|d| d.1).unwrap_or_else(|| self.lines.values().filter(|n| **n > 0).count() as u64) |
|
| 43 | + | } |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | /// A path as a report names it, relative to the repository: the Flow |
|
| 47 | + | /// workspace's `/src/` and a leading `./` taken off. `None` for a path |
|
| 48 | + | /// outside the repository. |
|
| 49 | + | pub fn relative(path: &str) -> Option<String> { |
|
| 50 | + | let p = path.trim(); |
|
| 51 | + | let p = p.strip_prefix("/src/").unwrap_or(p); |
|
| 52 | + | let p = p.strip_prefix("./").unwrap_or(p); |
|
| 53 | + | if p.is_empty() || p.starts_with('/') || p.contains('\\') || p.split('/').any(|s| s.is_empty() || s == "." || s == "..") { |
|
| 54 | + | return None; |
|
| 55 | + | } |
|
| 56 | + | Some(p.to_string()) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | #[derive(Default)] |
|
| 60 | + | struct Acc { |
|
| 61 | + | lines: BTreeMap<u32, u64>, |
|
| 62 | + | functions: BTreeMap<String, u64>, |
|
| 63 | + | branches: BTreeMap<(u32, String, String), u64>, |
|
| 64 | + | declared_fn: (u64, u64), |
|
| 65 | + | declared_br: (u64, u64), |
|
| 66 | + | declared_lines: (Option<u64>, Option<u64>), |
|
| 67 | + | records: u32, |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | /// Parse an LCOV report into each file's coverage, records for the same |
|
| 71 | + | /// file (from several test binaries) added together. Files whose paths |
|
| 72 | + | /// are outside the repository are counted in the second value. |
|
| 73 | + | pub fn parse_lcov(text: &str) -> Result<(Vec<FileCoverage>, usize), String> { |
|
| 74 | + | let mut files: BTreeMap<String, Acc> = BTreeMap::new(); |
|
| 75 | + | let mut current: Option<Option<String>> = None; |
|
| 76 | + | let mut outside = HashSet::new(); |
|
| 77 | + | let mut records = 0usize; |
|
| 78 | + | // A new SF: record has begun and not yet been counted for its file. |
|
| 79 | + | let mut started = false; |
|
| 80 | + | let num = |s: &str| s.trim().parse::<u64>().unwrap_or(0); |
|
| 81 | + | for line in text.lines() { |
|
| 82 | + | let line = line.trim(); |
|
| 83 | + | if let Some(path) = line.strip_prefix("SF:") { |
|
| 84 | + | records += 1; |
|
| 85 | + | let rel = relative(path); |
|
| 86 | + | if rel.is_none() { |
|
| 87 | + | outside.insert(path.to_string()); |
|
| 88 | + | } |
|
| 89 | + | current = Some(rel); |
|
| 90 | + | started = true; |
|
| 91 | + | continue; |
|
| 92 | + | } |
|
| 93 | + | if line == "end_of_record" { |
|
| 94 | + | current = None; |
|
| 95 | + | continue; |
|
| 96 | + | } |
|
| 97 | + | let Some(Some(path)) = ¤t else { continue }; |
|
| 98 | + | let acc = files.entry(path.clone()).or_default(); |
|
| 99 | + | if line == "TN:" || line.starts_with("TN:") { |
|
| 100 | + | continue; |
|
| 101 | + | } |
|
| 102 | + | if std::mem::take(&mut started) { |
|
| 103 | + | acc.records += 1; |
|
| 104 | + | } |
|
| 105 | + | if let Some(n) = line.strip_prefix("LF:") { |
|
| 106 | + | acc.declared_lines.0 = Some(num(n)); |
|
| 107 | + | } else if let Some(n) = line.strip_prefix("LH:") { |
|
| 108 | + | acc.declared_lines.1 = Some(num(n)); |
|
| 109 | + | } else if let Some(rest) = line.strip_prefix("DA:") { |
|
| 110 | + | let mut parts = rest.split(','); |
|
| 111 | + | if let (Some(l), Some(n)) = (parts.next(), parts.next()) { |
|
| 112 | + | if let Ok(l) = l.trim().parse::<u32>() { |
|
| 113 | + | *acc.lines.entry(l).or_default() += num(n); |
|
| 114 | + | } |
|
| 115 | + | } |
|
| 116 | + | } else if let Some(rest) = line.strip_prefix("FN:") { |
|
| 117 | + | if let Some((_, name)) = rest.split_once(',') { |
|
| 118 | + | acc.functions.entry(name.to_string()).or_default(); |
|
| 119 | + | } |
|
| 120 | + | } else if let Some(rest) = line.strip_prefix("FNDA:") { |
|
| 121 | + | if let Some((n, name)) = rest.split_once(',') { |
|
| 122 | + | *acc.functions.entry(name.to_string()).or_default() += num(n); |
|
| 123 | + | } |
|
| 124 | + | } else if let Some(rest) = line.strip_prefix("BRDA:") { |
|
| 125 | + | let parts: Vec<&str> = rest.split(',').collect(); |
|
| 126 | + | if let [l, block, branch, taken] = parts[..] { |
|
| 127 | + | if let Ok(l) = l.trim().parse::<u32>() { |
|
| 128 | + | let taken = if taken.trim() == "-" { 0 } else { num(taken) }; |
|
| 129 | + | *acc.branches.entry((l, block.to_string(), branch.to_string())).or_default() += taken; |
|
| 130 | + | } |
|
| 131 | + | } |
|
| 132 | + | } else if let Some(n) = line.strip_prefix("FNF:") { |
|
| 133 | + | acc.declared_fn.0 = acc.declared_fn.0.max(num(n)); |
|
| 134 | + | } else if let Some(n) = line.strip_prefix("FNH:") { |
|
| 135 | + | acc.declared_fn.1 = acc.declared_fn.1.max(num(n)); |
|
| 136 | + | } else if let Some(n) = line.strip_prefix("BRF:") { |
|
| 137 | + | acc.declared_br.0 = acc.declared_br.0.max(num(n)); |
|
| 138 | + | } else if let Some(n) = line.strip_prefix("BRH:") { |
|
| 139 | + | acc.declared_br.1 = acc.declared_br.1.max(num(n)); |
|
| 140 | + | } |
|
| 141 | + | } |
|
| 142 | + | if records == 0 { |
|
| 143 | + | return Err("this is not an LCOV report: it names no source file (no SF: line)".into()); |
|
| 144 | + | } |
|
| 145 | + | let out = files |
|
| 146 | + | .into_iter() |
|
| 147 | + | .map(|(path, a)| { |
|
| 148 | + | let (functions_found, functions_hit) = if a.functions.is_empty() { |
|
| 149 | + | a.declared_fn |
|
| 150 | + | } else { |
|
| 151 | + | (a.functions.len() as u64, a.functions.values().filter(|n| **n > 0).count() as u64) |
|
| 152 | + | }; |
|
| 153 | + | let (branches_found, branches_hit) = if a.branches.is_empty() { |
|
| 154 | + | a.declared_br |
|
| 155 | + | } else { |
|
| 156 | + | (a.branches.len() as u64, a.branches.values().filter(|n| **n > 0).count() as u64) |
|
| 157 | + | }; |
|
| 158 | + | let declared_lines = match (a.records, a.declared_lines) { |
|
| 159 | + | (1, (Some(f), Some(h))) if h <= f && f >= a.lines.len() as u64 => Some((f, h)), |
|
| 160 | + | _ => None, |
|
| 161 | + | }; |
|
| 162 | + | FileCoverage { path, lines: a.lines, functions_found, functions_hit, branches_found, branches_hit, declared_lines } |
|
| 163 | + | }) |
|
| 164 | + | .collect(); |
|
| 165 | + | Ok((out, outside.len())) |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | /// A percentage to one decimal place, or none when nothing was counted. |
|
| 169 | + | pub fn percent(hit: i64, found: i64) -> Option<f64> { |
|
| 170 | + | (found > 0).then(|| (hit as f64 * 1000.0 / found as f64).round() / 10.0) |
|
| 171 | + | } |
|
| 172 | + | ||
| 173 | + | #[derive(Clone, Debug, sqlx::FromRow)] |
|
| 174 | + | pub struct Report { |
|
| 175 | + | pub id: i64, |
|
| 176 | + | pub repo_id: i64, |
|
| 177 | + | pub run_id: i64, |
|
| 178 | + | pub job_id: i64, |
|
| 179 | + | pub job: String, |
|
| 180 | + | pub run_number: i64, |
|
| 181 | + | pub bookmark: String, |
|
| 182 | + | pub state: String, |
|
| 183 | + | pub heads: String, |
|
| 184 | + | pub blob: String, |
|
| 185 | + | pub format: String, |
|
| 186 | + | pub lines_found: i64, |
|
| 187 | + | pub lines_hit: i64, |
|
| 188 | + | pub functions_found: i64, |
|
| 189 | + | pub functions_hit: i64, |
|
| 190 | + | pub branches_found: i64, |
|
| 191 | + | pub branches_hit: i64, |
|
| 192 | + | pub files: i64, |
|
| 193 | + | pub dropped: i64, |
|
| 194 | + | pub created_ms: i64, |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | impl Report { |
|
| 198 | + | pub fn percent(&self) -> Option<f64> { |
|
| 199 | + | percent(self.lines_hit, self.lines_found) |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | pub fn functions_percent(&self) -> Option<f64> { |
|
| 203 | + | percent(self.functions_hit, self.functions_found) |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | pub fn branches_percent(&self) -> Option<f64> { |
|
| 207 | + | percent(self.branches_hit, self.branches_found) |
|
| 208 | + | } |
|
| 209 | + | ||
| 210 | + | pub fn head_list(&self) -> Vec<&str> { |
|
| 211 | + | self.heads.split(',').filter(|h| !h.is_empty()).collect() |
|
| 212 | + | } |
|
| 213 | + | } |
|
| 214 | + | ||
| 215 | + | #[derive(Clone, Debug, sqlx::FromRow)] |
|
| 216 | + | pub struct FileRow { |
|
| 217 | + | pub report_id: i64, |
|
| 218 | + | pub path: String, |
|
| 219 | + | pub lines_found: i64, |
|
| 220 | + | pub lines_hit: i64, |
|
| 221 | + | pub functions_found: i64, |
|
| 222 | + | pub functions_hit: i64, |
|
| 223 | + | pub branches_found: i64, |
|
| 224 | + | pub branches_hit: i64, |
|
| 225 | + | } |
|
| 226 | + | ||
| 227 | + | impl FileRow { |
|
| 228 | + | pub fn percent(&self) -> Option<f64> { |
|
| 229 | + | percent(self.lines_hit, self.lines_found) |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | pub fn missed(&self) -> i64 { |
|
| 233 | + | self.lines_found - self.lines_hit |
|
| 234 | + | } |
|
| 235 | + | } |
|
| 236 | + | ||
| 237 | + | /// Repositories with a report, so their pages show the Coverage tab: |
|
| 238 | + | /// keyed by the hub's repository root and the id, unique even between |
|
| 239 | + | /// hubs in one process. |
|
| 240 | + | static HAS: Mutex<Option<HashSet<String>>> = Mutex::new(None); |
|
| 241 | + | ||
| 242 | + | fn key(app: &Shared, repo_id: i64) -> String { |
|
| 243 | + | format!("{}#{repo_id}", app.repos.root().display()) |
|
| 244 | + | } |
|
| 245 | + | ||
| 246 | + | pub fn has_reports(app: &Shared, repo_id: i64) -> bool { |
|
| 247 | + | HAS.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id))) |
|
| 248 | + | } |
|
| 249 | + | ||
| 250 | + | fn mark(app: &Shared, repo_id: i64) { |
|
| 251 | + | HAS.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(HashSet::new).insert(key(app, repo_id)); |
|
| 252 | + | } |
|
| 253 | + | ||
| 254 | + | /// At start: which repositories have reports. |
|
| 255 | + | pub async fn load(app: &Shared) -> sqlx::Result<()> { |
|
| 256 | + | let ids: Vec<(i64,)> = sqlx::query_as("SELECT DISTINCT repo_id FROM coverage_reports").fetch_all(&app.db).await?; |
|
| 257 | + | for (id,) in ids { |
|
| 258 | + | mark(app, id); |
|
| 259 | + | } |
|
| 260 | + | Ok(()) |
|
| 261 | + | } |
|
| 262 | + | ||
| 263 | + | /// What a run's report is kept with. |
|
| 264 | + | pub struct Origin<'a> { |
|
| 265 | + | pub repo_id: i64, |
|
| 266 | + | pub run_id: i64, |
|
| 267 | + | pub run_number: i64, |
|
| 268 | + | pub job_id: i64, |
|
| 269 | + | pub job: &'a str, |
|
| 270 | + | pub bookmark: &'a str, |
|
| 271 | + | pub state: &'a str, |
|
| 272 | + | pub heads: &'a [String], |
|
| 273 | + | pub blob: &'a str, |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | /// Keep a parsed report: the files outside `in_state` are dropped and |
|
| 277 | + | /// counted. Replaces an earlier report of the same job. |
|
| 278 | + | pub async fn keep(app: &Shared, o: &Origin<'_>, files: Vec<FileCoverage>, outside: usize, in_state: &HashSet<String>) -> sqlx::Result<i64> { |
|
| 279 | + | let (kept, missing): (Vec<FileCoverage>, Vec<FileCoverage>) = files.into_iter().partition(|f| in_state.contains(&f.path)); |
|
| 280 | + | let sum = |f: &dyn Fn(&FileCoverage) -> u64| kept.iter().map(f).sum::<u64>() as i64; |
|
| 281 | + | let mut tx = crate::db::begin_write(&app.db).await?; |
|
| 282 | + | sqlx::query("DELETE FROM coverage_reports WHERE job_id = ?").bind(o.job_id).execute(&mut *tx).await?; |
|
| 283 | + | let id: i64 = sqlx::query_scalar( |
|
| 284 | + | "INSERT INTO coverage_reports (repo_id, run_id, job_id, job, run_number, bookmark, state, heads, blob, format, \ |
|
| 285 | + | lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit, files, dropped, created_ms) \ |
|
| 286 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'lcov', ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", |
|
| 287 | + | ) |
|
| 288 | + | .bind(o.repo_id) |
|
| 289 | + | .bind(o.run_id) |
|
| 290 | + | .bind(o.job_id) |
|
| 291 | + | .bind(o.job) |
|
| 292 | + | .bind(o.run_number) |
|
| 293 | + | .bind(o.bookmark) |
|
| 294 | + | .bind(o.state) |
|
| 295 | + | .bind(o.heads.join(",")) |
|
| 296 | + | .bind(o.blob) |
|
| 297 | + | .bind(sum(&|f| f.lines_found())) |
|
| 298 | + | .bind(sum(&|f| f.lines_hit())) |
|
| 299 | + | .bind(sum(&|f| f.functions_found)) |
|
| 300 | + | .bind(sum(&|f| f.functions_hit)) |
|
| 301 | + | .bind(sum(&|f| f.branches_found)) |
|
| 302 | + | .bind(sum(&|f| f.branches_hit)) |
|
| 303 | + | .bind(kept.len() as i64) |
|
| 304 | + | .bind((missing.len() + outside) as i64) |
|
| 305 | + | .bind(now_ms()) |
|
| 306 | + | .fetch_one(&mut *tx) |
|
| 307 | + | .await?; |
|
| 308 | + | for f in &kept { |
|
| 309 | + | sqlx::query( |
|
| 310 | + | "INSERT INTO coverage_files (report_id, path, lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit) \ |
|
| 311 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?)", |
|
| 312 | + | ) |
|
| 313 | + | .bind(id) |
|
| 314 | + | .bind(&f.path) |
|
| 315 | + | .bind(f.lines_found() as i64) |
|
| 316 | + | .bind(f.lines_hit() as i64) |
|
| 317 | + | .bind(f.functions_found as i64) |
|
| 318 | + | .bind(f.functions_hit as i64) |
|
| 319 | + | .bind(f.branches_found as i64) |
|
| 320 | + | .bind(f.branches_hit as i64) |
|
| 321 | + | .execute(&mut *tx) |
|
| 322 | + | .await?; |
|
| 323 | + | } |
|
| 324 | + | tx.commit().await?; |
|
| 325 | + | mark(app, o.repo_id); |
|
| 326 | + | Ok(id) |
|
| 327 | + | } |
|
| 328 | + | ||
| 329 | + | pub async fn report(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Report>> { |
|
| 330 | + | sqlx::query_as("SELECT * FROM coverage_reports WHERE id = ? AND repo_id = ?").bind(id).bind(repo_id).fetch_optional(pool).await |
|
| 331 | + | } |
|
| 332 | + | ||
| 333 | + | /// The newest reports on a bookmark, newest first. |
|
| 334 | + | pub async fn on_bookmark(pool: &Pool, repo_id: i64, bookmark: &str, limit: i64) -> sqlx::Result<Vec<Report>> { |
|
| 335 | + | sqlx::query_as("SELECT * FROM coverage_reports WHERE repo_id = ? AND bookmark = ? ORDER BY created_ms DESC, id DESC LIMIT ?") |
|
| 336 | + | .bind(repo_id) |
|
| 337 | + | .bind(bookmark) |
|
| 338 | + | .bind(limit) |
|
| 339 | + | .fetch_all(pool) |
|
| 340 | + | .await |
|
| 341 | + | } |
|
| 342 | + | ||
| 343 | + | pub async fn for_run(pool: &Pool, run_id: i64) -> sqlx::Result<Vec<Report>> { |
|
| 344 | + | sqlx::query_as("SELECT * FROM coverage_reports WHERE run_id = ? ORDER BY job").bind(run_id).fetch_all(pool).await |
|
| 345 | + | } |
|
| 346 | + | ||
| 347 | + | pub async fn files(pool: &Pool, report_id: i64) -> sqlx::Result<Vec<FileRow>> { |
|
| 348 | + | sqlx::query_as("SELECT * FROM coverage_files WHERE report_id = ? ORDER BY path").bind(report_id).fetch_all(pool).await |
|
| 349 | + | } |
|
| 350 | + | ||
| 351 | + | #[cfg(test)] |
|
| 352 | + | mod tests { |
|
| 353 | + | use super::*; |
|
| 354 | + | ||
| 355 | + | const LCOV: &str = "TN:\nSF:/src/crates/a/src/lib.rs\nFN:3,alpha\nFN:9,beta\nFNDA:4,alpha\nFNDA:0,beta\nFNF:2\nFNH:1\n\ |
|
| 356 | + | DA:3,4\nDA:4,4\nDA:9,0\nDA:10,0\nBRDA:4,0,0,3\nBRDA:4,0,1,-\nBRF:2\nBRH:1\nLF:4\nLH:2\nend_of_record\n\ |
|
| 357 | + | SF:/src/crates/a/src/lib.rs\nDA:9,1\nend_of_record\n\ |
|
| 358 | + | SF:./README.md\nDA:1,1\nend_of_record\n\ |
|
| 359 | + | SF:/home/runner/.cargo/registry/src/dep/lib.rs\nDA:1,1\nend_of_record\n"; |
|
| 360 | + | ||
| 361 | + | #[test] |
|
| 362 | + | fn lcov_reports_are_read_and_merged() { |
|
| 363 | + | let (files, outside) = parse_lcov(LCOV).unwrap(); |
|
| 364 | + | assert_eq!(outside, 1, "the dependency outside the workspace"); |
|
| 365 | + | assert_eq!(files.len(), 2); |
|
| 366 | + | let lib = &files[1]; |
|
| 367 | + | assert_eq!(lib.path, "crates/a/src/lib.rs"); |
|
| 368 | + | assert_eq!(lib.lines, [(3, 4), (4, 4), (9, 1), (10, 0)].into_iter().collect()); |
|
| 369 | + | assert_eq!((lib.lines_found(), lib.lines_hit()), (4, 3), "the second record ran line 9"); |
|
| 370 | + | assert_eq!((lib.functions_found, lib.functions_hit), (2, 1)); |
|
| 371 | + | assert_eq!((lib.branches_found, lib.branches_hit), (2, 1)); |
|
| 372 | + | assert_eq!(files[0].path, "README.md"); |
|
| 373 | + | assert_eq!(lib.declared_lines, None, "two records: counted from the lines"); |
|
| 374 | + | // One record whose totals name lines its DA lines leave out, as llvm-cov writes. |
|
| 375 | + | let (one, _) = parse_lcov("SF:/src/a.rs\nDA:1,1\nDA:2,0\nLF:5\nLH:3\nend_of_record\n").unwrap(); |
|
| 376 | + | assert_eq!((one[0].lines_found(), one[0].lines_hit()), (5, 3)); |
|
| 377 | + | assert!(parse_lcov("not a report\n").unwrap_err().contains("not an LCOV report")); |
|
| 378 | + | } |
|
| 379 | + | ||
| 380 | + | #[test] |
|
| 381 | + | fn paths_are_taken_relative_to_the_workspace() { |
|
| 382 | + | assert_eq!(relative("/src/a/b.rs").as_deref(), Some("a/b.rs")); |
|
| 383 | + | assert_eq!(relative("./a/b.rs").as_deref(), Some("a/b.rs")); |
|
| 384 | + | assert_eq!(relative("a/b.rs").as_deref(), Some("a/b.rs")); |
|
| 385 | + | for bad in ["/etc/passwd", "a/../b", "", "/src/", "a//b", "C:\\x\\y.rs"] { |
|
| 386 | + | assert_eq!(relative(bad), None, "{bad}"); |
|
| 387 | + | } |
|
| 388 | + | assert_eq!(percent(1, 3), Some(33.3)); |
|
| 389 | + | assert_eq!(percent(0, 0), None); |
|
| 390 | + | } |
|
| 391 | + | } |
added crates/hub/src/web/coverage.rs +270 −0
| 1 | + | //! Coverage pages (oxygenhub#113): a repository's coverage, its trend, |
|
| 2 | + | //! every file, and each file's lines marked run or missed; and a badge. |
|
| 3 | + | ||
| 4 | + | use super::{filters, lookup, Error, Page, RepoShell, Shell}; |
|
| 5 | + | use crate::auth::Viewer; |
|
| 6 | + | use crate::coverage::{self, FileRow, Report}; |
|
| 7 | + | use crate::repos::view::{self, FileBody, RefKind, RefTarget}; |
|
| 8 | + | use crate::Shared; |
|
| 9 | + | use askama::Template; |
|
| 10 | + | use axum::extract::{Path, Query, State}; |
|
| 11 | + | use axum::response::{IntoResponse, Response}; |
|
| 12 | + | use serde::Deserialize; |
|
| 13 | + | use std::collections::BTreeMap; |
|
| 14 | + | use std::sync::{Arc, Mutex}; |
|
| 15 | + | ||
| 16 | + | /// Reports drawn on the trend. |
|
| 17 | + | const TREND: i64 = 30; |
|
| 18 | + | ||
| 19 | + | #[derive(Deserialize, Default)] |
|
| 20 | + | pub struct CoverageQuery { |
|
| 21 | + | /// A bookmark other than the default. |
|
| 22 | + | bookmark: Option<String>, |
|
| 23 | + | /// One report, by id. |
|
| 24 | + | report: Option<i64>, |
|
| 25 | + | /// `missed` (default), `percent` or `path`. |
|
| 26 | + | sort: Option<String>, |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | #[derive(Template)] |
|
| 30 | + | #[template(path = "coverage.html")] |
|
| 31 | + | pub struct CoveragePage { |
|
| 32 | + | shell: Shell, |
|
| 33 | + | repo: RepoShell, |
|
| 34 | + | bookmark: String, |
|
| 35 | + | report: Option<Report>, |
|
| 36 | + | /// Against the report before it on the bookmark, in percentage points. |
|
| 37 | + | delta: Option<f64>, |
|
| 38 | + | files: Vec<FileRow>, |
|
| 39 | + | sort: String, |
|
| 40 | + | trend: String, |
|
| 41 | + | runs: usize, |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | impl CoveragePage { |
|
| 45 | + | pub fn pct(&self, p: Option<f64>) -> String { |
|
| 46 | + | p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()) |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | pub fn delta_text(&self) -> Option<String> { |
|
| 50 | + | self.delta.map(|d| if d.abs() < 0.05 { "unchanged".to_string() } else { format!("{d:+.1}") }) |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | pub fn class_of(&self, p: Option<f64>) -> &'static str { |
|
| 54 | + | level(p) |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | /// A colour class for a percentage. |
|
| 59 | + | pub fn level(p: Option<f64>) -> &'static str { |
|
| 60 | + | match p { |
|
| 61 | + | Some(p) if p >= 90.0 => "cov-good", |
|
| 62 | + | Some(p) if p >= 75.0 => "cov-fair", |
|
| 63 | + | Some(p) if p >= 50.0 => "cov-low", |
|
| 64 | + | Some(_) => "cov-poor", |
|
| 65 | + | None => "cov-none", |
|
| 66 | + | } |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | /// The trend as an SVG line chart, oldest on the left. |
|
| 70 | + | fn trend_svg(reports: &[Report], url: &str) -> String { |
|
| 71 | + | let points: Vec<(f64, &Report)> = reports.iter().rev().filter_map(|r| r.percent().map(|p| (p, r))).collect(); |
|
| 72 | + | if points.len() < 2 { |
|
| 73 | + | return String::new(); |
|
| 74 | + | } |
|
| 75 | + | let (w, h, pad) = (640.0, 120.0, 8.0); |
|
| 76 | + | let lo = points.iter().map(|p| p.0).fold(100.0f64, f64::min).floor().max(0.0); |
|
| 77 | + | let hi = points.iter().map(|p| p.0).fold(0.0f64, f64::max).ceil().min(100.0); |
|
| 78 | + | let (lo, hi) = if hi - lo < 2.0 { ((lo - 1.0).max(0.0), (hi + 1.0).min(100.0)) } else { (lo, hi) }; |
|
| 79 | + | let step = (w - 2.0 * pad) / (points.len() - 1) as f64; |
|
| 80 | + | let y = |p: f64| pad + (hi - p) / (hi - lo) * (h - 2.0 * pad); |
|
| 81 | + | let line: Vec<String> = points.iter().enumerate().map(|(i, (p, _))| format!("{:.1},{:.1}", pad + i as f64 * step, y(*p))).collect(); |
|
| 82 | + | let mut svg = format!( |
|
| 83 | + | r#"<svg class="cov-trend" viewBox="0 0 {w} {h}" role="img" aria-label="Coverage over the last {n} runs, from {first:.1}% to {last:.1}%"><text x="{w}" y="12" text-anchor="end" class="axis">{hi}%</text><text x="{w}" y="{hb}" text-anchor="end" class="axis">{lo}%</text><polyline points="{pts}"/>"#, |
|
| 84 | + | n = points.len(), |
|
| 85 | + | first = points[0].0, |
|
| 86 | + | last = points[points.len() - 1].0, |
|
| 87 | + | hb = h - 2.0, |
|
| 88 | + | pts = line.join(" "), |
|
| 89 | + | ); |
|
| 90 | + | for (i, (p, r)) in points.iter().enumerate() { |
|
| 91 | + | svg.push_str(&format!( |
|
| 92 | + | r#"<a href="{url}/coverage?report={id}"><circle cx="{x:.1}" cy="{y:.1}" r="3.5"><title>Run #{run}: {p:.1}%</title></circle></a>"#, |
|
| 93 | + | id = r.id, |
|
| 94 | + | x = pad + i as f64 * step, |
|
| 95 | + | y = y(*p), |
|
| 96 | + | run = r.run_number, |
|
| 97 | + | )); |
|
| 98 | + | } |
|
| 99 | + | svg.push_str("</svg>"); |
|
| 100 | + | svg |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// `GET /{owner}/{name}/coverage`. |
|
| 104 | + | pub async fn overview( |
|
| 105 | + | State(app): State<Shared>, |
|
| 106 | + | viewer: Viewer, |
|
| 107 | + | Path((owner, name)): Path<(String, String)>, |
|
| 108 | + | Query(q): Query<CoverageQuery>, |
|
| 109 | + | ) -> Result<Page<CoveragePage>, Error> { |
|
| 110 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer); |
|
| 111 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 112 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 113 | + | let report = match q.report { |
|
| 114 | + | Some(id) => Some(coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?), |
|
| 115 | + | None => None, |
|
| 116 | + | }; |
|
| 117 | + | let bookmark = report.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 118 | + | let history = coverage::on_bookmark(&app.db, row.id, &bookmark, TREND).await.map_err(e)?; |
|
| 119 | + | let report = report.or_else(|| history.first().cloned()); |
|
| 120 | + | let delta = report.as_ref().and_then(|r| { |
|
| 121 | + | let before = history.iter().find(|h| h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id))?; |
|
| 122 | + | Some(r.percent()? - before.percent()?) |
|
| 123 | + | }); |
|
| 124 | + | let mut files = match &report { |
|
| 125 | + | Some(r) => coverage::files(&app.db, r.id).await.map_err(e)?, |
|
| 126 | + | None => vec![], |
|
| 127 | + | }; |
|
| 128 | + | let sort = match q.sort.as_deref() { |
|
| 129 | + | Some("percent") => "percent", |
|
| 130 | + | Some("path") => "path", |
|
| 131 | + | _ => "missed", |
|
| 132 | + | }; |
|
| 133 | + | match sort { |
|
| 134 | + | "percent" => files.sort_by(|a, b| a.percent().unwrap_or(101.0).total_cmp(&b.percent().unwrap_or(101.0)).then(a.path.cmp(&b.path))), |
|
| 135 | + | "path" => {} |
|
| 136 | + | _ => files.sort_by(|a, b| b.missed().cmp(&a.missed()).then(a.path.cmp(&b.path))), |
|
| 137 | + | } |
|
| 138 | + | let repo = RepoShell::new(&app, &row, "coverage").with_role(role); |
|
| 139 | + | let trend = trend_svg(&history, &repo.url); |
|
| 140 | + | Ok(Page(CoveragePage { shell, repo, bookmark, report, delta, files, sort: sort.into(), trend, runs: history.len() })) |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | /// One line of a file, as the file view shows it. |
|
| 144 | + | pub struct CovLine { |
|
| 145 | + | pub number: usize, |
|
| 146 | + | pub html: String, |
|
| 147 | + | /// None: not an instrumented line. |
|
| 148 | + | pub hits: Option<u64>, |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | #[derive(Template)] |
|
| 152 | + | #[template(path = "coverage_file.html")] |
|
| 153 | + | pub struct CoverageFilePage { |
|
| 154 | + | shell: Shell, |
|
| 155 | + | repo: RepoShell, |
|
| 156 | + | report: Report, |
|
| 157 | + | file: FileRow, |
|
| 158 | + | lines: Vec<CovLine>, |
|
| 159 | + | note: Option<String>, |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | impl CoverageFilePage { |
|
| 163 | + | pub fn pct(&self, p: Option<f64>) -> String { |
|
| 164 | + | p.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()) |
|
| 165 | + | } |
|
| 166 | + | ||
| 167 | + | pub fn class_of(&self, p: Option<f64>) -> &'static str { |
|
| 168 | + | level(p) |
|
| 169 | + | } |
|
| 170 | + | } |
|
| 171 | + | ||
| 172 | + | /// The line counts of the report last read, by file: the file view reads |
|
| 173 | + | /// the report's artifact again, so that browsing one report's files |
|
| 174 | + | /// parses it once. |
|
| 175 | + | type Parsed = Arc<BTreeMap<String, BTreeMap<u32, u64>>>; |
|
| 176 | + | static LAST: Mutex<Option<(String, Parsed)>> = Mutex::new(None); |
|
| 177 | + | ||
| 178 | + | async fn line_counts(app: &Shared, repo: &crate::repos::RepoName, blob: &str) -> Option<Parsed> { |
|
| 179 | + | if let Some((b, p)) = LAST.lock().ok()?.as_ref() { |
|
| 180 | + | if b == blob { |
|
| 181 | + | return Some(p.clone()); |
|
| 182 | + | } |
|
| 183 | + | } |
|
| 184 | + | let r = oxy_core::hash::ObjectRef::from_hex(blob)?; |
|
| 185 | + | let bytes = app.repos.read(repo, move |repo, _| Ok(repo.read_blob(&r)?)).await.ok()?; |
|
| 186 | + | let parsed = tokio::task::spawn_blocking(move || { |
|
| 187 | + | let text = String::from_utf8_lossy(&bytes); |
|
| 188 | + | coverage::parse_lcov(&text).ok().map(|(files, _)| files.into_iter().map(|f| (f.path, f.lines)).collect::<BTreeMap<_, _>>()) |
|
| 189 | + | }) |
|
| 190 | + | .await |
|
| 191 | + | .ok()??; |
|
| 192 | + | let parsed = Arc::new(parsed); |
|
| 193 | + | if let Ok(mut last) = LAST.lock() { |
|
| 194 | + | *last = Some((blob.to_string(), parsed.clone())); |
|
| 195 | + | } |
|
| 196 | + | Some(parsed) |
|
| 197 | + | } |
|
| 198 | + | ||
| 199 | + | /// `GET /{owner}/{name}/coverage/{report}/file/{*path}`. |
|
| 200 | + | pub async fn file( |
|
| 201 | + | State(app): State<Shared>, |
|
| 202 | + | viewer: Viewer, |
|
| 203 | + | Path((owner, name, id, path)): Path<(String, String, i64, String)>, |
|
| 204 | + | ) -> Result<Page<CoverageFilePage>, Error> { |
|
| 205 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage/{id}/file/{path}"), &viewer); |
|
| 206 | + | let (repo_name, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 207 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 208 | + | let report = coverage::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 209 | + | let file = coverage::files(&app.db, report.id) |
|
| 210 | + | .await |
|
| 211 | + | .map_err(e)? |
|
| 212 | + | .into_iter() |
|
| 213 | + | .find(|f| f.path == path) |
|
| 214 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 215 | + | let counts = line_counts(&app, &repo_name, &report.blob).await.and_then(|p| p.get(&path).cloned()).unwrap_or_default(); |
|
| 216 | + | let heads: Vec<_> = report.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect(); |
|
| 217 | + | let limit = app.config.limits.render_limit.0; |
|
| 218 | + | let p = path.clone(); |
|
| 219 | + | let target = RefTarget { label: report.state.clone(), kind: RefKind::Change, heads }; |
|
| 220 | + | let fv = app |
|
| 221 | + | .repos |
|
| 222 | + | .read(&repo_name, move |r, cache| view::file(r, cache, target, &p, limit)) |
|
| 223 | + | .await |
|
| 224 | + | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 225 | + | let (lines, note) = match &fv.body { |
|
| 226 | + | FileBody::Text(text) => { |
|
| 227 | + | let html = crate::render::highlight::lines(&path, text); |
|
| 228 | + | let lines = html |
|
| 229 | + | .into_iter() |
|
| 230 | + | .enumerate() |
|
| 231 | + | .map(|(i, h)| CovLine { number: i + 1, html: h, hits: counts.get(&(i as u32 + 1)).copied() }) |
|
| 232 | + | .collect(); |
|
| 233 | + | (lines, None) |
|
| 234 | + | } |
|
| 235 | + | FileBody::Binary => (vec![], Some("This file is binary.".to_string())), |
|
| 236 | + | FileBody::TooLarge => (vec![], Some("This file is too large to show.".to_string())), |
|
| 237 | + | }; |
|
| 238 | + | let repo = RepoShell::new(&app, &row, "coverage").with_role(role); |
|
| 239 | + | Ok(Page(CoverageFilePage { shell, repo, report, file, lines, note })) |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | /// `GET /{owner}/{name}/coverage/badge.svg`: the default bookmark's latest coverage. |
|
| 243 | + | pub async fn badge(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Response, Error> { |
|
| 244 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/coverage"), &viewer); |
|
| 245 | + | let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 246 | + | let latest = coverage::on_bookmark(&app.db, row.id, &row.default_bookmark, 1).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 247 | + | let pct = latest.first().and_then(|r| r.percent()); |
|
| 248 | + | let text = pct.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "unknown".into()); |
|
| 249 | + | let colour = match level(pct) { |
|
| 250 | + | "cov-good" => "#2da44e", |
|
| 251 | + | "cov-fair" => "#97ca00", |
|
| 252 | + | "cov-low" => "#dfb317", |
|
| 253 | + | "cov-poor" => "#e05d44", |
|
| 254 | + | _ => "#6e7781", |
|
| 255 | + | }; |
|
| 256 | + | let label = "coverage"; |
|
| 257 | + | let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10); |
|
| 258 | + | let svg = format!( |
|
| 259 | + | r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" aria-label="{label}: {text}"><title>{label}: {text}</title><rect width="{lw}" height="20" rx="3" fill="#555"/><rect x="{lw}" width="{tw}" height="20" rx="3" fill="{colour}"/><rect x="{lw}" width="4" height="20" fill="{colour}"/><g fill="#fff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle"><text x="{lx}" y="14">{label}</text><text x="{tx}" y="14">{text}</text></g></svg>"##, |
|
| 260 | + | w = lw + tw, |
|
| 261 | + | lx = lw / 2, |
|
| 262 | + | tx = lw + tw / 2, |
|
| 263 | + | ); |
|
| 264 | + | Ok(( |
|
| 265 | + | [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")], |
|
| 266 | + | svg, |
|
| 267 | + | ) |
|
| 268 | + | .into_response()) |
|
| 269 | + | } |
|
| 270 | + |
added templates/coverage.html +47 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Coverage · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block tabs %}{% call m::tabs(repo, "coverage") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 6 | + | <div class="head"><h1>Coverage <small>of {{ bookmark }}</small></h1></div> |
|
| 7 | + | {% if let Some(r) = report %} |
|
| 8 | + | <section class="cov-summary"> |
|
| 9 | + | <div class="cov-big {{ self.class_of(r.percent()) }}">{{ self.pct(r.percent()) }}{% if let Some(d) = self.delta_text() %} <span class="cov-delta" title="Against the run before it on {{ bookmark }}">{{ d }}</span>{% endif %}</div> |
|
| 10 | + | <dl class="facts"> |
|
| 11 | + | <dt>Lines</dt><dd>{{ r.lines_hit }} of {{ r.lines_found }} run</dd> |
|
| 12 | + | {% if r.functions_found > 0 %}<dt>Functions</dt><dd>{{ self.pct(r.functions_percent()) }} ({{ r.functions_hit }} of {{ r.functions_found }})</dd>{% endif %} |
|
| 13 | + | {% if r.branches_found > 0 %}<dt>Branches</dt><dd>{{ self.pct(r.branches_percent()) }} ({{ r.branches_hit }} of {{ r.branches_found }})</dd>{% endif %} |
|
| 14 | + | <dt>From</dt><dd><a href="{{ repo.url }}/flow/{{ r.run_number }}">run #{{ r.run_number }}</a>, job {{ r.job }}, <time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></dd> |
|
| 15 | + | <dt>Files</dt><dd>{{ r.files }}{% if r.dropped > 0 %} <span class="mute small">({{ r.dropped }} named in the report are not in this state and are left out)</span>{% endif %}</dd> |
|
| 16 | + | <dt>Badge</dt><dd><img src="{{ repo.url }}/coverage/badge.svg" alt="coverage badge"> <code class="small">[]({{ shell.base_url }}{{ repo.url }}/coverage)</code></dd> |
|
| 17 | + | </dl> |
|
| 18 | + | </section> |
|
| 19 | + | {% if !trend.is_empty() %}<section class="panel"><h2>The last {{ runs }} runs</h2>{{ trend|safe }}</section>{% endif %} |
|
| 20 | + | <section class="panel"> |
|
| 21 | + | <h2>Files <small class="mute">sorted by {% if sort == "missed" %}lines missed{% else if sort == "percent" %}coverage, lowest first{% else %}path{% endif %}: |
|
| 22 | + | {% if sort != "missed" %}<a href="?report={{ r.id }}&sort=missed">lines missed</a> {% endif %}{% if sort != "percent" %}<a href="?report={{ r.id }}&sort=percent">lowest first</a> {% endif %}{% if sort != "path" %}<a href="?report={{ r.id }}&sort=path">path</a>{% endif %}</small></h2> |
|
| 23 | + | <div class="table"><table class="cov-files"> |
|
| 24 | + | <tr><th>File</th><th>Coverage</th><th class="num">Lines</th><th class="num">Missed</th></tr> |
|
| 25 | + | {% for f in files %}<tr> |
|
| 26 | + | <td><a href="{{ repo.url }}/coverage/{{ r.id }}/file/{{ f.path }}">{{ f.path }}</a></td> |
|
| 27 | + | <td><span class="cov-bar {{ self.class_of(f.percent()) }}" role="img" aria-label="{{ self.pct(f.percent()) }}"><span style="width: {% if let Some(p) = f.percent() %}{{ p }}{% else %}0{% endif %}%"></span></span> {{ self.pct(f.percent()) }}</td> |
|
| 28 | + | <td class="num">{{ f.lines_found }}</td> |
|
| 29 | + | <td class="num">{{ f.missed() }}</td> |
|
| 30 | + | </tr>{% endfor %} |
|
| 31 | + | </table></div> |
|
| 32 | + | </section> |
|
| 33 | + | {% else %} |
|
| 34 | + | <section class="panel"> |
|
| 35 | + | <p>No coverage report for <strong>{{ bookmark }}</strong> yet.</p> |
|
| 36 | + | <p>Give a job in <code>.flow/pipeline.kdl</code> an artifact named <code>coverage</code> holding an LCOV report, and the hub reads it when the job uploads it. For Rust:</p> |
|
| 37 | + | <pre>job "coverage" { |
|
| 38 | + | sandbox "rust" |
|
| 39 | + | exec "cargo" "llvm-cov" "--workspace" "--lcov" "--output-path" "/src/target/lcov.info" |
|
| 40 | + | output { |
|
| 41 | + | artifact "coverage" { path "/src/target/lcov.info" } |
|
| 42 | + | } |
|
| 43 | + | }</pre> |
|
| 44 | + | <p class="small mute">Istanbul (<code>--reporter=lcov</code>), coverage.py (<code>coverage lcov</code>) and gcov with lcov write LCOV too. Paths are read relative to the job's <code>/src</code>.</p> |
|
| 45 | + | </section> |
|
| 46 | + | {% endif %} |
|
| 47 | + | {% endblock %} |
added templates/coverage_file.html +14 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}{{ file.path }} · Coverage · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block tabs %}{% call m::tabs(repo, "coverage") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 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> |
|
| 8 | + | {% if let Some(n) = note %}<p class="mute">{{ n }}</p>{% endif %} |
|
| 9 | + | {% if !lines.is_empty() %} |
|
| 10 | + | <table class="code cov-code"> |
|
| 11 | + | {% for l in lines %}<tr id="L{{ l.number }}" class="{% if let Some(h) = l.hits %}{% if *h > 0 %}cov-hit{% else %}cov-miss{% endif %}{% endif %}"><td class="n">{{ l.number }}</td><td class="cov-hits">{% if let Some(h) = l.hits %}{{ h }}×{% endif %}</td><td class="t"><pre>{{ l.html|safe }}</pre></td></tr> |
|
| 12 | + | {% endfor %}</table> |
|
| 13 | + | {% endif %} |
|
| 14 | + | {% endblock %} |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **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, a trend over the last 30 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 | + | ||
| 7 | 9 | - **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. |
|
| 8 | 10 | ||
| 9 | 11 | - **Mermaid diagrams in Markdown.** A ```` ```mermaid ```` block in a README, wiki page, issue or proposal is drawn as a diagram, in the page's light or dark theme. The hub serves its own copy of Mermaid 12.1.0, loads it only on pages that have a diagram, and runs it in strict mode. A diagram that does not parse stays as its source, with the reason under it. |
|
| … | 127 unchanged lines |
modified crates/hub/src/flow.rs +60 −0
| … | 941 unchanged lines |
||
| 942 | 942 | let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?; |
|
| 943 | 943 | let content = blake3::hash(&body).to_hex().to_string(); |
|
| 944 | 944 | let size = body.len() as i64; |
|
| 945 | + | let report_bytes = (name == crate::coverage::ARTIFACT).then(|| body.clone()); |
|
| 945 | 946 | let blob = app |
|
| 946 | 947 | .repos |
|
| 947 | 948 | .write(&repo, move |r, _| Ok(r.store_blob(&body)?)) |
|
| … | 1 unchanged lines |
||
| 949 | 950 | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?; |
|
| 950 | 951 | let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() }; |
|
| 951 | 952 | fdb::add_artifact(&app.db, &a).await?; |
|
| 953 | + | // A coverage report (#113) is read now; what it says, or why it could |
|
| 954 | + | // not be read, goes in the job's log. It never fails the upload. |
|
| 955 | + | if let Some(bytes) = report_bytes { |
|
| 956 | + | let said = match read_coverage(&app, &repo, &row, &run, &job, &a.blob, bytes).await { |
|
| 957 | + | Ok(text) | Err(text) => text, |
|
| 958 | + | }; |
|
| 959 | + | let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said }; |
|
| 960 | + | fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok(); |
|
| 961 | + | } |
|
| 952 | 962 | Ok(Json(serde_json::json!({ "blob": a.blob })).into_response()) |
|
| 963 | + | } |
|
| 964 | + | ||
| 965 | + | /// Parse and keep a job's coverage report: what it says, or why not. |
|
| 966 | + | async fn read_coverage( |
|
| 967 | + | app: &Shared, |
|
| 968 | + | repo: &RepoName, |
|
| 969 | + | row: &db::repos::RepoRow, |
|
| 970 | + | run: &fdb::Run, |
|
| 971 | + | job: &fdb::Job, |
|
| 972 | + | blob: &str, |
|
| 973 | + | bytes: axum::body::Bytes, |
|
| 974 | + | ) -> Result<String, String> { |
|
| 975 | + | if bytes.len() > crate::coverage::MAX_REPORT { |
|
| 976 | + | return Err(format!( |
|
| 977 | + | "coverage: the report is {} MiB; at most {} MiB is read", |
|
| 978 | + | bytes.len() >> 20, |
|
| 979 | + | crate::coverage::MAX_REPORT >> 20 |
|
| 980 | + | )); |
|
| 981 | + | } |
|
| 982 | + | let text = String::from_utf8(bytes.to_vec()).map_err(|_| "coverage: the report is not text (LCOV is expected)".to_string())?; |
|
| 983 | + | let (files, outside) = tokio::task::spawn_blocking(move || crate::coverage::parse_lcov(&text)) |
|
| 984 | + | .await |
|
| 985 | + | .map_err(|e| format!("coverage: {e}"))? |
|
| 986 | + | .map_err(|e| format!("coverage: {e}"))?; |
|
| 987 | + | let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect(); |
|
| 988 | + | let in_state: std::collections::HashSet<String> = app |
|
| 989 | + | .repos |
|
| 990 | + | .read(repo, move |r, cache| Ok(cache.load(r, &heads)?.snapshot.files.keys().cloned().collect())) |
|
| 991 | + | .await |
|
| 992 | + | .map_err(|e| format!("coverage: the run's files could not be read: {e}"))?; |
|
| 993 | + | let heads: Vec<String> = run.head_list().into_iter().map(String::from).collect(); |
|
| 994 | + | let origin = crate::coverage::Origin { |
|
| 995 | + | repo_id: row.id, |
|
| 996 | + | run_id: run.id, |
|
| 997 | + | run_number: run.number, |
|
| 998 | + | job_id: job.id, |
|
| 999 | + | job: &job.name, |
|
| 1000 | + | bookmark: &run.bookmark, |
|
| 1001 | + | state: &run.state, |
|
| 1002 | + | heads: &heads, |
|
| 1003 | + | blob, |
|
| 1004 | + | }; |
|
| 1005 | + | let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?; |
|
| 1006 | + | let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else { |
|
| 1007 | + | return Err("coverage: the report was not kept".into()); |
|
| 1008 | + | }; |
|
| 1009 | + | let pct = r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into()); |
|
| 1010 | + | let dropped = |
|
| 1011 | + | if r.dropped > 0 { format!(" ({} named files are not in this state and were left out)", r.dropped) } else { String::new() }; |
|
| 1012 | + | Ok(format!("coverage: {pct} of lines ({} of {}) in {} files{dropped}", r.lines_hit, r.lines_found, r.files)) |
|
| 953 | 1013 | } |
|
| 954 | 1014 | ||
| 955 | 1015 | /// `POST /api/v1/flow/jobs/{id}/finish`. |
|
| … | 440 unchanged lines |
modified crates/hub/src/lib.rs +2 −0
| … | 7 unchanged lines |
||
| 8 | 8 | pub mod assistants; |
|
| 9 | 9 | pub mod auth; |
|
| 10 | 10 | pub mod config; |
|
| 11 | + | pub mod coverage; |
|
| 11 | 12 | pub mod db; |
|
| 12 | 13 | pub mod events; |
|
| 13 | 14 | pub mod flow; |
|
| … | 99 unchanged lines |
||
| 113 | 114 | }); |
|
| 114 | 115 | *slot.lock().unwrap() = Some(Arc::downgrade(&app)); |
|
| 115 | 116 | storage::load(&app).await?; |
|
| 117 | + | coverage::load(&app).await?; |
|
| 116 | 118 | tokio::spawn(hooks::worker(app.clone())); |
|
| 117 | 119 | tokio::spawn(flow::reaper(app.clone())); |
|
| 118 | 120 | tokio::spawn(flow::scheduler(app.clone())); |
|
| … | 46 unchanged lines |
modified crates/hub/src/web/flow.rs +8 −1
| … | 286 unchanged lines |
||
| 287 | 287 | pub duration: String, |
|
| 288 | 288 | /// The last line shown; the live view asks for what follows. |
|
| 289 | 289 | pub last_seq: i64, |
|
| 290 | + | /// The job's coverage report, when it uploaded one (#113): its id and percentage. |
|
| 291 | + | pub coverage: Option<(i64, String)>, |
|
| 290 | 292 | } |
|
| 291 | 293 | ||
| 292 | 294 | #[derive(Template)] |
|
| … | 30 unchanged lines |
||
| 323 | 325 | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 324 | 326 | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 325 | 327 | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 328 | + | let reports = crate::coverage::for_run(&app.db, run.id).await.unwrap_or_default(); |
|
| 326 | 329 | let mut logs = Vec::new(); |
|
| 327 | 330 | for j in &jobs { |
|
| 328 | 331 | let lines = fdb::log(&app.db, j.id, 0, PAGE_LINES).await.unwrap_or_default(); |
|
| 329 | 332 | let last_seq = lines.last().map(|l| l.seq).unwrap_or(0); |
|
| 330 | 333 | let artifacts = fdb::artifacts(&app.db, j.id).await.unwrap_or_default(); |
|
| 331 | - | logs.push(JobLog { duration: crate::flow::duration(j), j: j.clone(), artifacts, lines, last_seq }); |
|
| 334 | + | let coverage = reports |
|
| 335 | + | .iter() |
|
| 336 | + | .find(|r| r.job_id == j.id) |
|
| 337 | + | .map(|r| (r.id, r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into()))); |
|
| 338 | + | logs.push(JobLog { duration: crate::flow::duration(j), j: j.clone(), artifacts, lines, last_seq, coverage }); |
|
| 332 | 339 | } |
|
| 333 | 340 | let duration = match run.finished_ms { |
|
| 334 | 341 | Some(f) => crate::flow::human_ms(f - run.created_ms), |
|
| … | 388 unchanged lines |
modified crates/hub/src/web/mod.rs +7 −0
| … | 6 unchanged lines |
||
| 7 | 7 | pub(crate) mod assistants; |
|
| 8 | 8 | mod avatar; |
|
| 9 | 9 | pub mod chain; |
|
| 10 | + | pub mod coverage; |
|
| 10 | 11 | mod device; |
|
| 11 | 12 | pub mod edit; |
|
| 12 | 13 | pub mod filters; |
|
| … | 201 unchanged lines |
||
| 214 | 215 | .route("/{owner}/{name}/newfile/{r}", get(edit::new_form)) |
|
| 215 | 216 | .route("/{owner}/{name}/flow", get(flow::runs)) |
|
| 216 | 217 | .route("/{owner}/{name}/flow/badge.svg", get(flow::badge)) |
|
| 218 | + | .route("/{owner}/{name}/coverage", get(coverage::overview)) |
|
| 219 | + | .route("/{owner}/{name}/coverage/badge.svg", get(coverage::badge)) |
|
| 220 | + | .route("/{owner}/{name}/coverage/{id}/file/{*path}", get(coverage::file)) |
|
| 217 | 221 | .route("/{owner}/{name}/flow/{id}", get(flow::run)) |
|
| 218 | 222 | .route("/{owner}/{name}/flow/{id}/live", get(flow::live)) |
|
| 219 | 223 | .route("/{owner}/{name}/flow/start", post(flow::start)) |
|
| … | 187 unchanged lines |
||
| 407 | 411 | pub wiki: bool, |
|
| 408 | 412 | /// The bucket its data lives in, when not this hub's disk (#96). |
|
| 409 | 413 | pub stored_in: Option<String>, |
|
| 414 | + | /// Whether it has coverage reports, for the Coverage tab (#113). |
|
| 415 | + | pub coverage: bool, |
|
| 410 | 416 | } |
|
| 411 | 417 | ||
| 412 | 418 | impl RepoShell { |
|
| … | 13 unchanged lines |
||
| 426 | 432 | archived: row.archived, |
|
| 427 | 433 | wiki: crate::repos::RepoName::new(&row.owner, &row.name).is_some_and(|n| app.wikis.exists(&n)), |
|
| 428 | 434 | stored_in: crate::storage::location(app, &row.owner, &row.name), |
|
| 435 | + | coverage: crate::coverage::has_reports(app, row.id), |
|
| 429 | 436 | } |
|
| 430 | 437 | } |
|
| 431 | 438 | ||
| … | 276 unchanged lines |
modified crates/hub/tests/flow.rs +59 −0
| … | 2125 unchanged lines |
||
| 2126 | 2126 | assert_eq!((declined[0].job_id, declined[0].reason.as_str()), (job, "rootfs 36fc4e323804 is not on this runner")); |
|
| 2127 | 2127 | assert_eq!(r.tier_list(), vec!["microvm"]); |
|
| 2128 | 2128 | } |
|
| 2129 | + | ||
| 2130 | + | /// oxygenhub#113: a job's artifact named `coverage` (LCOV) is read when |
|
| 2131 | + | /// it is uploaded. The job's log says what it found; the Coverage tab, |
|
| 2132 | + | /// the file view, the badge and the run page show it. |
|
| 2133 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 2134 | + | async fn a_coverage_artifact_becomes_a_coverage_report() { |
|
| 2135 | + | let h = hub().await; |
|
| 2136 | + | create(&h.app, "andy", "cov").await; |
|
| 2137 | + | let url = format!("{}/andy/cov", h.base); |
|
| 2138 | + | let work = tempfile::tempdir().unwrap(); |
|
| 2139 | + | let c = client(&url, work.path()); |
|
| 2140 | + | write( |
|
| 2141 | + | work.path(), |
|
| 2142 | + | ".flow/pipeline.kdl", |
|
| 2143 | + | &format!( |
|
| 2144 | + | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"coverage\" {{\n sandbox \"alpine\"\n exec \"make\" \"coverage\"\n output {{\n artifact \"coverage\" {{\n path \"/src/lcov.info\"\n }}\n }}\n}}\n" |
|
| 2145 | + | ), |
|
| 2146 | + | ); |
|
| 2147 | + | write(work.path(), "src/lib.rs", "pub fn add(a: u32, b: u32) -> u32 {\n a + b\n}\n\npub fn unused() {\n todo!()\n}\n"); |
|
| 2148 | + | c.snapshot("test").unwrap(); |
|
| 2149 | + | c.commit("Code and coverage", &[]).unwrap(); |
|
| 2150 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2151 | + | let run = runs_of(&h.app, "andy", "cov").await.remove(0); |
|
| 2152 | + | assert!(!call(&h.app, "GET", "/andy/cov", None, None).await.1.contains(">Coverage</a>"), "no tab before a report"); |
|
| 2153 | + | let id = claim(&h.app).await.unwrap()["job_id"].as_i64().unwrap(); |
|
| 2154 | + | ||
| 2155 | + | let (st, text) = upload(&h.app, id, "coverage", b"this is not a report").await; |
|
| 2156 | + | assert_eq!(st, StatusCode::OK, "a bad report never fails the upload: {text}"); |
|
| 2157 | + | let lcov = "TN:\nSF:/src/src/lib.rs\nFN:1,add\nFN:5,unused\nFNDA:3,add\nFNDA:0,unused\nDA:1,3\nDA:2,3\nDA:5,0\nDA:6,0\nend_of_record\n\ |
|
| 2158 | + | SF:/src/src/gone.rs\nDA:1,1\nend_of_record\nSF:/usr/lib/rustlib/x.rs\nDA:1,1\nend_of_record\n"; |
|
| 2159 | + | let (st, text) = upload(&h.app, id, "coverage", lcov.as_bytes()).await; |
|
| 2160 | + | assert_eq!(st, StatusCode::OK, "{text}"); |
|
| 2161 | + | let log: Vec<String> = db::flow::log(&h.app.db, id, 0, 50).await.unwrap().into_iter().map(|l| l.text).collect(); |
|
| 2162 | + | assert!(log.iter().any(|l| l.contains("coverage: this is not an LCOV report")), "{log:?}"); |
|
| 2163 | + | assert!( |
|
| 2164 | + | log.iter().any(|l| l == "coverage: 50.0% of lines (2 of 4) in 1 files (2 named files are not in this state and were left out)"), |
|
| 2165 | + | "{log:?}" |
|
| 2166 | + | ); |
|
| 2167 | + | ||
| 2168 | + | let (st, page) = call(&h.app, "GET", "/andy/cov/coverage", None, None).await; |
|
| 2169 | + | assert_eq!(st, StatusCode::OK); |
|
| 2170 | + | assert!(page.contains(">Coverage</a>") && page.contains("50.0%") && page.contains("2 of 4 run"), "{page}"); |
|
| 2171 | + | assert!(page.contains("1 of 2") && page.contains("/andy/cov/coverage/") && page.contains("/file/src/lib.rs"), "{page}"); |
|
| 2172 | + | let report = crate_report(&h.app, run.id).await; |
|
| 2173 | + | let (st, file) = call(&h.app, "GET", &format!("/andy/cov/coverage/{report}/file/src/lib.rs"), None, None).await; |
|
| 2174 | + | assert_eq!(st, StatusCode::OK, "{file}"); |
|
| 2175 | + | assert_eq!(file.matches("class=\"cov-hit\"").count(), 2, "{file}"); |
|
| 2176 | + | assert_eq!(file.matches("class=\"cov-miss\"").count(), 2, "{file}"); |
|
| 2177 | + | assert!(file.contains("3×"), "{file}"); |
|
| 2178 | + | assert_eq!(call(&h.app, "GET", &format!("/andy/cov/coverage/{report}/file/src/gone.rs"), None, None).await.0, StatusCode::NOT_FOUND); |
|
| 2179 | + | let (_, badge) = call(&h.app, "GET", "/andy/cov/coverage/badge.svg", None, None).await; |
|
| 2180 | + | assert!(badge.contains("coverage: 50.0%"), "{badge}"); |
|
| 2181 | + | let (_, run_page) = call(&h.app, "GET", &format!("/andy/cov/flow/{}", run.number), None, None).await; |
|
| 2182 | + | assert!(run_page.contains("coverage 50.0%"), "{run_page}"); |
|
| 2183 | + | } |
|
| 2184 | + | ||
| 2185 | + | async fn crate_report(app: &Shared, run_id: i64) -> i64 { |
|
| 2186 | + | oxygenhub::coverage::for_run(&app.db, run_id).await.unwrap()[0].id |
|
| 2187 | + | } |
modified docs/spec.md +10 −0
| … | 579 unchanged lines |
||
| 580 | 580 | - **Masking.** Log intake replaces any token this hub signed (recognised by its `kid`) with `***`, as it does secret values. |
|
| 581 | 581 | - **Trust, for example.** AWS: an IAM OIDC provider with the hub's URL and audience `sts.amazonaws.com`, a role whose trust policy matches `sub` `repo:andymccall/metalgear:bookmark:main`; the job then calls `aws sts assume-role-with-web-identity --web-identity-token file:///run/secrets/aws-token`. Google: a workload identity pool provider with the same issuer, mapping `assertion.sub`. HashiCorp Vault: the JWT auth method with `bound_audiences` and `bound_claims` on `repository`. |
|
| 582 | 582 | ||
| 583 | + | ### 13.6 Coverage reports (oxygenhub#113, first part done 2026-10-05) |
|
| 584 | + | ||
| 585 | + | Coverage is built in, as Coveralls or Codecov would be, fed by OxygenFlow. |
|
| 586 | + | ||
| 587 | + | - **Reports.** A job's artifact named `coverage` in LCOV format (`cargo llvm-cov --lcov`, Istanbul's `lcov` reporter, `coverage lcov`, gcov with lcov) is read when the job uploads it. The hub writes what it found into the job's log (`coverage: 88.6% of lines (951 of 1073) in 5 files`), or why it could not read it; a bad report never fails the upload. At most 64 MiB is read. |
|
| 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 | + | - **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 | + | - **Pages.** A Coverage tab, shown once a repository has a report: the bookmark's latest percentage with its change against the report before, a trend of the last 30 runs, 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). |
|
| 592 | + | ||
| 583 | 593 | ## 14. Git interoperability |
|
| 584 | 594 | ||
| 585 | 595 | A repository may enable a **Git mirror**: on every landing on selected bookmarks the hub runs `oxy git export` into `<data>/mirrors/<owner>/<name>.git` and serves it read-only over Git's smart HTTP protocol at `/{owner}/{name}.git` (implemented with the `git` binary's `http-backend` behind the hub's authorization, since reimplementing pack negotiation is not the product). This lets GitHub Actions runners, IDEs and tooling that only speak Git read hosted Oxygen repositories. Pushing to a mirror is refused with a message pointing at `oxy`. |
|
| … | 144 unchanged lines |
modified templates/flow_run.html +1 −1
| … | 31 unchanged lines |
||
| 32 | 32 | </section> |
|
| 33 | 33 | {% for l in logs %} |
|
| 34 | 34 | <details class="panel flow-job st-{{ l.j.status }}" id="job-{{ l.j.id }}" data-job="{{ l.j.id }}"{% if l.j.status == "failed" || l.j.status == "error" || l.j.status == "running" %} open{% endif %}> |
|
| 35 | - | <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}</summary> |
|
| 35 | + | <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}{% if let Some((cid, pct)) = l.coverage %} <span class="small">· <a href="{{ repo.url }}/coverage?report={{ cid }}">coverage {{ pct }}</a></span>{% endif %}</summary> |
|
| 36 | 36 | {% if !l.j.error.is_empty() %}<div class="strip bad">{{ l.j.error }}</div>{% endif %} |
|
| 37 | 37 | {% if !l.artifacts.is_empty() %}<ul class="flow-artifacts small">{% for a in l.artifacts %}<li><a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}">{{ a.name }}</a> <span class="mute">{{ a.path }} · {{ a.size|bytes }} · blob <code title="{{ a.blob }}">{{ a.blob|short }}</code></span>{% if !releases.is_empty() %} <form class="tiny inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}/attach"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="release" aria-label="Release to attach {{ a.name }} to">{% for r in releases %}<option value="{{ r.id }}">{{ r.tag }}</option>{% endfor %}</select> <button>Attach to release</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 38 | 38 | <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span> |
|
| … | 7 unchanged lines |
modified templates/repo_tabs.html +1 −0
| … | 7 unchanged lines |
||
| 8 | 8 | <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on" aria-current="page"{% endif %}>Issues{% if repo.open_issues > 0 %} <span class="count-chip">{{ repo.open_issues }}</span>{% endif %}</a> |
|
| 9 | 9 | {% if repo.wiki || repo.can(crate::auth::Access::Write) %}<a href="{{ repo.url }}/wiki"{% if tab == "wiki" %} class="on" aria-current="page"{% endif %}>Wiki</a>{% endif %} |
|
| 10 | 10 | <a href="{{ repo.url }}/flow"{% if tab == "flow" %} class="on" aria-current="page"{% endif %}>Flow</a> |
|
| 11 | + | {% if repo.coverage %}<a href="{{ repo.url }}/coverage"{% if tab == "coverage" %} class="on" aria-current="page"{% endif %}>Coverage</a>{% endif %} |
|
| 11 | 12 | <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on" aria-current="page"{% endif %}>Bookmarks</a> |
|
| 12 | 13 | <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on" aria-current="page"{% endif %}>Releases</a> |
|
| 13 | 14 | <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on" aria-current="page"{% endif %}>Tags</a> |
|
| … | 62 unchanged lines |
modified templates/style.css +17 −0
| … | 808 unchanged lines |
||
| 809 | 809 | /* Mermaid diagrams drawn from ```mermaid blocks (assets/hub.js). */ |
|
| 810 | 810 | figure.mermaid-diagram { margin: 1em 0; overflow-x: auto; text-align: center; } |
|
| 811 | 811 | figure.mermaid-diagram svg { max-width: 100%; height: auto; } |
|
| 812 | + | ||
| 813 | + | /* Coverage (#113). */ |
|
| 814 | + | .cov-summary { display: flex; gap: 2em; align-items: center; flex-wrap: wrap; margin: 1em 0; } |
|
| 815 | + | .cov-big { font-size: 3em; font-weight: 600; line-height: 1; } |
|
| 816 | + | .cov-delta { font-size: .35em; vertical-align: middle; opacity: .8; } |
|
| 817 | + | .cov-good { color: #1a7f37; } .cov-fair { color: #4d7c0f; } .cov-low { color: #9a6700; } .cov-poor { color: #cf222e; } .cov-none { color: inherit; } |
|
| 818 | + | .cov-bar { display: inline-block; width: 8em; height: .7em; background: rgba(127,127,127,.2); border-radius: 3px; vertical-align: middle; overflow: hidden; } |
|
| 819 | + | .cov-bar > span { display: block; height: 100%; background: currentColor; } |
|
| 820 | + | .cov-files td.num, .cov-files th.num { text-align: right; } |
|
| 821 | + | .cov-trend { width: 100%; max-width: 640px; height: auto; } |
|
| 822 | + | .cov-trend polyline { fill: none; stroke: #2da44e; stroke-width: 2; } |
|
| 823 | + | .cov-trend circle { fill: #2da44e; } |
|
| 824 | + | .cov-trend .axis { font-size: 10px; fill: currentColor; opacity: .6; } |
|
| 825 | + | .cov-code tr.cov-hit td.t, .cov-key.cov-hit { background: rgba(45,164,78,.15); } |
|
| 826 | + | .cov-code tr.cov-miss td.t, .cov-key.cov-miss { background: rgba(207,34,46,.18); } |
|
| 827 | + | .cov-code td.cov-hits { text-align: right; padding: 0 .5em; opacity: .6; font-size: .85em; white-space: nowrap; user-select: none; } |
|
| 828 | + | .cov-key { padding: 0 .4em; border-radius: 3px; } |