JaCoCo coverage is read (closes #308) 17dc8de4fca8
Gradle's jacocoTestReport and Kover write JaCoCo XML: a coverage artifact may now be one, beside LCOV and Cobertura. Lines are hit when an instruction ran; branches and methods are counted. JaCoCo's paths are relative to a source root, so a path not in the state is matched by its ending to the one file it can be, for any format.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key aa4ed503b7e5
- When
- · 2026-10-26 00:01 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
17dc8de4fca8a9745b17b8c62dff0ad6cf348f62a4524b67cd61561d05a48eef- Depends on
- 40dd3f86379a 42fa95540468 439ecd8708a1 50d93fa443dc 554ccacfc123 617c2840446c 7fff30c4f5e2 93be9ce4653e a3ba7cc10673 a7e6e8e6094c b5668108487f
- Depended on by
- 4dfa4599b3f7 c2efc0b4674d d2263e299da7
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 17 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **JaCoCo coverage is read (#308):** Gradle's `jacocoTestReport` and Kover write JaCoCo XML, which a `coverage` artifact (or `POST /coverage`) may now be, beside LCOV and Cobertura: lines hit when an instruction ran, branches, methods. Its paths are relative to a source root (`io/oxygenhub/…/X.kt`), so a report's path not in the state is matched by its ending to the one file it can be. |
|
| 7 | 8 | ## 0.9.4 (2026-10-25) |
|
| 8 | 9 | ||
| 9 | 10 | - **Boards keep the last fortnight of finished work (#293):** by default every column archives its finished cards (closed issues, closed or landed proposals) 14 days after they reached it, hourly, so a Done column stops growing however long a project runs; open work and notes stay. A column can instead archive every card after N days, or never ("Archive automatically" in its menu). Archived cards stay on the board's archive view, searchable and restorable. On the next hourly pass, boards with years of Done cards (Super Mario Land 2's 327) shrink to their last two weeks. |
|
| … | 326 unchanged lines |
modified crates/hub/src/api/reports.rs +1 −1
| … | 87 unchanged lines |
||
| 88 | 88 | bookmark: Option<String>, |
|
| 89 | 89 | #[serde(default)] |
|
| 90 | 90 | change: Option<String>, |
|
| 91 | - | /// The report: LCOV or Cobertura XML. |
|
| 91 | + | /// The report: LCOV, Cobertura XML or JaCoCo XML. |
|
| 92 | 92 | content: String, |
|
| 93 | 93 | } |
|
| 94 | 94 | ||
| … | 149 unchanged lines |
modified crates/hub/src/coverage.rs +164 −4
| … | 169 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. |
|
| 173 | + | /// Parse a report in any format the hub reads: JaCoCo XML (a `<report>`), |
|
| 174 | + | /// Cobertura XML (a `<coverage>`) or LCOV. Returns the files, those outside |
|
| 175 | + | /// the repository, and the format's name. |
|
| 176 | 176 | pub fn parse(text: &str) -> Result<(Vec<FileCoverage>, usize, &'static str), String> { |
|
| 177 | - | if text.trim_start().starts_with('<') { |
|
| 177 | + | let t = text.trim_start(); |
|
| 178 | + | if t.starts_with('<') && root_element(t).as_deref() == Some("report") { |
|
| 179 | + | parse_jacoco(text).map(|(f, o)| (f, o, "jacoco")) |
|
| 180 | + | } else if t.starts_with('<') { |
|
| 178 | 181 | parse_cobertura(text).map(|(f, o)| (f, o, "cobertura")) |
|
| 179 | 182 | } else { |
|
| 180 | 183 | parse_lcov(text).map(|(f, o)| (f, o, "lcov")) |
|
| 181 | 184 | } |
|
| 182 | 185 | } |
|
| 183 | 186 | ||
| 187 | + | /// The name of an XML document's first element. |
|
| 188 | + | fn root_element(text: &str) -> Option<String> { |
|
| 189 | + | use quick_xml::events::Event; |
|
| 190 | + | let mut r = quick_xml::Reader::from_str(text); |
|
| 191 | + | loop { |
|
| 192 | + | match r.read_event().ok()? { |
|
| 193 | + | Event::Start(e) | Event::Empty(e) => return Some(e.local_name().as_ref().to_string()), |
|
| 194 | + | Event::Eof => return None, |
|
| 195 | + | _ => {} |
|
| 196 | + | } |
|
| 197 | + | } |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | /// Parse a JaCoCo XML report (Gradle's `jacocoTestReport`, Kotlin's Kover; |
|
| 201 | + | /// oxygenhub#308): each `<package name>`'s `<sourcefile name>` with its |
|
| 202 | + | /// `<line nr mi ci mb cb>` (hit when any of the line's instructions ran; |
|
| 203 | + | /// its branches missed and covered), and each `<class sourcefilename>`'s |
|
| 204 | + | /// `<method>`s as functions, hit when their METHOD counter says so. Paths |
|
| 205 | + | /// are the package's, relative to a source root (`io/oxygenhub/api/X.kt`); |
|
| 206 | + | /// [`keep`] finds them in the state by their ending. |
|
| 207 | + | pub fn parse_jacoco(text: &str) -> Result<(Vec<FileCoverage>, usize), String> { |
|
| 208 | + | use quick_xml::events::Event; |
|
| 209 | + | let mut r = quick_xml::Reader::from_str(text); |
|
| 210 | + | let mut files: BTreeMap<String, Acc> = BTreeMap::new(); |
|
| 211 | + | let mut package = String::new(); |
|
| 212 | + | let mut sourcefile: Option<String> = None; |
|
| 213 | + | let mut class_file: Option<String> = None; |
|
| 214 | + | let mut method: Option<(String, bool)> = None; |
|
| 215 | + | let mut seen_root = false; |
|
| 216 | + | let path = |package: &str, name: &str| if package.is_empty() { name.to_string() } else { format!("{package}/{name}") }; |
|
| 217 | + | loop { |
|
| 218 | + | let ev = r.read_event().map_err(|e| format!("this is not a JaCoCo report: {e}"))?; |
|
| 219 | + | match ev { |
|
| 220 | + | Event::Start(ref e) | Event::Empty(ref e) => { |
|
| 221 | + | let tag = e.local_name().as_ref().to_string(); |
|
| 222 | + | match tag.as_str() { |
|
| 223 | + | "report" => seen_root = true, |
|
| 224 | + | "package" => package = xml_attr(e, "name").trim_matches('/').to_string(), |
|
| 225 | + | "class" => { |
|
| 226 | + | let f = xml_attr(e, "sourcefilename"); |
|
| 227 | + | class_file = (!f.is_empty()).then(|| path(&package, &f)); |
|
| 228 | + | } |
|
| 229 | + | "method" if class_file.is_some() => method = Some((xml_attr(e, "name") + &xml_attr(e, "desc"), false)), |
|
| 230 | + | "counter" => { |
|
| 231 | + | if let Some((_, hit)) = &mut method { |
|
| 232 | + | if xml_attr(e, "type") == "METHOD" { |
|
| 233 | + | *hit |= xml_attr(e, "covered").trim().parse::<u64>().unwrap_or(0) > 0; |
|
| 234 | + | } |
|
| 235 | + | } |
|
| 236 | + | } |
|
| 237 | + | "sourcefile" => { |
|
| 238 | + | let f = path(&package, &xml_attr(e, "name")); |
|
| 239 | + | files.entry(f.clone()).or_default().records += 1; |
|
| 240 | + | sourcefile = Some(f); |
|
| 241 | + | } |
|
| 242 | + | "line" => { |
|
| 243 | + | let Some(f) = &sourcefile else { continue }; |
|
| 244 | + | let Ok(n) = xml_attr(e, "nr").trim().parse::<u32>() else { continue }; |
|
| 245 | + | let num = |a: &str| xml_attr(e, a).trim().parse::<u64>().unwrap_or(0); |
|
| 246 | + | let acc = files.entry(f.clone()).or_default(); |
|
| 247 | + | *acc.lines.entry(n).or_default() += num("ci"); |
|
| 248 | + | let (missed, covered) = (num("mb"), num("cb")); |
|
| 249 | + | for i in 0..missed + covered { |
|
| 250 | + | let b = acc.branches.entry((n, "0".into(), i.to_string())).or_default(); |
|
| 251 | + | *b = (*b).max(u64::from(i < covered)); |
|
| 252 | + | } |
|
| 253 | + | } |
|
| 254 | + | _ => {} |
|
| 255 | + | } |
|
| 256 | + | if matches!(ev, Event::Empty(_)) && tag == "method" { |
|
| 257 | + | if let (Some(f), Some((name, hit))) = (&class_file, method.take()) { |
|
| 258 | + | *files.entry(f.clone()).or_default().functions.entry(name).or_default() += u64::from(hit); |
|
| 259 | + | } |
|
| 260 | + | } |
|
| 261 | + | } |
|
| 262 | + | Event::End(e) => match e.local_name().as_ref() { |
|
| 263 | + | "package" => package.clear(), |
|
| 264 | + | "sourcefile" => sourcefile = None, |
|
| 265 | + | "class" => class_file = None, |
|
| 266 | + | "method" => { |
|
| 267 | + | if let (Some(f), Some((name, hit))) = (&class_file, method.take()) { |
|
| 268 | + | *files.entry(f.clone()).or_default().functions.entry(name).or_default() += u64::from(hit); |
|
| 269 | + | } |
|
| 270 | + | } |
|
| 271 | + | _ => {} |
|
| 272 | + | }, |
|
| 273 | + | Event::Eof => break, |
|
| 274 | + | _ => {} |
|
| 275 | + | } |
|
| 276 | + | } |
|
| 277 | + | if !seen_root { |
|
| 278 | + | return Err("this is not a JaCoCo report: no <report> element".into()); |
|
| 279 | + | } |
|
| 280 | + | let out: Vec<FileCoverage> = files |
|
| 281 | + | .into_iter() |
|
| 282 | + | .filter(|(_, a)| a.records > 0) |
|
| 283 | + | .map(|(path, a)| FileCoverage { |
|
| 284 | + | path, |
|
| 285 | + | functions_found: a.functions.len() as u64, |
|
| 286 | + | functions_hit: a.functions.values().filter(|n| **n > 0).count() as u64, |
|
| 287 | + | branches_found: a.branches.len() as u64, |
|
| 288 | + | branches_hit: a.branches.values().filter(|n| **n > 0).count() as u64, |
|
| 289 | + | lines: a.lines, |
|
| 290 | + | declared_lines: None, |
|
| 291 | + | }) |
|
| 292 | + | .collect(); |
|
| 293 | + | if out.is_empty() { |
|
| 294 | + | return Err("this JaCoCo report names no source file (no <sourcefile>)".into()); |
|
| 295 | + | } |
|
| 296 | + | Ok((out, 0)) |
|
| 297 | + | } |
|
| 298 | + | ||
| 299 | + | /// A report's path that is not in the state, matched by its ending to the |
|
| 300 | + | /// one file of the state it can be (a JaCoCo or JVM Cobertura path is |
|
| 301 | + | /// relative to a source root, `app/src/main/java`); left as it is when |
|
| 302 | + | /// none or several match. |
|
| 303 | + | pub fn resolve_endings(files: Vec<FileCoverage>, in_state: &HashSet<String>) -> Vec<FileCoverage> { |
|
| 304 | + | files |
|
| 305 | + | .into_iter() |
|
| 306 | + | .map(|mut f| { |
|
| 307 | + | if !in_state.contains(&f.path) { |
|
| 308 | + | let tail = format!("/{}", f.path); |
|
| 309 | + | let mut found = in_state.iter().filter(|p| p.ends_with(&tail)); |
|
| 310 | + | if let (Some(p), None) = (found.next(), found.next()) { |
|
| 311 | + | f.path = p.clone(); |
|
| 312 | + | } |
|
| 313 | + | } |
|
| 314 | + | f |
|
| 315 | + | }) |
|
| 316 | + | .collect() |
|
| 317 | + | } |
|
| 318 | + | ||
| 184 | 319 | fn xml_attr(e: &quick_xml::events::BytesStart, name: &str) -> String { |
|
| 185 | 320 | e.try_get_attribute(name) |
|
| 186 | 321 | .ok() |
|
| … | 274 unchanged lines |
||
| 461 | 596 | /// Keep a parsed report: the files outside `in_state` are dropped and |
|
| 462 | 597 | /// counted. Replaces an earlier report of the same job. |
|
| 463 | 598 | pub async fn keep(app: &Shared, o: &Origin<'_>, files: Vec<FileCoverage>, outside: usize, in_state: &HashSet<String>) -> sqlx::Result<i64> { |
|
| 599 | + | let files = resolve_endings(files, in_state); |
|
| 464 | 600 | let (kept, missing): (Vec<FileCoverage>, Vec<FileCoverage>) = files.into_iter().partition(|f| in_state.contains(&f.path)); |
|
| 465 | 601 | let sum = |f: &dyn Fn(&FileCoverage) -> u64| kept.iter().map(f).sum::<u64>() as i64; |
|
| 466 | 602 | let mut tx = crate::db::begin_write(&app.db).await?; |
|
| … | 152 unchanged lines |
||
| 619 | 755 | #[cfg(test)] |
|
| 620 | 756 | mod tests { |
|
| 621 | 757 | use super::*; |
|
| 758 | + | ||
| 759 | + | /// oxygenhub#308: a JaCoCo report (Gradle's jacocoTestReport, Kover): |
|
| 760 | + | /// lines hit when an instruction ran, branches, methods; package paths |
|
| 761 | + | /// found in the state by their ending. |
|
| 762 | + | #[test] |
|
| 763 | + | fn a_jacoco_report_is_read_and_its_paths_found() { |
|
| 764 | + | let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><!DOCTYPE report PUBLIC "-//JACOCO//DTD Report 1.1//EN" "report.dtd"><report name="app"><sessioninfo id="x" start="1" dump="2"/><package name="io/oxygenhub/android/api"><class name="io/oxygenhub/android/api/Links" sourcefilename="Links.kt"><method name="routeFor" desc="(Ljava/lang/String;)" line="11"><counter type="INSTRUCTION" missed="2" covered="30"/><counter type="METHOD" missed="0" covered="1"/></method><method name="unused" desc="()V" line="40"><counter type="METHOD" missed="1" covered="0"/></method></class><sourcefile name="Links.kt"><line nr="11" mi="0" ci="5" mb="1" cb="1"/><line nr="12" mi="3" ci="0" mb="0" cb="0"/><line nr="40" mi="2" ci="0" mb="0" cb="0"/><counter type="LINE" missed="2" covered="1"/></sourcefile></package></report>"#; |
|
| 765 | + | let (files, outside, format) = parse(xml).unwrap(); |
|
| 766 | + | assert_eq!((format, outside, files.len()), ("jacoco", 0, 1)); |
|
| 767 | + | let f = &files[0]; |
|
| 768 | + | assert_eq!(f.path, "io/oxygenhub/android/api/Links.kt"); |
|
| 769 | + | assert_eq!((f.lines_found(), f.lines_hit()), (3, 1)); |
|
| 770 | + | assert_eq!((f.branches_found, f.branches_hit), (2, 1)); |
|
| 771 | + | assert_eq!((f.functions_found, f.functions_hit), (2, 1)); |
|
| 772 | + | let state: HashSet<String> = |
|
| 773 | + | ["app/src/main/java/io/oxygenhub/android/api/Links.kt".to_string(), "README.md".to_string()].into_iter().collect(); |
|
| 774 | + | let found = resolve_endings(files, &state); |
|
| 775 | + | assert_eq!(found[0].path, "app/src/main/java/io/oxygenhub/android/api/Links.kt"); |
|
| 776 | + | // Two files of the state end the same way: left as it is. |
|
| 777 | + | let twice: HashSet<String> = ["a/x/Links.kt".to_string(), "b/x/Links.kt".to_string()].into_iter().collect(); |
|
| 778 | + | let left = resolve_endings(vec![FileCoverage { path: "x/Links.kt".into(), ..Default::default() }], &twice); |
|
| 779 | + | assert_eq!(left[0].path, "x/Links.kt"); |
|
| 780 | + | assert!(parse_jacoco("<report></report>").is_err(), "no source file"); |
|
| 781 | + | } |
|
| 622 | 782 | ||
| 623 | 783 | 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\ |
|
| 624 | 784 | 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\ |
|
| … | 117 unchanged lines |
modified crates/hub/src/flow.rs +1 −1
| … | 2234 unchanged lines |
||
| 2235 | 2235 | )); |
|
| 2236 | 2236 | } |
|
| 2237 | 2237 | let text = String::from_utf8(bytes.to_vec()) |
|
| 2238 | - | .map_err(|_| "coverage: the report is not text (LCOV or Cobertura XML is expected)".to_string())?; |
|
| 2238 | + | .map_err(|_| "coverage: the report is not text (LCOV, Cobertura XML or JaCoCo XML is expected)".to_string())?; |
|
| 2239 | 2239 | let (files, outside, format) = tokio::task::spawn_blocking(move || crate::coverage::parse(&text)) |
|
| 2240 | 2240 | .await |
|
| 2241 | 2241 | .map_err(|e| format!("coverage: {e}"))? |
|
| … | 694 unchanged lines |
modified docs/reports.md +1 −1
| … | 34 unchanged lines |
||
| 35 | 35 | ``` |
|
| 36 | 36 | ||
| 37 | 37 | - **Built-in kinds** name their data file and need no page: |
|
| 38 | - | `kind = "coverage"` with `file = "lcov.info"` (LCOV or Cobertura), and |
|
| 38 | + | `kind = "coverage"` with `file = "lcov.info"` (LCOV, Cobertura or JaCoCo), and |
|
| 39 | 39 | `kind = "tests"` with `file = "junit.xml"`. The hub reads them as it |
|
| 40 | 40 | does today, and works out their metrics itself (line coverage; passed, |
|
| 41 | 41 | failed, skipped). |
|
| … | 186 unchanged lines |
modified docs/spec.md +1 −1
| … | 491 unchanged lines |
||
| 492 | 492 | ||
| 493 | 493 | ### 12.1 API |
|
| 494 | 494 | ||
| 495 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 495 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 496 | 496 | ||
| 497 | 497 | ### 12.2 Webhooks |
|
| 498 | 498 | ||
| … | 396 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; 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> |
|
| 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) and JaCoCo XML (Gradle's <code>jacocoTestReport</code>, Kover) are read as well. Paths are read relative to the job's <code>/src</code>; a JaCoCo path, relative to its source root, is found by its ending.</p> |
|
| 97 | 97 | </section> |
|
| 98 | 98 | {% endif %} |
|
| 99 | 99 | {% endblock %} |