OXYGENHUB

Coverage / crates/hub/src/api/reports.rs 93.7%

193 of 206 lines run, 27 of 72 functions · run #498 on main · run missed

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
273×
fn coverage_of(app: &Shared, base: &str, r: &coverage::Report) -> CoverageReport {
28
    CoverageReport {
293×
        id: r.id,
303×
        run: r.run_number,
313×
        job: r.job.clone(),
323×
        bookmark: r.bookmark.clone(),
333×
        state: r.state.clone(),
343×
        format: r.format.clone(),
353×
        lines_found: r.lines_found,
363×
        lines_hit: r.lines_hit,
373×
        percent: r.percent(),
383×
        functions_found: r.functions_found,
393×
        functions_hit: r.functions_hit,
403×
        branches_found: r.branches_found,
413×
        branches_hit: r.branches_hit,
423×
        files: r.files,
433×
        dropped: r.dropped,
443×
        created_ms: r.created_ms,
453×
        url: format!("{}{base}/coverage?report={}", app.config.base_url().trim_end_matches('/'), r.id),
463×
        uploaded_by: r.uploaded().then(|| r.uploader.clone()),
47
    }
483×
}
49
505×
fn tests_of(app: &Shared, base: &str, r: &junit::Report) -> TestReport {
515×
    TestReport {
525×
        id: r.id,
535×
        run: r.run_number,
545×
        job: r.job.clone(),
555×
        bookmark: r.bookmark.clone(),
565×
        state: r.state.clone(),
575×
        tests: r.tests,
585×
        passed: r.passed,
595×
        failed: r.failed,
605×
        errors: r.errors,
615×
        skipped: r.skipped,
625×
        time_ms: r.time_ms,
635×
        created_ms: r.created_ms,
645×
        url: format!("{}{base}/tests?report={}", app.config.base_url().trim_end_matches('/'), r.id),
655×
    }
665×
}
67
68
/// `GET /repos/{o}/{n}/coverage`.
691×
pub async fn coverage_list(
701×
    State(app): State<Shared>,
711×
    headers: HeaderMap,
721×
    Path((owner, name)): Path<(String, String)>,
731×
    Query(q): Query<ListQuery>,
741×
) -> Result<Json<Vec<CoverageReport>>, ApiError> {
751×
    let c = caller(&app, &headers).await?;
761×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
771×
    let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
781×
    let list = coverage::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?;
791×
    let base = format!("/{owner}/{name}");
801×
    Ok(Json(list.iter().map(|r| coverage_of(&app, &base, r)).collect()))
811×
}
82
83
#[derive(Deserialize)]
84
pub struct Upload {
85
    /// The bookmark whose current state it measured (default: the default
86
    /// bookmark), or `change`: the state whose only head is that change.
87
    #[serde(default)]
88
    bookmark: Option<String>,
89
    #[serde(default)]
90
    change: Option<String>,
91
    /// The report: LCOV, Cobertura XML or JaCoCo XML.
92
    content: String,
93
}
94
95
/// `POST /repos/{o}/{n}/coverage` (repo:write, #136): coverage measured off
96
/// the hub, as a workstation's, kept for a state with the uploader's name.
97
/// It is read against that state's files as a job's artifact is, and sets
98
/// no check: no runner measured it.
993×
pub async fn coverage_upload(
1003×
    State(app): State<Shared>,
1013×
    headers: HeaderMap,
1023×
    Path((owner, name)): Path<(String, String)>,
1033×
    Json(u): Json<Upload>,
1043×
) -> Result<(axum::http::StatusCode, Json<CoverageReport>), ApiError> {
105
    use axum::http::StatusCode;
106
    use oxy_core::hash::ChangeHash;
1073×
    let c = caller(&app, &headers).await?;
1083×
    let me = c.require("repo:write")?.clone();
1092×
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
1102×
    if u.content.len() > coverage::MAX_REPORT {
1110×
        return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("at most {} MiB of coverage is read", coverage::MAX_REPORT >> 20)));
1122×
    }
1132×
    let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m);
114
    // The state it measured: a change's, or a bookmark's now.
1152×
    let (bookmark, heads) = match u.change.as_deref().map(str::trim).filter(|c| !c.is_empty()) {
1160×
        Some(ch) => {
1170×
            let h = ChangeHash::from_hex(ch.trim_start_matches('#')).ok_or_else(|| bad("give the full hash of a change".into()))?;
1180×
            (String::new(), vec![h])
119
        }
120
        None => {
1212×
            let b = u.bookmark.clone().filter(|b| !b.is_empty()).unwrap_or_else(|| row.default_bookmark.clone());
1222×
            let bb = b.clone();
1232×
            let heads = app
1242×
                .repos
1252×
                .read(&repo, move |r, _| match r.view()?.bookmarks.get(&bb) {
1261×
                    Some(st) => Ok(Some(r.store().get_state(st)?.heads)),
1271×
                    None => Ok(None),
1282×
                })
1292×
                .await
1302×
                .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1312×
                .ok_or_else(|| bad(format!("no bookmark {b}")))?;
1321×
            (b, heads)
133
        }
134
    };
1351×
    let state =
1361×
        oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads.clone()).encode());
1371×
    let text = u.content;
1381×
    let (files, outside, format) = tokio::task::spawn_blocking({
1391×
        let t = text.clone();
1401×
        move || coverage::parse(&t)
141
    })
1421×
    .await
1431×
    .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1441×
    .map_err(|e| bad(format!("coverage: {e}")))?;
1451×
    let h = heads.clone();
1461×
    let (blob, in_state) = app
1471×
        .repos
1481×
        .write(&repo, move |r, cache| {
1491×
            let files: std::collections::HashSet<String> = cache.load(r, &h)?.snapshot.files.keys().cloned().collect();
1501×
            Ok((r.store_blob(text.as_bytes())?, files))
1511×
        })
1521×
        .await
1531×
        .map_err(|e| bad(format!("that state's files could not be read: {e}")))?;
1541×
    let hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
1551×
    let origin = coverage::Origin {
1561×
        repo_id: row.id,
1571×
        run_id: None,
1581×
        run_number: 0,
1591×
        job_id: None,
1601×
        uploader: &me.username,
1611×
        job: "upload",
1621×
        bookmark: &bookmark,
1631×
        state: &state.to_hex(),
1641×
        heads: &hex,
1651×
        blob: &blob.to_hex(),
1661×
        format,
1671×
    };
1681×
    let id = coverage::keep(&app, &origin, files, outside, &in_state).await?;
1691×
    let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("the report was not kept"))?;
1701×
    crate::db::accounts::audit(&app.db, Some(&me), "coverage.upload", &format!("{owner}/{name}"), &r.source()).await.ok();
1711×
    Ok((StatusCode::CREATED, Json(coverage_of(&app, &format!("/{owner}/{name}"), &r))))
1723×
}
173
174
/// `GET /repos/{o}/{n}/coverage/{id}`.
1751×
pub async fn coverage_one(
1761×
    State(app): State<Shared>,
1771×
    headers: HeaderMap,
1781×
    Path((owner, name, id)): Path<(String, String, i64)>,
1791×
) -> Result<Json<CoverageDetail>, ApiError> {
1801×
    let c = caller(&app, &headers).await?;
1811×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1821×
    let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such coverage report"))?;
1831×
    let files = coverage::files(&app.db, r.id)
1841×
        .await?
1851×
        .into_iter()
1861×
        .map(|f| CoverageFile {
1871×
            percent: f.percent(),
1881×
            path: f.path,
1891×
            lines_found: f.lines_found,
1901×
            lines_hit: f.lines_hit,
1911×
            functions_found: f.functions_found,
1921×
            functions_hit: f.functions_hit,
1931×
            branches_found: f.branches_found,
1941×
            branches_hit: f.branches_hit,
1951×
        })
1961×
        .collect();
1971×
    Ok(Json(CoverageDetail { report: coverage_of(&app, &format!("/{owner}/{name}"), &r), files }))
1981×
}
199
200
/// `GET /repos/{o}/{n}/tests`.
2011×
pub async fn tests_list(
2021×
    State(app): State<Shared>,
2031×
    headers: HeaderMap,
2041×
    Path((owner, name)): Path<(String, String)>,
2051×
    Query(q): Query<ListQuery>,
2061×
) -> Result<Json<Vec<TestReport>>, ApiError> {
2071×
    let c = caller(&app, &headers).await?;
2081×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
2091×
    let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
2101×
    let list = junit::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?;
2111×
    let base = format!("/{owner}/{name}");
2123×
    Ok(Json(list.iter().map(|r| tests_of(&app, &base, r)).collect()))
2131×
}
214
215
/// `GET /repos/{o}/{n}/tests/{id}`.
2163×
pub async fn tests_one(
2173×
    State(app): State<Shared>,
2183×
    headers: HeaderMap,
2193×
    Path((owner, name, id)): Path<(String, String, i64)>,
2203×
    Query(q): Query<CasesQuery>,
2213×
) -> Result<Json<TestReportDetail>, ApiError> {
2223×
    let c = caller(&app, &headers).await?;
2233×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
2243×
    let r = junit::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such test report"))?;
2252×
    let statuses: &[&str] = match q.status.as_deref() {
2261×
        Some("failed") => &["failed", "error"],
2271×
        _ => &[],
228
    };
2292×
    let cases = junit::cases(&app.db, r.id, statuses, junit::MAX_CASES as i64)
2302×
        .await?
2312×
        .into_iter()
2322×
        .map(|c| TestCase {
2335×
            suite: c.suite,
2345×
            classname: c.classname,
2355×
            name: c.name,
2365×
            status: c.status,
2375×
            time_ms: c.time_ms,
2385×
            message: c.message,
2395×
            output: c.output,
2405×
        })
2412×
        .collect();
2422×
    Ok(Json(TestReportDetail { report: tests_of(&app, &format!("/{owner}/{name}"), &r), cases }))
2433×
}