//! Coverage (oxygenhub#113) and test reports (#114) over the API: what the //! Coverage and Tests tabs show, for whoever may read the repository. use super::{caller, not_found, reach, ApiError}; use crate::auth::Access; use crate::{coverage, junit, Shared}; use axum::extract::{Path, Query, State}; use axum::http::HeaderMap; use axum::Json; use hub_api::{CoverageDetail, CoverageFile, CoverageReport, 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 })) }