OXYGENHUB

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)) = &current 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">[![coverage]({{ shell.base_url }}{{ repo.url }}/coverage/badge.svg)]({{ 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; }