API: coverage and test reports 617c2840446c
GET /repos/{o}/{n}/coverage and /tests list a bookmark's reports newest
first (?bookmark=&limit=); /coverage/{id} gives a report with its files,
/tests/{id} one with its cases (?status=failed for the failed and
errored ones). The types are in hub-api.
closes #113
refs #114
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-06 11:05 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
617c2840446cc84a8d1c1a4e436d40fcfd442d5d8d6570b46a06db3d5ecb0524- Depends on
- 0b203bbf7cc3 17a3a84b00d1 36e5a9c15a84 42fa95540468 554ccacfc123 5ea4872f23e8 62ceb2eec18c 7edaff01d506 7fff30c4f5e2 93be9ce4653e b64e2537d702 d1727c573f30 d23464c0ac60 ef20fdc14e8b
- Depended on by
- fac6853c4e46 9bbca80f777a 17dc8de4fca8 e97500b70f9a 8a48dd830178 8dcaeeb848c6 4f40eed0588f 965b950f9b96 8aa5ef384ef1 026a76d251ce 5d90e0e045e2 6dbd999ad794 40dd3f86379a b1e92a6233ee 3d5c825aebba
- 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
- 14 atoms, 1 tree operation, 6 files
Browse the tree at this change
added crates/hub/src/api/reports.rs +151 −0
| 1 | + | //! Coverage (oxygenhub#113) and test reports (#114) over the API: what the |
|
| 2 | + | //! Coverage and Tests tabs show, for whoever may read the repository. |
|
| 3 | + | ||
| 4 | + | use super::{caller, not_found, reach, ApiError}; |
|
| 5 | + | use crate::auth::Access; |
|
| 6 | + | use crate::{coverage, junit, Shared}; |
|
| 7 | + | use axum::extract::{Path, Query, State}; |
|
| 8 | + | use axum::http::HeaderMap; |
|
| 9 | + | use axum::Json; |
|
| 10 | + | use hub_api::{CoverageDetail, CoverageFile, CoverageReport, TestCase, TestReport, TestReportDetail}; |
|
| 11 | + | use serde::Deserialize; |
|
| 12 | + | ||
| 13 | + | #[derive(Deserialize, Default)] |
|
| 14 | + | pub struct ListQuery { |
|
| 15 | + | /// The bookmark (default: the repository's default). |
|
| 16 | + | bookmark: Option<String>, |
|
| 17 | + | /// At most this many, newest first (default 20, at most 200). |
|
| 18 | + | limit: Option<i64>, |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | #[derive(Deserialize, Default)] |
|
| 22 | + | pub struct CasesQuery { |
|
| 23 | + | /// `failed` for the failed and errored cases only. |
|
| 24 | + | status: Option<String>, |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | fn coverage_of(app: &Shared, base: &str, r: &coverage::Report) -> CoverageReport { |
|
| 28 | + | CoverageReport { |
|
| 29 | + | id: r.id, |
|
| 30 | + | run: r.run_number, |
|
| 31 | + | job: r.job.clone(), |
|
| 32 | + | bookmark: r.bookmark.clone(), |
|
| 33 | + | state: r.state.clone(), |
|
| 34 | + | format: r.format.clone(), |
|
| 35 | + | lines_found: r.lines_found, |
|
| 36 | + | lines_hit: r.lines_hit, |
|
| 37 | + | percent: r.percent(), |
|
| 38 | + | functions_found: r.functions_found, |
|
| 39 | + | functions_hit: r.functions_hit, |
|
| 40 | + | branches_found: r.branches_found, |
|
| 41 | + | branches_hit: r.branches_hit, |
|
| 42 | + | files: r.files, |
|
| 43 | + | dropped: r.dropped, |
|
| 44 | + | created_ms: r.created_ms, |
|
| 45 | + | url: format!("{}{base}/coverage?report={}", app.config.base_url().trim_end_matches('/'), r.id), |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | fn tests_of(app: &Shared, base: &str, r: &junit::Report) -> TestReport { |
|
| 50 | + | TestReport { |
|
| 51 | + | id: r.id, |
|
| 52 | + | run: r.run_number, |
|
| 53 | + | job: r.job.clone(), |
|
| 54 | + | bookmark: r.bookmark.clone(), |
|
| 55 | + | state: r.state.clone(), |
|
| 56 | + | tests: r.tests, |
|
| 57 | + | passed: r.passed, |
|
| 58 | + | failed: r.failed, |
|
| 59 | + | errors: r.errors, |
|
| 60 | + | skipped: r.skipped, |
|
| 61 | + | time_ms: r.time_ms, |
|
| 62 | + | created_ms: r.created_ms, |
|
| 63 | + | url: format!("{}{base}/tests?report={}", app.config.base_url().trim_end_matches('/'), r.id), |
|
| 64 | + | } |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | /// `GET /repos/{o}/{n}/coverage`. |
|
| 68 | + | pub async fn coverage_list( |
|
| 69 | + | State(app): State<Shared>, |
|
| 70 | + | headers: HeaderMap, |
|
| 71 | + | Path((owner, name)): Path<(String, String)>, |
|
| 72 | + | Query(q): Query<ListQuery>, |
|
| 73 | + | ) -> Result<Json<Vec<CoverageReport>>, ApiError> { |
|
| 74 | + | let c = caller(&app, &headers).await?; |
|
| 75 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 76 | + | let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 77 | + | let list = coverage::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?; |
|
| 78 | + | let base = format!("/{owner}/{name}"); |
|
| 79 | + | Ok(Json(list.iter().map(|r| coverage_of(&app, &base, r)).collect())) |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | /// `GET /repos/{o}/{n}/coverage/{id}`. |
|
| 83 | + | pub async fn coverage_one( |
|
| 84 | + | State(app): State<Shared>, |
|
| 85 | + | headers: HeaderMap, |
|
| 86 | + | Path((owner, name, id)): Path<(String, String, i64)>, |
|
| 87 | + | ) -> Result<Json<CoverageDetail>, ApiError> { |
|
| 88 | + | let c = caller(&app, &headers).await?; |
|
| 89 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 90 | + | let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such coverage report"))?; |
|
| 91 | + | let files = coverage::files(&app.db, r.id) |
|
| 92 | + | .await? |
|
| 93 | + | .into_iter() |
|
| 94 | + | .map(|f| CoverageFile { |
|
| 95 | + | percent: f.percent(), |
|
| 96 | + | path: f.path, |
|
| 97 | + | lines_found: f.lines_found, |
|
| 98 | + | lines_hit: f.lines_hit, |
|
| 99 | + | functions_found: f.functions_found, |
|
| 100 | + | functions_hit: f.functions_hit, |
|
| 101 | + | branches_found: f.branches_found, |
|
| 102 | + | branches_hit: f.branches_hit, |
|
| 103 | + | }) |
|
| 104 | + | .collect(); |
|
| 105 | + | Ok(Json(CoverageDetail { report: coverage_of(&app, &format!("/{owner}/{name}"), &r), files })) |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | /// `GET /repos/{o}/{n}/tests`. |
|
| 109 | + | pub async fn tests_list( |
|
| 110 | + | State(app): State<Shared>, |
|
| 111 | + | headers: HeaderMap, |
|
| 112 | + | Path((owner, name)): Path<(String, String)>, |
|
| 113 | + | Query(q): Query<ListQuery>, |
|
| 114 | + | ) -> Result<Json<Vec<TestReport>>, ApiError> { |
|
| 115 | + | let c = caller(&app, &headers).await?; |
|
| 116 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 117 | + | let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 118 | + | let list = junit::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?; |
|
| 119 | + | let base = format!("/{owner}/{name}"); |
|
| 120 | + | Ok(Json(list.iter().map(|r| tests_of(&app, &base, r)).collect())) |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | /// `GET /repos/{o}/{n}/tests/{id}`. |
|
| 124 | + | pub async fn tests_one( |
|
| 125 | + | State(app): State<Shared>, |
|
| 126 | + | headers: HeaderMap, |
|
| 127 | + | Path((owner, name, id)): Path<(String, String, i64)>, |
|
| 128 | + | Query(q): Query<CasesQuery>, |
|
| 129 | + | ) -> Result<Json<TestReportDetail>, ApiError> { |
|
| 130 | + | let c = caller(&app, &headers).await?; |
|
| 131 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 132 | + | let r = junit::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such test report"))?; |
|
| 133 | + | let statuses: &[&str] = match q.status.as_deref() { |
|
| 134 | + | Some("failed") => &["failed", "error"], |
|
| 135 | + | _ => &[], |
|
| 136 | + | }; |
|
| 137 | + | let cases = junit::cases(&app.db, r.id, statuses, junit::MAX_CASES as i64) |
|
| 138 | + | .await? |
|
| 139 | + | .into_iter() |
|
| 140 | + | .map(|c| TestCase { |
|
| 141 | + | suite: c.suite, |
|
| 142 | + | classname: c.classname, |
|
| 143 | + | name: c.name, |
|
| 144 | + | status: c.status, |
|
| 145 | + | time_ms: c.time_ms, |
|
| 146 | + | message: c.message, |
|
| 147 | + | output: c.output, |
|
| 148 | + | }) |
|
| 149 | + | .collect(); |
|
| 150 | + | Ok(Json(TestReportDetail { report: tests_of(&app, &format!("/{owner}/{name}"), &r), cases })) |
|
| 151 | + | } |
modified crates/hub-api/src/lib.rs +90 −0
| … | 1140 unchanged lines |
||
| 1141 | 1141 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 1142 | 1142 | pub inherited: Vec<(String, String)>, |
|
| 1143 | 1143 | } |
|
| 1144 | + | ||
| 1145 | + | /// `GET /repos/{o}/{n}/coverage`: a coverage report from a Flow job |
|
| 1146 | + | /// (oxygenhub#113); `percent` values are of lines, functions and branches, |
|
| 1147 | + | /// absent when nothing was counted. |
|
| 1148 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] |
|
| 1149 | + | pub struct CoverageReport { |
|
| 1150 | + | pub id: i64, |
|
| 1151 | + | pub run: i64, |
|
| 1152 | + | pub job: String, |
|
| 1153 | + | pub bookmark: String, |
|
| 1154 | + | /// The run's state, by hash. |
|
| 1155 | + | pub state: String, |
|
| 1156 | + | /// `lcov` or `cobertura`. |
|
| 1157 | + | pub format: String, |
|
| 1158 | + | pub lines_found: i64, |
|
| 1159 | + | pub lines_hit: i64, |
|
| 1160 | + | pub percent: Option<f64>, |
|
| 1161 | + | pub functions_found: i64, |
|
| 1162 | + | pub functions_hit: i64, |
|
| 1163 | + | pub branches_found: i64, |
|
| 1164 | + | pub branches_hit: i64, |
|
| 1165 | + | pub files: i64, |
|
| 1166 | + | /// Files the report named that are not in the run's state. |
|
| 1167 | + | pub dropped: i64, |
|
| 1168 | + | pub created_ms: i64, |
|
| 1169 | + | /// The report on the hub's Coverage tab, absolute. |
|
| 1170 | + | pub url: String, |
|
| 1171 | + | } |
|
| 1172 | + | ||
| 1173 | + | /// One file of a coverage report. |
|
| 1174 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] |
|
| 1175 | + | pub struct CoverageFile { |
|
| 1176 | + | pub path: String, |
|
| 1177 | + | pub lines_found: i64, |
|
| 1178 | + | pub lines_hit: i64, |
|
| 1179 | + | pub percent: Option<f64>, |
|
| 1180 | + | pub functions_found: i64, |
|
| 1181 | + | pub functions_hit: i64, |
|
| 1182 | + | pub branches_found: i64, |
|
| 1183 | + | pub branches_hit: i64, |
|
| 1184 | + | } |
|
| 1185 | + | ||
| 1186 | + | /// `GET /repos/{o}/{n}/coverage/{id}`: a report with its files. |
|
| 1187 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] |
|
| 1188 | + | pub struct CoverageDetail { |
|
| 1189 | + | pub report: CoverageReport, |
|
| 1190 | + | pub files: Vec<CoverageFile>, |
|
| 1191 | + | } |
|
| 1192 | + | ||
| 1193 | + | /// `GET /repos/{o}/{n}/tests`: a JUnit test report from a Flow job |
|
| 1194 | + | /// (oxygenhub#114). |
|
| 1195 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1196 | + | pub struct TestReport { |
|
| 1197 | + | pub id: i64, |
|
| 1198 | + | pub run: i64, |
|
| 1199 | + | pub job: String, |
|
| 1200 | + | pub bookmark: String, |
|
| 1201 | + | pub state: String, |
|
| 1202 | + | pub tests: i64, |
|
| 1203 | + | pub passed: i64, |
|
| 1204 | + | pub failed: i64, |
|
| 1205 | + | pub errors: i64, |
|
| 1206 | + | pub skipped: i64, |
|
| 1207 | + | /// The cases' times added up. |
|
| 1208 | + | pub time_ms: i64, |
|
| 1209 | + | pub created_ms: i64, |
|
| 1210 | + | pub url: String, |
|
| 1211 | + | } |
|
| 1212 | + | ||
| 1213 | + | /// One test case of a report. |
|
| 1214 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1215 | + | pub struct TestCase { |
|
| 1216 | + | pub suite: String, |
|
| 1217 | + | pub classname: String, |
|
| 1218 | + | pub name: String, |
|
| 1219 | + | /// `passed`, `failed`, `error` or `skipped`. |
|
| 1220 | + | pub status: String, |
|
| 1221 | + | pub time_ms: i64, |
|
| 1222 | + | pub message: String, |
|
| 1223 | + | /// A failure's text and output, cut to 16 KiB; empty for a pass. |
|
| 1224 | + | pub output: String, |
|
| 1225 | + | } |
|
| 1226 | + | ||
| 1227 | + | /// `GET /repos/{o}/{n}/tests/{id}`: a report with its cases |
|
| 1228 | + | /// (`?status=failed` for the failed and errored ones). |
|
| 1229 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1230 | + | pub struct TestReportDetail { |
|
| 1231 | + | pub report: TestReport, |
|
| 1232 | + | pub cases: Vec<TestCase>, |
|
| 1233 | + | } |
modified crates/hub/src/api/mod.rs +6 −1
| … | 16 unchanged lines |
||
| 17 | 17 | ||
| 18 | 18 | pub mod device; |
|
| 19 | 19 | mod projects; |
|
| 20 | + | mod reports; |
|
| 20 | 21 | ||
| 21 | 22 | /// Any other path under `/api/v1`: a problem document, not the site's page. |
|
| 22 | 23 | /// `GET /api/v1/version`: the hub's version and build, and the Oxygen and |
|
| … | 75 unchanged lines |
||
| 98 | 99 | .route("/repos/{owner}/{name}/labels", get(labels).post(create_label)) |
|
| 99 | 100 | .route("/repos/{owner}/{name}/labels/{label}", axum::routing::delete(delete_label)) |
|
| 100 | 101 | .route("/repos/{owner}/{name}/milestones", get(milestones).post(create_milestone)) |
|
| 102 | + | .route("/repos/{owner}/{name}/coverage", get(reports::coverage_list)) |
|
| 103 | + | .route("/repos/{owner}/{name}/coverage/{id}", get(reports::coverage_one)) |
|
| 104 | + | .route("/repos/{owner}/{name}/tests", get(reports::tests_list)) |
|
| 105 | + | .route("/repos/{owner}/{name}/tests/{id}", get(reports::tests_one)) |
|
| 101 | 106 | .route("/repos/{owner}/{name}/milestones/{title}", axum::routing::patch(edit_milestone)) |
|
| 102 | 107 | .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook)) |
|
| 103 | 108 | .route("/repos/{owner}/{name}/assistants", get(repo_assistants).post(repo_assistant_put)) |
|
| … | 241 unchanged lines |
||
| 345 | 350 | } |
|
| 346 | 351 | ||
| 347 | 352 | /// A repository the caller may reach with `access`, else 404. |
|
| 348 | - | async fn reach( |
|
| 353 | + | pub(super) async fn reach( |
|
| 349 | 354 | app: &Shared, |
|
| 350 | 355 | c: &Caller, |
|
| 351 | 356 | owner: &str, |
|
| … | 2017 unchanged lines |
modified crates/hub/src/junit.rs +18 −0
| … | 325 unchanged lines |
||
| 326 | 326 | sqlx::query_as("SELECT * FROM test_reports WHERE run_id = ? ORDER BY job").bind(run_id).fetch_all(pool).await |
|
| 327 | 327 | } |
|
| 328 | 328 | ||
| 329 | + | /// A report's cases in report order, all or only those with a status |
|
| 330 | + | /// among `statuses`. |
|
| 331 | + | pub async fn cases(pool: &Pool, report_id: i64, statuses: &[&str], limit: i64) -> sqlx::Result<Vec<Case>> { |
|
| 332 | + | let filter = if statuses.is_empty() { |
|
| 333 | + | String::new() |
|
| 334 | + | } else { |
|
| 335 | + | format!(" AND status IN ({})", statuses.iter().map(|_| "?").collect::<Vec<_>>().join(", ")) |
|
| 336 | + | }; |
|
| 337 | + | let sql = format!( |
|
| 338 | + | "SELECT suite, classname, name, status, time_ms, message, output FROM test_cases WHERE report_id = ?{filter} ORDER BY idx LIMIT ?" |
|
| 339 | + | ); |
|
| 340 | + | let mut q = sqlx::query_as(&sql).bind(report_id); |
|
| 341 | + | for s in statuses { |
|
| 342 | + | q = q.bind(*s); |
|
| 343 | + | } |
|
| 344 | + | q.bind(limit).fetch_all(pool).await |
|
| 345 | + | } |
|
| 346 | + | ||
| 329 | 347 | /// A report's failed and errored cases, in report order. |
|
| 330 | 348 | pub async fn failures(pool: &Pool, report_id: i64, limit: i64) -> sqlx::Result<Vec<Case>> { |
|
| 331 | 349 | sqlx::query_as( |
|
| … | 170 unchanged lines |
modified crates/hub/tests/flow.rs +22 −0
| … | 2228 unchanged lines |
||
| 2229 | 2229 | assert!(badge.contains("coverage: 50.0%"), "{badge}"); |
|
| 2230 | 2230 | let (_, run_page) = call(&h.app, "GET", &format!("/andy/cov/flow/{}", run.number), None, None).await; |
|
| 2231 | 2231 | assert!(run_page.contains("coverage 50.0%"), "{run_page}"); |
|
| 2232 | + | // The API (#113). |
|
| 2233 | + | let (_, list) = call(&h.app, "GET", "/api/v1/repos/andy/cov/coverage", None, None).await; |
|
| 2234 | + | let list: serde_json::Value = serde_json::from_str(&list).unwrap(); |
|
| 2235 | + | assert_eq!((list[0]["percent"].as_f64(), list[0]["format"].as_str()), (Some(50.0), Some("lcov")), "{list}"); |
|
| 2236 | + | let (_, one) = call(&h.app, "GET", &format!("/api/v1/repos/andy/cov/coverage/{report}"), None, None).await; |
|
| 2237 | + | let one: serde_json::Value = serde_json::from_str(&one).unwrap(); |
|
| 2238 | + | assert_eq!((one["files"][0]["path"].as_str(), one["files"][0]["lines_hit"].as_i64()), (Some("src/lib.rs"), Some(2)), "{one}"); |
|
| 2232 | 2239 | ||
| 2233 | 2240 | // The `coverage` check on the run's state: it reports, and with a |
|
| 2234 | 2241 | // minimum the repository sets, it fails below it. |
|
| … | 91 unchanged lines |
||
| 2326 | 2333 | let (_, page) = call(&h.app, "GET", &format!("/andy/t/tests?report={second}"), None, None).await; |
|
| 2327 | 2334 | assert!(page.contains("1 failing") && page.contains("Newly failing") && page.contains("b broke"), "{page}"); |
|
| 2328 | 2335 | assert!(page.contains("1 new tests"), "d is new"); |
|
| 2336 | + | ||
| 2337 | + | // The API (#114): the bookmark's reports newest first, and one with |
|
| 2338 | + | // its failed cases. |
|
| 2339 | + | let (st, list) = call(&h.app, "GET", "/api/v1/repos/andy/t/tests", None, None).await; |
|
| 2340 | + | assert_eq!(st, StatusCode::OK, "{list}"); |
|
| 2341 | + | let list: serde_json::Value = serde_json::from_str(&list).unwrap(); |
|
| 2342 | + | assert_eq!(list.as_array().unwrap().len(), 3); |
|
| 2343 | + | assert_eq!((list[0]["tests"].as_i64(), list[1]["failed"].as_i64()), (Some(4), Some(1))); |
|
| 2344 | + | let (_, one) = call(&h.app, "GET", &format!("/api/v1/repos/andy/t/tests/{second}?status=failed"), None, None).await; |
|
| 2345 | + | let one: serde_json::Value = serde_json::from_str(&one).unwrap(); |
|
| 2346 | + | assert_eq!(one["cases"].as_array().unwrap().len(), 1); |
|
| 2347 | + | assert_eq!((one["cases"][0]["name"].as_str(), one["cases"][0]["message"].as_str()), (Some("b"), Some("b broke"))); |
|
| 2348 | + | let (_, all) = call(&h.app, "GET", &format!("/api/v1/repos/andy/t/tests/{second}"), None, None).await; |
|
| 2349 | + | assert_eq!(serde_json::from_str::<serde_json::Value>(&all).unwrap()["cases"].as_array().unwrap().len(), 4); |
|
| 2350 | + | assert_eq!(call(&h.app, "GET", "/api/v1/repos/andy/t/tests/99999", None, None).await.0, StatusCode::NOT_FOUND); |
|
| 2329 | 2351 | } |
modified docs/spec.md +4 −3
| … | 459 unchanged lines |
||
| 460 | 460 | ||
| 461 | 461 | ### 12.1 API |
|
| 462 | 462 | ||
| 463 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/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), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 463 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 464 | 464 | ||
| 465 | 465 | ### 12.2 Webhooks |
|
| 466 | 466 | ||
| … | 113 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) |
|
| 583 | + | ### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06) |
|
| 584 | 584 | ||
| 585 | 585 | Coverage is built in, as Coveralls or Codecov would be, fed by OxygenFlow. |
|
| 586 | 586 | ||
| … | 3 unchanged lines |
||
| 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:** the API. |
|
| 593 | + | - **API:** `GET /repos/{o}/{n}/coverage` and `/coverage/{id}` (§12.1). |
|
| 594 | 594 | ||
| 595 | 595 | ### 13.7 Test reports (oxygenhub#114, first two parts done 2026-10-05) |
|
| 596 | 596 | ||
| … | 2 unchanged lines |
||
| 599 | 599 | - **Stored.** `test_reports` keeps the counts and summed test time per report with its run, job, bookmark and state; `test_cases` every case with its status and time, and for a failure or error its message (2 KiB) and its text with the case's output (16 KiB). |
|
| 600 | 600 | - **Run page.** Beside each job its counts, linked to a block above its log: passed, failed, errored and skipped, then the first 50 failures, failures first in report order, each collapsible to its message and output. |
|
| 601 | 601 | - **Tests tab.** Shown once a repository has a report: the bookmark's latest pass rate (or how many fail) over a chart of up to 200 runs, the counts and test time against the run before, the tests newly failing and fixed against that job's previous report, tests that both failed and passed in the last 30 reports (flaky), the run's failures, a passed-and-failed chart, the slowest tests and recent runs. `?report=` shows any report, `?bookmark=` another bookmark. |
|
| 602 | + | - **API:** `GET /repos/{o}/{n}/tests` and `/tests/{id}` (§12.1). |
|
| 602 | 603 | - **Next:** a test's history across runs, and proposals naming the tests that pass on the target and fail on the proposal. |
|
| 603 | 604 | ||
| 604 | 605 | ## 14. Git interoperability |
|
| … | 146 unchanged lines |