//! Coverage (oxygenhub#113), test reports (#114) and the reports Flow jobs //! publish (#348) over the API: what the Coverage, Tests and Reports tabs //! show, for whoever may read the repository. use super::{caller, not_found, reach, ApiError}; use crate::auth::Access; use crate::{coverage, junit, reports, Shared}; use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderMap, HeaderValue}; use axum::response::{IntoResponse, Response}; use axum::Json; use hub_api::{CoverageDetail, CoverageFile, CoverageReport, Report, ReportFile, ReportMetric, TestCase, TestReport, TestReportDetail}; use serde::Deserialize; #[derive(Deserialize, Default)] pub struct ListQuery { /// The bookmark (default: the repository's default). bookmark: Option, /// At most this many, newest first (default 20, at most 200). limit: Option, } #[derive(Deserialize, Default)] pub struct CasesQuery { /// `failed` for the failed and errored cases only. status: Option, } fn coverage_of(app: &Shared, base: &str, r: &coverage::Report) -> CoverageReport { CoverageReport { id: r.id, run: r.run_number, job: r.job.clone(), bookmark: r.bookmark.clone(), state: r.state.clone(), format: r.format.clone(), lines_found: r.lines_found, lines_hit: r.lines_hit, percent: r.percent(), functions_found: r.functions_found, functions_hit: r.functions_hit, branches_found: r.branches_found, branches_hit: r.branches_hit, files: r.files, dropped: r.dropped, created_ms: r.created_ms, url: format!("{}{base}/coverage?report={}", app.config.base_url().trim_end_matches('/'), r.id), uploaded_by: r.uploaded().then(|| r.uploader.clone()), } } fn tests_of(app: &Shared, base: &str, r: &junit::Report) -> TestReport { TestReport { id: r.id, run: r.run_number, job: r.job.clone(), bookmark: r.bookmark.clone(), state: r.state.clone(), tests: r.tests, passed: r.passed, failed: r.failed, errors: r.errors, skipped: r.skipped, time_ms: r.time_ms, created_ms: r.created_ms, url: format!("{}{base}/tests?report={}", app.config.base_url().trim_end_matches('/'), r.id), } } /// `GET /repos/{o}/{n}/coverage`. pub async fn coverage_list( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); let list = coverage::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?; let base = format!("/{owner}/{name}"); Ok(Json(list.iter().map(|r| coverage_of(&app, &base, r)).collect())) } #[derive(Deserialize)] pub struct Upload { /// The bookmark whose current state it measured (default: the default /// bookmark), or `change`: the state whose only head is that change. #[serde(default)] bookmark: Option, #[serde(default)] change: Option, /// The report: LCOV, Cobertura XML or JaCoCo XML. content: String, } /// `POST /repos/{o}/{n}/coverage` (repo:write, #136): coverage measured off /// the hub, as a workstation's, kept for a state with the uploader's name. /// It is read against that state's files as a job's artifact is, and sets /// no check: no runner measured it. pub async fn coverage_upload( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(u): Json, ) -> Result<(axum::http::StatusCode, Json), ApiError> { use axum::http::StatusCode; use oxy_core::hash::ChangeHash; let c = caller(&app, &headers).await?; let me = c.require("repo:write")?.clone(); let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; if u.content.len() > coverage::MAX_REPORT { return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("at most {} MiB of coverage is read", coverage::MAX_REPORT >> 20))); } let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m); // The state it measured: a change's, or a bookmark's now. let (bookmark, heads) = match u.change.as_deref().map(str::trim).filter(|c| !c.is_empty()) { Some(ch) => { let h = ChangeHash::from_hex(ch.trim_start_matches('#')).ok_or_else(|| bad("give the full hash of a change".into()))?; (String::new(), vec![h]) } None => { let b = u.bookmark.clone().filter(|b| !b.is_empty()).unwrap_or_else(|| row.default_bookmark.clone()); let bb = b.clone(); let heads = app .repos .read(&repo, move |r, _| match r.view()?.bookmarks.get(&bb) { Some(st) => Ok(Some(r.store().get_state(st)?.heads)), None => Ok(None), }) .await .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or_else(|| bad(format!("no bookmark {b}")))?; (b, heads) } }; let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads.clone()).encode()); let text = u.content; let (files, outside, format) = tokio::task::spawn_blocking({ let t = text.clone(); move || coverage::parse(&t) }) .await .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| bad(format!("coverage: {e}")))?; let h = heads.clone(); let (blob, in_state) = app .repos .write(&repo, move |r, cache| { let files: std::collections::HashSet = cache.load(r, &h)?.snapshot.files.keys().cloned().collect(); Ok((r.store_blob(text.as_bytes())?, files)) }) .await .map_err(|e| bad(format!("that state's files could not be read: {e}")))?; let hex: Vec = heads.iter().map(|h| h.to_hex()).collect(); let origin = coverage::Origin { repo_id: row.id, run_id: None, run_number: 0, job_id: None, uploader: &me.username, job: "upload", bookmark: &bookmark, state: &state.to_hex(), heads: &hex, blob: &blob.to_hex(), format, }; let id = coverage::keep(&app, &origin, files, outside, &in_state).await?; let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("the report was not kept"))?; crate::db::accounts::audit(&app.db, Some(&me), "coverage.upload", &format!("{owner}/{name}"), &r.source()).await.ok(); Ok((StatusCode::CREATED, Json(coverage_of(&app, &format!("/{owner}/{name}"), &r)))) } /// `GET /repos/{o}/{n}/coverage/{id}`. pub async fn coverage_one( State(app): State, headers: HeaderMap, Path((owner, name, id)): Path<(String, String, i64)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such coverage report"))?; let files = coverage::files(&app.db, r.id) .await? .into_iter() .map(|f| CoverageFile { percent: f.percent(), path: f.path, lines_found: f.lines_found, lines_hit: f.lines_hit, functions_found: f.functions_found, functions_hit: f.functions_hit, branches_found: f.branches_found, branches_hit: f.branches_hit, }) .collect(); Ok(Json(CoverageDetail { report: coverage_of(&app, &format!("/{owner}/{name}"), &r), files })) } /// `GET /repos/{o}/{n}/tests`. pub async fn tests_list( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); let list = junit::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?; let base = format!("/{owner}/{name}"); Ok(Json(list.iter().map(|r| tests_of(&app, &base, r)).collect())) } /// `GET /repos/{o}/{n}/tests/{id}`. pub async fn tests_one( State(app): State, headers: HeaderMap, Path((owner, name, id)): Path<(String, String, i64)>, Query(q): Query, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let r = junit::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such test report"))?; let statuses: &[&str] = match q.status.as_deref() { Some("failed") => &["failed", "error"], _ => &[], }; let cases = junit::cases(&app.db, r.id, statuses, junit::MAX_CASES as i64) .await? .into_iter() .map(|c| TestCase { suite: c.suite, classname: c.classname, name: c.name, status: c.status, time_ms: c.time_ms, message: c.message, output: c.output, }) .collect(); Ok(Json(TestReportDetail { report: tests_of(&app, &format!("/{owner}/{name}"), &r), cases })) } #[derive(Deserialize, Default)] pub struct ReportQuery { /// Which of a job's reports, when it published more than one. name: Option, /// One run of a report, by its id (the web page's `?id=`). id: Option, /// The bookmark whose newest run of a report to take (default: the /// repository's default, else wherever it ran last). bookmark: Option, } /// A kept report as the API shows it, with its metrics; with its files /// when `files` says so. async fn report_of(app: &Shared, base: &str, r: &reports::Report, files: bool) -> Result { let metrics = reports::metrics(&app.db, r.id) .await? .into_iter() .map(|m| ReportMetric { key: m.key, value: m.value, unit: m.unit, better: m.better, fail_above: m.fail_above, fail_below: m.fail_below, }) .collect(); let manifest = if files { reports::files_of(&app.db, r.id).await?.into_iter().map(|(path, size)| ReportFile { path, size }).collect() } else { vec![] }; Ok(Report { id: r.id, name: r.name.clone(), kind: r.kind.clone(), title: r.title.clone(), summary: r.summary.clone(), metrics, context: serde_json::from_str(&r.context).unwrap_or_default(), run: r.run_number, job: r.job.clone(), bookmark: r.bookmark.clone(), state: r.state.clone(), files: r.files, bytes: r.bytes, created_ms: r.created_ms, files_expired_ms: r.files_expired_ms, url: format!("{}{base}/reports/{}?id={}", app.config.base_url().trim_end_matches('/'), r.name, r.id), manifest, }) } /// The report a job of run `number` published: the only one, or the one /// `name` names. The errors say what there is. async fn job_report(app: &Shared, repo_id: i64, number: i64, job: &str, name: Option<&str>) -> Result { let run = crate::db::flow::run(&app.db, repo_id, number).await?.ok_or_else(|| not_found(&format!("no run #{number}")))?; let jobs = crate::db::flow::jobs(&app.db, run.id).await?; let Some(j) = jobs.iter().find(|j| j.name == job) else { let names: Vec<&str> = jobs.iter().map(|j| j.name.as_str()).collect(); return Err(not_found(&format!("run #{number} has no job {job}; its jobs: {}", names.join(", ")))); }; let mut list = reports::of_job(&app.db, j.id).await?; let names = || list.iter().map(|r| r.name.as_str()).collect::>().join(", "); match name { Some(n) => match list.iter().position(|r| r.name == n) { Some(i) => Ok(list.swap_remove(i)), None if list.is_empty() => Err(not_found(&format!("job {job} of run #{number} published no report"))), None => Err(not_found(&format!("job {job} of run #{number} published no report {n}; it published {}", names()))), }, None if list.len() == 1 => Ok(list.remove(0)), None if list.is_empty() => Err(not_found(&format!("job {job} of run #{number} published no report"))), None => Err(ApiError( axum::http::StatusCode::BAD_REQUEST, format!("job {job} of run #{number} published {} reports ({}): say which with ?name=", list.len(), names()), )), } } /// Report `name`'s run `id`, or its newest on `bookmark` (default: the /// repository's default bookmark, else wherever it ran last), as the /// report's web page picks it. async fn named_report(app: &Shared, row: &crate::db::repos::RepoRow, name: &str, q: &ReportQuery) -> Result { if let Some(id) = q.id { return reports::get(&app.db, row.id, id) .await? .filter(|r| r.name == name) .ok_or_else(|| not_found(&format!("report {name} has no run with id {id}"))); } let bookmarks = reports::bookmarks_of(&app.db, row.id, name).await?; if bookmarks.is_empty() { return Err(not_found(&format!("no report {name} in {}/{}", row.owner, row.name))); } let bookmark = match q.bookmark.clone().filter(|b| !b.is_empty()) { Some(b) if bookmarks.contains(&b) => b, Some(b) => return Err(not_found(&format!("report {name} never ran on {b}; it ran on {}", bookmarks.join(", ")))), None => bookmarks.iter().find(|b| **b == row.default_bookmark).unwrap_or(&bookmarks[0]).clone(), }; reports::history(&app.db, row.id, name, &bookmark, 1).await?.into_iter().next().ok_or_else(|| not_found("no such report")) } /// A report's file as it was published. It is served as a download would /// be: under a sandbox that runs nothing, so a page in it can do nothing /// on the hub's own origin. async fn report_file(app: &Shared, repo: &crate::repos::RepoName, r: &reports::Report, path: &str) -> Result { if let Some(when) = r.files_expired_ms { return Err(ApiError( axum::http::StatusCode::GONE, format!("report {} (id {}) lost its files under retention at {when} ms; its metrics stay", r.name, r.id), )); } let Some((blob, _)) = reports::file(&app.db, r.id, path).await? else { let files: Vec = reports::files_of(&app.db, r.id).await?.into_iter().map(|(p, _)| p).take(20).collect(); return Err(not_found(&format!("report {} has no file {path}; it has {}", r.name, files.join(", ")))); }; let oref = oxy_core::hash::ObjectRef::from_hex(&blob).ok_or_else(|| not_found("the file's blob is unreadable"))?; let bytes = app .repos .read(repo, move |r, _| Ok(r.read_blob(&oref)?)) .await .map_err(|e| ApiError(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("the file could not be read: {e}")))?; let mut res = (axum::http::StatusCode::OK, bytes).into_response(); let h = res.headers_mut(); h.insert(header::CONTENT_TYPE, HeaderValue::from_static(crate::web::reports::content_type(path))); h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("sandbox; default-src 'none'")); h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); Ok(res) } /// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/reports`: the reports /// a job published (oxygenhub#348). pub async fn job_reports( State(app): State, headers: HeaderMap, Path((owner, name, number, job)): Path<(String, String, i64, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let run = crate::db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| not_found(&format!("no run #{number}")))?; let j = crate::db::flow::jobs(&app.db, run.id) .await? .into_iter() .find(|j| j.name == job) .ok_or_else(|| not_found(&format!("run #{number} has no job {job}")))?; let base = format!("/{owner}/{name}"); let mut out = vec![]; for r in reports::of_job(&app.db, j.id).await? { out.push(report_of(&app, &base, &r, false).await?); } Ok(Json(out)) } /// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/report[?name=]`: the /// report a job published, with its files (oxygenhub#348). pub async fn job_report_one( State(app): State, headers: HeaderMap, Path((owner, name, number, job)): Path<(String, String, i64, String)>, Query(q): Query, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let r = job_report(&app, row.id, number, &job, q.name.as_deref()).await?; Ok(Json(report_of(&app, &format!("/{owner}/{name}"), &r, true).await?)) } /// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/report/{*path}[?name=]`: /// one file of it, as published. pub async fn job_report_file( State(app): State, headers: HeaderMap, Path((owner, name, number, job, path)): Path<(String, String, i64, String, String)>, Query(q): Query, ) -> Result { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let r = job_report(&app, row.id, number, &job, q.name.as_deref()).await?; report_file(&app, &repo, &r, &path).await } /// `GET /repos/{o}/{n}/reports[?bookmark=]`: the newest run of each report, /// on `bookmark` when given, newest first (oxygenhub#348). pub async fn latest_reports( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let base = format!("/{owner}/{name}"); let mut out = vec![]; for r in reports::latest(&app.db, row.id, q.bookmark.as_deref().filter(|b| !b.is_empty())).await? { out.push(report_of(&app, &base, &r, false).await?); } Ok(Json(out)) } /// `GET /repos/{o}/{n}/reports/{report}[?id=|?bookmark=]`: one run of a /// report with its files: by id, else the newest on the bookmark. pub async fn named_report_one( State(app): State, headers: HeaderMap, Path((owner, name, report)): Path<(String, String, String)>, Query(q): Query, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let r = named_report(&app, &row, &report, &q).await?; Ok(Json(report_of(&app, &format!("/{owner}/{name}"), &r, true).await?)) } /// `GET /repos/{o}/{n}/reports/{report}/files/{*path}[?id=|?bookmark=]`. pub async fn named_report_file( State(app): State, headers: HeaderMap, Path((owner, name, report, path)): Path<(String, String, String, String)>, Query(q): Query, ) -> Result { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let r = named_report(&app, &row, &report, &q).await?; report_file(&app, &repo, &r, &path).await }