OXYGENHUB

Coverage: read Cobertura XML reports too 7fff30c4f5e2

A coverage artifact starting with < is read as Cobertura XML, as
coverage.py's coverage xml, cargo llvm-cov --cobertura, Coverlet and
JaCoCo through cover2cover write it: a class's filename tried against
each <source>, branch lines' condition-coverage as branches, methods as
functions, classes of one file added together. The report keeps its
format.

refs #113
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-06 10:25 UTC
Signature
✔ signed by Andy McCall
Hash
7fff30c4f5e20b229a2fc857ee80b7dc0879f4328aae78921f5951419788cd03
Depends on
42fa95540468 554ccacfc123 7edaff01d506 93be9ce4653e c987c86513df
Depended on by
17dc8de4fca8 40dd3f86379a 617c2840446c
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
17 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/hub/src/coverage.rs +199 −2
1 1
//! Coverage reports (oxygenhub#113): Coveralls built into the hub. An
2 2
//! OxygenFlow job that declares an artifact named `coverage` in LCOV
3 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
4 +
//! gcov/lcov write) or Cobertura XML has it parsed when it is uploaded. The totals and each
5 5
//! file's counts are kept per run; the per-line counts are read again from
6 6
//! the artifact's blob for the file view. Paths are taken relative to the
7 7
//! job's workspace (`/src`), and only files of the run's state are kept.
…
162 unchanged lines
170 170
    Ok((out, outside.len()))
171 171
}
172 172
173 +
/// Parse a report in either format the hub reads: Cobertura XML (it
174 +
/// starts with `<`) or LCOV. Returns the files, those outside the
175 +
/// repository, and the format's name.
176 +
pub fn parse(text: &str) -> Result<(Vec<FileCoverage>, usize, &'static str), String> {
177 +
    if text.trim_start().starts_with('<') {
178 +
        parse_cobertura(text).map(|(f, o)| (f, o, "cobertura"))
179 +
    } else {
180 +
        parse_lcov(text).map(|(f, o)| (f, o, "lcov"))
181 +
    }
182 +
}
183 +
184 +
fn xml_attr(e: &quick_xml::events::BytesStart, name: &str) -> String {
185 +
    e.try_get_attribute(name)
186 +
        .ok()
187 +
        .flatten()
188 +
        .and_then(|a| a.normalized_value(quick_xml::XmlVersion::Implicit1_0).ok().map(|v| v.into_owned()))
189 +
        .unwrap_or_default()
190 +
}
191 +
192 +
/// Parse a Cobertura XML report (coverage.py `coverage xml`, JaCoCo via
193 +
/// cover2cover, .NET Coverlet, `cargo llvm-cov --cobertura`): each
194 +
/// `<class>`'s `<line number hits>` by file, with `branch` lines'
195 +
/// `condition-coverage="50% (1/2)"` as branches and `<method>`s as
196 +
/// functions (one is hit when any of its lines ran). A file named by
197 +
/// several classes is added together. A `<source>` is tried as the base of
198 +
/// a relative filename, the workspace's `/src` among them.
199 +
pub fn parse_cobertura(text: &str) -> Result<(Vec<FileCoverage>, usize), String> {
200 +
    use quick_xml::events::Event;
201 +
    let mut r = quick_xml::Reader::from_str(text);
202 +
    let mut sources: Vec<String> = Vec::new();
203 +
    let mut in_source = false;
204 +
    let mut source_text = String::new();
205 +
    // Files as the classes name them, resolved once the sources are known.
206 +
    let mut raw: BTreeMap<String, Acc> = BTreeMap::new();
207 +
    let mut file: Option<String> = None;
208 +
    let mut method: Option<(String, bool)> = None;
209 +
    let mut seen_root = false;
210 +
    loop {
211 +
        let ev = r.read_event().map_err(|e| format!("this is not a Cobertura report: {e}"))?;
212 +
        match ev {
213 +
            Event::Start(ref e) | Event::Empty(ref e) => {
214 +
                let tag = e.local_name().as_ref().to_string();
215 +
                match tag.as_str() {
216 +
                    "coverage" => seen_root = true,
217 +
                    "source" if matches!(ev, Event::Start(_)) => {
218 +
                        in_source = true;
219 +
                        source_text.clear();
220 +
                    }
221 +
                    "class" => {
222 +
                        let f = xml_attr(e, "filename");
223 +
                        raw.entry(f.clone()).or_default().records += 1;
224 +
                        file = Some(f);
225 +
                    }
226 +
                    "method" => method = Some((xml_attr(e, "name") + &xml_attr(e, "signature"), false)),
227 +
                    "line" => {
228 +
                        let Some(f) = &file else { continue };
229 +
                        let Ok(n) = xml_attr(e, "number").trim().parse::<u32>() else { continue };
230 +
                        let hits = xml_attr(e, "hits").trim().parse::<u64>().unwrap_or(0);
231 +
                        if let Some((_, hit)) = &mut method {
232 +
                            *hit |= hits > 0;
233 +
                            // A method's lines repeat the class's; count them once.
234 +
                            continue;
235 +
                        }
236 +
                        let acc = raw.entry(f.clone()).or_default();
237 +
                        *acc.lines.entry(n).or_default() += hits;
238 +
                        if xml_attr(e, "branch") == "true" {
239 +
                            // "50% (1/2)": covered of total conditions.
240 +
                            let cc = xml_attr(e, "condition-coverage");
241 +
                            if let Some((hit, total)) = cc
242 +
                                .split_once('(')
243 +
                                .and_then(|(_, rest)| rest.trim_end_matches(')').split_once('/'))
244 +
                                .and_then(|(h, t)| Some((h.trim().parse::<u64>().ok()?, t.trim().parse::<u64>().ok()?)))
245 +
                            {
246 +
                                for i in 0..total {
247 +
                                    let b = acc.branches.entry((n, "0".into(), i.to_string())).or_default();
248 +
                                    *b = (*b).max(u64::from(i < hit));
249 +
                                }
250 +
                            }
251 +
                        }
252 +
                    }
253 +
                    _ => {}
254 +
                }
255 +
                if matches!(ev, Event::Empty(_)) && tag == "method" {
256 +
                    if let (Some(f), Some((name, hit))) = (&file, method.take()) {
257 +
                        *raw.entry(f.clone()).or_default().functions.entry(name).or_default() += u64::from(hit);
258 +
                    }
259 +
                }
260 +
            }
261 +
            Event::Text(t) if in_source => source_text.push_str(&t.xml10_content()),
262 +
            Event::End(e) => match e.local_name().as_ref() {
263 +
                "source" => {
264 +
                    in_source = false;
265 +
                    sources.push(source_text.trim().trim_end_matches('/').to_string());
266 +
                }
267 +
                "class" => file = None,
268 +
                "method" => {
269 +
                    if let (Some(f), Some((name, hit))) = (&file, method.take()) {
270 +
                        *raw.entry(f.clone()).or_default().functions.entry(name).or_default() += u64::from(hit);
271 +
                    }
272 +
                }
273 +
                _ => {}
274 +
            },
275 +
            Event::Eof => break,
276 +
            _ => {}
277 +
        }
278 +
    }
279 +
    if !seen_root {
280 +
        return Err("this is not a Cobertura report: no <coverage> element".into());
281 +
    }
282 +
    if raw.is_empty() {
283 +
        return Err("this Cobertura report names no source file (no <class filename>)".into());
284 +
    }
285 +
    // A filename relative to one of the sources, or to the workspace.
286 +
    let resolve = |f: &str| -> Option<String> {
287 +
        if f.starts_with('/') {
288 +
            return relative(f);
289 +
        }
290 +
        sources.iter().filter(|s| !s.is_empty() && *s != ".").find_map(|s| relative(&format!("{s}/{f}"))).or_else(|| relative(f))
291 +
    };
292 +
    let mut files: BTreeMap<String, Acc> = BTreeMap::new();
293 +
    let mut outside = HashSet::new();
294 +
    for (f, a) in raw {
295 +
        let Some(path) = resolve(&f) else {
296 +
            outside.insert(f);
297 +
            continue;
298 +
        };
299 +
        let into = files.entry(path).or_default();
300 +
        for (l, n) in a.lines {
301 +
            *into.lines.entry(l).or_default() += n;
302 +
        }
303 +
        for (k, n) in a.functions {
304 +
            *into.functions.entry(k).or_default() += n;
305 +
        }
306 +
        for (k, n) in a.branches {
307 +
            let b = into.branches.entry(k).or_default();
308 +
            *b = (*b).max(n);
309 +
        }
310 +
    }
311 +
    let out = files
312 +
        .into_iter()
313 +
        .map(|(path, a)| FileCoverage {
314 +
            path,
315 +
            functions_found: a.functions.len() as u64,
316 +
            functions_hit: a.functions.values().filter(|n| **n > 0).count() as u64,
317 +
            branches_found: a.branches.len() as u64,
318 +
            branches_hit: a.branches.values().filter(|n| **n > 0).count() as u64,
319 +
            lines: a.lines,
320 +
            declared_lines: None,
321 +
        })
322 +
        .collect();
323 +
    Ok((out, outside.len()))
324 +
}
325 +
173 326
/// A percentage to one decimal place, or none when nothing was counted.
174 327
pub fn percent(hit: i64, found: i64) -> Option<f64> {
175 328
    (found > 0).then(|| (hit as f64 * 1000.0 / found as f64).round() / 10.0)
…
106 unchanged lines
282 435
    pub state: &'a str,
283 436
    pub heads: &'a [String],
284 437
    pub blob: &'a str,
438 +
    /// `lcov` or `cobertura`.
439 +
    pub format: &'a str,
285 440
}
286 441
287 442
/// Keep a parsed report: the files outside `in_state` are dropped and
…
6 unchanged lines
294 449
    let id: i64 = sqlx::query_scalar(
295 450
        "INSERT INTO coverage_reports (repo_id, run_id, job_id, job, run_number, bookmark, state, heads, blob, format, \
296 451
         lines_found, lines_hit, functions_found, functions_hit, branches_found, branches_hit, hits, files, dropped, created_ms) \
297 -
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'lcov', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
452 +
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
298 453
    )
299 454
    .bind(o.repo_id)
300 455
    .bind(o.run_id)
…
4 unchanged lines
305 460
    .bind(o.state)
306 461
    .bind(o.heads.join(","))
307 462
    .bind(o.blob)
463 +
    .bind(o.format)
308 464
    .bind(sum(&|f| f.lines_found()))
309 465
    .bind(sum(&|f| f.lines_hit()))
310 466
    .bind(sum(&|f| f.functions_found))
…
206 unchanged lines
517 673
        }
518 674
        assert_eq!(percent(1, 3), Some(33.3));
519 675
        assert_eq!(percent(0, 0), None);
676 +
    }
677 +
678 +
    #[test]
679 +
    fn cobertura_from_coverage_py_and_coverlet() {
680 +
        // coverage.py: a source to resolve against, branches by condition.
681 +
        let py = r#"<?xml version="1.0" ?>
682 +
<coverage version="7.4" line-rate="0.6" branch-rate="0.5">
683 +
  <sources><source>/src/app</source></sources>
684 +
  <packages><package name="app"><classes>
685 +
    <class name="main.py" filename="main.py" line-rate="0.66">
686 +
      <methods/>
687 +
      <lines>
688 +
        <line number="1" hits="1"/>
689 +
        <line number="2" hits="0"/>
690 +
        <line number="3" hits="4" branch="true" condition-coverage="50% (1/2)"/>
691 +
      </lines>
692 +
    </class>
693 +
    <class name="gone.py" filename="/usr/lib/python3/x.py"><lines><line number="1" hits="1"/></lines></class>
694 +
  </classes></package></packages>
695 +
</coverage>"#;
696 +
        let (files, outside, format) = parse(py).unwrap();
697 +
        assert_eq!((format, outside), ("cobertura", 1));
698 +
        let f = &files[0];
699 +
        assert_eq!(f.path, "app/main.py");
700 +
        assert_eq!((f.lines_found(), f.lines_hit(), f.hits()), (3, 2, 5));
701 +
        assert_eq!((f.branches_found, f.branches_hit), (2, 1));
702 +
703 +
        // Coverlet: methods with their own lines, two classes in one file.
704 +
        let net = r#"<coverage><sources><source>/src/</source></sources><packages><package name="Lib"><classes>
705 +
<class name="Lib.A" filename="Lib/A.cs"><methods><method name="Run" signature="()"><lines><line number="5" hits="2"/></lines></method><method name="Idle" signature="()"><lines><line number="9" hits="0"/></lines></method></methods>
706 +
<lines><line number="5" hits="2"/><line number="9" hits="0"/></lines></class>
707 +
<class name="Lib.A/Inner" filename="Lib/A.cs"><methods/><lines><line number="12" hits="1"/></lines></class>
708 +
</classes></package></packages></coverage>"#;
709 +
        let (files, _, _) = parse(net).unwrap();
710 +
        let f = &files[0];
711 +
        assert_eq!(f.path, "Lib/A.cs");
712 +
        assert_eq!((f.lines_found(), f.lines_hit()), (3, 2));
713 +
        assert_eq!((f.functions_found, f.functions_hit), (2, 1));
714 +
715 +
        assert!(parse("<html/>").unwrap_err().contains("Cobertura"));
716 +
        assert_eq!(parse("SF:/src/a.rs\nDA:1,1\nend_of_record\n").unwrap().2, "lcov");
520 717
    }
521 718
}
modified crates/hub/src/flow.rs +4 −2
…
978 unchanged lines
979 979
            crate::coverage::MAX_REPORT >> 20
980 980
        ));
981 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))
982 +
    let text = String::from_utf8(bytes.to_vec())
983 +
        .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?;
984 +
    let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text))
984 985
        .await
985 986
        .map_err(|e| format!("coverage: {e}"))?
986 987
        .map_err(|e| format!("coverage: {e}"))?;
…
14 unchanged lines
1001 1002
        state: &run.state,
1002 1003
        heads: &heads,
1003 1004
        blob,
1005 +
        format,
1004 1006
    };
1005 1007
    let id = crate::coverage::keep(app, &origin, files, outside, &in_state).await.map_err(|e| format!("coverage: {e}"))?;
1006 1008
    let Some(r) = crate::coverage::report(&app.db, row.id, id).await.map_err(|e| format!("coverage: {e}"))? else {
…
466 unchanged lines
modified crates/hub/src/web/coverage.rs +1 −1
…
419 unchanged lines
420 420
    let bytes = app.repos.read(repo, move |repo, _| Ok(repo.read_blob(&r)?)).await.ok()?;
421 421
    let parsed = tokio::task::spawn_blocking(move || {
422 422
        let text = String::from_utf8_lossy(&bytes);
423 -
        coverage::parse_lcov(&text).ok().map(|(files, _)| files.into_iter().map(|f| (f.path, f.lines)).collect::<BTreeMap<_, _>>())
423 +
        coverage::parse(&text).ok().map(|(files, _, _)| files.into_iter().map(|f| (f.path, f.lines)).collect::<BTreeMap<_, _>>())
424 424
    })
425 425
    .await
426 426
    .ok()??;
…
78 unchanged lines
modified docs/spec.md +2 −2
…
583 unchanged lines
584 584
585 585
Coverage is built in, as Coveralls or Codecov would be, fed by OxygenFlow.
586 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.
587 +
- **Reports.** A job's artifact named `coverage` in LCOV format (`cargo llvm-cov --lcov`, Istanbul's `lcov` reporter, `coverage lcov`, gcov with lcov) or Cobertura XML (`coverage xml`, `cargo llvm-cov --cobertura`, Coverlet, JaCoCo through cover2cover; a report starting with `<`) is read when the job uploads it. In Cobertura a filename is tried against each `<source>`, a `branch` line's `condition-coverage` counts its branches, and a `<method>` is a function, hit when any of its lines ran. 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 588
- **Counting.** Paths are taken relative to the job's `/src`; a path outside the repository, or a file the run's state does not hold, is left out and counted. Records for one file are added together. A file with a single record keeps the tool's own `LF`/`LH` totals, since `llvm-cov` declares lines its `DA` lines leave out; the totals then match what the tool printed.
589 589
- **Stored.** `coverage_reports` keeps each report's totals (lines, functions, branches) with its run, job, bookmark and state; `coverage_files` each file's counts. The per-line counts stay in the artifact's blob and are read again for the file view.
590 590
- **Pages.** A Coverage tab, shown once a repository has a report: the bookmark's latest percentage with its change against the report before over a chart of up to 200 runs (each point names its run and opens its report), a strip of facts (lines covered, hits per line, functions, branches, files, the first report, the badge with its Markdown), this run's changes with the files whose coverage moved most, files by coverage band, a chart of covered and missed lines, the recent runs, a bookmark picker, every file with a bar (sorted by lines missed, lowest coverage, or path), and a file view with each line run or missed and how often it ran. `/OWNER/NAME/coverage/badge.svg` is a badge of the default bookmark's latest coverage. The run page shows each job's coverage beside its artifacts.
591 591
- **The check.** Each report sets a `coverage` check on its run's state, measured against the default bookmark: its previous report there for a run on it, its latest for a run on any other bookmark (a proposal's). Settings › Coverage can set a minimum percentage and a largest drop in points; broken, the check fails and says why (`50.0%: below the minimum of 60%`), and naming `coverage` among the required checks holds a proposal back. Without rules it only reports. A bookmark's first report shows the files that moved against the default bookmark.
592 592
- **Oxygen itself:** its `test` job runs under cargo-llvm-cov in the build image (oxygenflow 7705d699) and keeps its report as the `coverage` artifact; the README carries the badge.
593 -
- **Next:** Cobertura XML, and the API.
593 +
- **Next:** the API.
594 594
595 595
## 14. Git interoperability
596 596
…
145 unchanged lines
modified templates/coverage.html +1 −1
…
92 unchanged lines
93 93
        artifact "coverage" { path "/src/target/lcov.info" }
94 94
    }
95 95
}</pre>
96 -
  <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>
96 +
  <p class="small mute">Istanbul (<code>--reporter=lcov</code>), coverage.py (<code>coverage lcov</code>) and gcov with lcov write LCOV too; Cobertura XML (<code>coverage xml</code>, Coverlet, JaCoCo through cover2cover) is read as well. Paths are read relative to the job's <code>/src</code>.</p>
97 97
</section>
98 98
{% endif %}
99 99
{% endblock %}