OXYGENHUB

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

377 of 408 lines run, 59 of 152 functions · run #536 on main · run missed

1
//! Coverage (oxygenhub#113), test reports (#114) and the reports Flow jobs
2
//! publish (#348) over the API: what the Coverage, Tests and Reports tabs
3
//! show, for whoever may read the repository.
4
5
use super::{caller, not_found, reach, ApiError};
6
use crate::auth::Access;
7
use crate::{coverage, junit, reports, Shared};
8
use axum::extract::{Path, Query, State};
9
use axum::http::{header, HeaderMap, HeaderValue};
10
use axum::response::{IntoResponse, Response};
11
use axum::Json;
12
use hub_api::{CoverageDetail, CoverageFile, CoverageReport, Report, ReportFile, ReportMetric, TestCase, TestReport, TestReportDetail};
13
use serde::Deserialize;
14
15
#[derive(Deserialize, Default)]
16
pub struct ListQuery {
17
    /// The bookmark (default: the repository's default).
18
    bookmark: Option<String>,
19
    /// At most this many, newest first (default 20, at most 200).
20
    limit: Option<i64>,
21
}
22
23
#[derive(Deserialize, Default)]
24
pub struct CasesQuery {
25
    /// `failed` for the failed and errored cases only.
26
    status: Option<String>,
27
}
28
293×
fn coverage_of(app: &Shared, base: &str, r: &coverage::Report) -> CoverageReport {
30
    CoverageReport {
313×
        id: r.id,
323×
        run: r.run_number,
333×
        job: r.job.clone(),
343×
        bookmark: r.bookmark.clone(),
353×
        state: r.state.clone(),
363×
        format: r.format.clone(),
373×
        lines_found: r.lines_found,
383×
        lines_hit: r.lines_hit,
393×
        percent: r.percent(),
403×
        functions_found: r.functions_found,
413×
        functions_hit: r.functions_hit,
423×
        branches_found: r.branches_found,
433×
        branches_hit: r.branches_hit,
443×
        files: r.files,
453×
        dropped: r.dropped,
463×
        created_ms: r.created_ms,
473×
        url: format!("{}{base}/coverage?report={}", app.config.base_url().trim_end_matches('/'), r.id),
483×
        uploaded_by: r.uploaded().then(|| r.uploader.clone()),
49
    }
503×
}
51
525×
fn tests_of(app: &Shared, base: &str, r: &junit::Report) -> TestReport {
535×
    TestReport {
545×
        id: r.id,
555×
        run: r.run_number,
565×
        job: r.job.clone(),
575×
        bookmark: r.bookmark.clone(),
585×
        state: r.state.clone(),
595×
        tests: r.tests,
605×
        passed: r.passed,
615×
        failed: r.failed,
625×
        errors: r.errors,
635×
        skipped: r.skipped,
645×
        time_ms: r.time_ms,
655×
        created_ms: r.created_ms,
665×
        url: format!("{}{base}/tests?report={}", app.config.base_url().trim_end_matches('/'), r.id),
675×
    }
685×
}
69
70
/// `GET /repos/{o}/{n}/coverage`.
711×
pub async fn coverage_list(
721×
    State(app): State<Shared>,
731×
    headers: HeaderMap,
741×
    Path((owner, name)): Path<(String, String)>,
751×
    Query(q): Query<ListQuery>,
761×
) -> Result<Json<Vec<CoverageReport>>, ApiError> {
771×
    let c = caller(&app, &headers).await?;
781×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
791×
    let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
801×
    let list = coverage::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?;
811×
    let base = format!("/{owner}/{name}");
821×
    Ok(Json(list.iter().map(|r| coverage_of(&app, &base, r)).collect()))
831×
}
84
85
#[derive(Deserialize)]
86
pub struct Upload {
87
    /// The bookmark whose current state it measured (default: the default
88
    /// bookmark), or `change`: the state whose only head is that change.
89
    #[serde(default)]
90
    bookmark: Option<String>,
91
    #[serde(default)]
92
    change: Option<String>,
93
    /// The report: LCOV, Cobertura XML or JaCoCo XML.
94
    content: String,
95
}
96
97
/// `POST /repos/{o}/{n}/coverage` (repo:write, #136): coverage measured off
98
/// the hub, as a workstation's, kept for a state with the uploader's name.
99
/// It is read against that state's files as a job's artifact is, and sets
100
/// no check: no runner measured it.
1013×
pub async fn coverage_upload(
1023×
    State(app): State<Shared>,
1033×
    headers: HeaderMap,
1043×
    Path((owner, name)): Path<(String, String)>,
1053×
    Json(u): Json<Upload>,
1063×
) -> Result<(axum::http::StatusCode, Json<CoverageReport>), ApiError> {
107
    use axum::http::StatusCode;
108
    use oxy_core::hash::ChangeHash;
1093×
    let c = caller(&app, &headers).await?;
1103×
    let me = c.require("repo:write")?.clone();
1112×
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
1122×
    if u.content.len() > coverage::MAX_REPORT {
1130×
        return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("at most {} MiB of coverage is read", coverage::MAX_REPORT >> 20)));
1142×
    }
1152×
    let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m);
116
    // The state it measured: a change's, or a bookmark's now.
1172×
    let (bookmark, heads) = match u.change.as_deref().map(str::trim).filter(|c| !c.is_empty()) {
1180×
        Some(ch) => {
1190×
            let h = ChangeHash::from_hex(ch.trim_start_matches('#')).ok_or_else(|| bad("give the full hash of a change".into()))?;
1200×
            (String::new(), vec![h])
121
        }
122
        None => {
1232×
            let b = u.bookmark.clone().filter(|b| !b.is_empty()).unwrap_or_else(|| row.default_bookmark.clone());
1242×
            let bb = b.clone();
1252×
            let heads = app
1262×
                .repos
1272×
                .read(&repo, move |r, _| match r.view()?.bookmarks.get(&bb) {
1281×
                    Some(st) => Ok(Some(r.store().get_state(st)?.heads)),
1291×
                    None => Ok(None),
1302×
                })
1312×
                .await
1322×
                .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1332×
                .ok_or_else(|| bad(format!("no bookmark {b}")))?;
1341×
            (b, heads)
135
        }
136
    };
1371×
    let state =
1381×
        oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads.clone()).encode());
1391×
    let text = u.content;
1401×
    let (files, outside, format) = tokio::task::spawn_blocking({
1411×
        let t = text.clone();
1421×
        move || coverage::parse(&t)
143
    })
1441×
    .await
1451×
    .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1461×
    .map_err(|e| bad(format!("coverage: {e}")))?;
1471×
    let h = heads.clone();
1481×
    let (blob, in_state) = app
1491×
        .repos
1501×
        .write(&repo, move |r, cache| {
1511×
            let files: std::collections::HashSet<String> = cache.load(r, &h)?.snapshot.files.keys().cloned().collect();
1521×
            Ok((r.store_blob(text.as_bytes())?, files))
1531×
        })
1541×
        .await
1551×
        .map_err(|e| bad(format!("that state's files could not be read: {e}")))?;
1561×
    let hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
1571×
    let origin = coverage::Origin {
1581×
        repo_id: row.id,
1591×
        run_id: None,
1601×
        run_number: 0,
1611×
        job_id: None,
1621×
        uploader: &me.username,
1631×
        job: "upload",
1641×
        bookmark: &bookmark,
1651×
        state: &state.to_hex(),
1661×
        heads: &hex,
1671×
        blob: &blob.to_hex(),
1681×
        format,
1691×
    };
1701×
    let id = coverage::keep(&app, &origin, files, outside, &in_state).await?;
1711×
    let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("the report was not kept"))?;
1721×
    crate::db::accounts::audit(&app.db, Some(&me), "coverage.upload", &format!("{owner}/{name}"), &r.source()).await.ok();
1731×
    Ok((StatusCode::CREATED, Json(coverage_of(&app, &format!("/{owner}/{name}"), &r))))
1743×
}
175
176
/// `GET /repos/{o}/{n}/coverage/{id}`.
1771×
pub async fn coverage_one(
1781×
    State(app): State<Shared>,
1791×
    headers: HeaderMap,
1801×
    Path((owner, name, id)): Path<(String, String, i64)>,
1811×
) -> Result<Json<CoverageDetail>, ApiError> {
1821×
    let c = caller(&app, &headers).await?;
1831×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1841×
    let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such coverage report"))?;
1851×
    let files = coverage::files(&app.db, r.id)
1861×
        .await?
1871×
        .into_iter()
1881×
        .map(|f| CoverageFile {
1891×
            percent: f.percent(),
1901×
            path: f.path,
1911×
            lines_found: f.lines_found,
1921×
            lines_hit: f.lines_hit,
1931×
            functions_found: f.functions_found,
1941×
            functions_hit: f.functions_hit,
1951×
            branches_found: f.branches_found,
1961×
            branches_hit: f.branches_hit,
1971×
        })
1981×
        .collect();
1991×
    Ok(Json(CoverageDetail { report: coverage_of(&app, &format!("/{owner}/{name}"), &r), files }))
2001×
}
201
202
/// `GET /repos/{o}/{n}/tests`.
2031×
pub async fn tests_list(
2041×
    State(app): State<Shared>,
2051×
    headers: HeaderMap,
2061×
    Path((owner, name)): Path<(String, String)>,
2071×
    Query(q): Query<ListQuery>,
2081×
) -> Result<Json<Vec<TestReport>>, ApiError> {
2091×
    let c = caller(&app, &headers).await?;
2101×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
2111×
    let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
2121×
    let list = junit::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?;
2131×
    let base = format!("/{owner}/{name}");
2143×
    Ok(Json(list.iter().map(|r| tests_of(&app, &base, r)).collect()))
2151×
}
216
217
/// `GET /repos/{o}/{n}/tests/{id}`.
2183×
pub async fn tests_one(
2193×
    State(app): State<Shared>,
2203×
    headers: HeaderMap,
2213×
    Path((owner, name, id)): Path<(String, String, i64)>,
2223×
    Query(q): Query<CasesQuery>,
2233×
) -> Result<Json<TestReportDetail>, ApiError> {
2243×
    let c = caller(&app, &headers).await?;
2253×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
2263×
    let r = junit::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such test report"))?;
2272×
    let statuses: &[&str] = match q.status.as_deref() {
2281×
        Some("failed") => &["failed", "error"],
2291×
        _ => &[],
230
    };
2312×
    let cases = junit::cases(&app.db, r.id, statuses, junit::MAX_CASES as i64)
2322×
        .await?
2332×
        .into_iter()
2342×
        .map(|c| TestCase {
2355×
            suite: c.suite,
2365×
            classname: c.classname,
2375×
            name: c.name,
2385×
            status: c.status,
2395×
            time_ms: c.time_ms,
2405×
            message: c.message,
2415×
            output: c.output,
2425×
        })
2432×
        .collect();
2442×
    Ok(Json(TestReportDetail { report: tests_of(&app, &format!("/{owner}/{name}"), &r), cases }))
2453×
}
246
247
#[derive(Deserialize, Default)]
248
pub struct ReportQuery {
249
    /// Which of a job's reports, when it published more than one.
250
    name: Option<String>,
251
    /// One run of a report, by its id (the web page's `?id=`).
252
    id: Option<i64>,
253
    /// The bookmark whose newest run of a report to take (default: the
254
    /// repository's default, else wherever it ran last).
255
    bookmark: Option<String>,
256
}
257
258
/// A kept report as the API shows it, with its metrics; with its files
259
/// when `files` says so.
2605×
async fn report_of(app: &Shared, base: &str, r: &reports::Report, files: bool) -> Result<Report, ApiError> {
2615×
    let metrics = reports::metrics(&app.db, r.id)
2625×
        .await?
2635×
        .into_iter()
2645×
        .map(|m| ReportMetric {
2655×
            key: m.key,
2665×
            value: m.value,
2675×
            unit: m.unit,
2685×
            better: m.better,
2695×
            fail_above: m.fail_above,
2705×
            fail_below: m.fail_below,
2715×
        })
2725×
        .collect();
2735×
    let manifest = if files {
2746×
        reports::files_of(&app.db, r.id).await?.into_iter().map(|(path, size)| ReportFile { path, size }).collect()
275
    } else {
2762×
        vec![]
277
    };
2785×
    Ok(Report {
2795×
        id: r.id,
2805×
        name: r.name.clone(),
2815×
        kind: r.kind.clone(),
2825×
        title: r.title.clone(),
2835×
        summary: r.summary.clone(),
2845×
        metrics,
2855×
        context: serde_json::from_str(&r.context).unwrap_or_default(),
2865×
        run: r.run_number,
2875×
        job: r.job.clone(),
2885×
        bookmark: r.bookmark.clone(),
2895×
        state: r.state.clone(),
2905×
        files: r.files,
2915×
        bytes: r.bytes,
2925×
        created_ms: r.created_ms,
2935×
        files_expired_ms: r.files_expired_ms,
2945×
        url: format!("{}{base}/reports/{}?id={}", app.config.base_url().trim_end_matches('/'), r.name, r.id),
2955×
        manifest,
2965×
    })
2975×
}
298
299
/// The report a job of run `number` published: the only one, or the one
300
/// `name` names. The errors say what there is.
3016×
async fn job_report(app: &Shared, repo_id: i64, number: i64, job: &str, name: Option<&str>) -> Result<reports::Report, ApiError> {
3026×
    let run = crate::db::flow::run(&app.db, repo_id, number).await?.ok_or_else(|| not_found(&format!("no run #{number}")))?;
3036×
    let jobs = crate::db::flow::jobs(&app.db, run.id).await?;
3046×
    let Some(j) = jobs.iter().find(|j| j.name == job) else {
3051×
        let names: Vec<&str> = jobs.iter().map(|j| j.name.as_str()).collect();
3061×
        return Err(not_found(&format!("run #{number} has no job {job}; its jobs: {}", names.join(", "))));
307
    };
3085×
    let mut list = reports::of_job(&app.db, j.id).await?;
3095×
    let names = || list.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ");
3100×
    match name {
3112×
        Some(n) => match list.iter().position(|r| r.name == n) {
3121×
            Some(i) => Ok(list.swap_remove(i)),
3131×
            None if list.is_empty() => Err(not_found(&format!("job {job} of run #{number} published no report"))),
3141×
            None => Err(not_found(&format!("job {job} of run #{number} published no report {n}; it published {}", names()))),
315
        },
3163×
        None if list.len() == 1 => Ok(list.remove(0)),
3170×
        None if list.is_empty() => Err(not_found(&format!("job {job} of run #{number} published no report"))),
3180×
        None => Err(ApiError(
3190×
            axum::http::StatusCode::BAD_REQUEST,
3200×
            format!("job {job} of run #{number} published {} reports ({}): say which with ?name=", list.len(), names()),
3210×
        )),
322
    }
3236×
}
324
325
/// Report `name`'s run `id`, or its newest on `bookmark` (default: the
326
/// repository's default bookmark, else wherever it ran last), as the
327
/// report's web page picks it.
3285×
async fn named_report(app: &Shared, row: &crate::db::repos::RepoRow, name: &str, q: &ReportQuery) -> Result<reports::Report, ApiError> {
3295×
    if let Some(id) = q.id {
3302×
        return reports::get(&app.db, row.id, id)
3312×
            .await?
3322×
            .filter(|r| r.name == name)
3332×
            .ok_or_else(|| not_found(&format!("report {name} has no run with id {id}")));
3343×
    }
3353×
    let bookmarks = reports::bookmarks_of(&app.db, row.id, name).await?;
3363×
    if bookmarks.is_empty() {
3371×
        return Err(not_found(&format!("no report {name} in {}/{}", row.owner, row.name)));
3382×
    }
3392×
    let bookmark = match q.bookmark.clone().filter(|b| !b.is_empty()) {
3401×
        Some(b) if bookmarks.contains(&b) => b,
3411×
        Some(b) => return Err(not_found(&format!("report {name} never ran on {b}; it ran on {}", bookmarks.join(", ")))),
3421×
        None => bookmarks.iter().find(|b| **b == row.default_bookmark).unwrap_or(&bookmarks[0]).clone(),
343
    };
3441×
    reports::history(&app.db, row.id, name, &bookmark, 1).await?.into_iter().next().ok_or_else(|| not_found("no such report"))
3455×
}
346
347
/// A report's file as it was published. It is served as a download would
348
/// be: under a sandbox that runs nothing, so a page in it can do nothing
349
/// on the hub's own origin.
3504×
async fn report_file(app: &Shared, repo: &crate::repos::RepoName, r: &reports::Report, path: &str) -> Result<Response, ApiError> {
3514×
    if let Some(when) = r.files_expired_ms {
3520×
        return Err(ApiError(
3530×
            axum::http::StatusCode::GONE,
3540×
            format!("report {} (id {}) lost its files under retention at {when} ms; its metrics stay", r.name, r.id),
3550×
        ));
3564×
    }
3574×
    let Some((blob, _)) = reports::file(&app.db, r.id, path).await? else {
3581×
        let files: Vec<String> = reports::files_of(&app.db, r.id).await?.into_iter().map(|(p, _)| p).take(20).collect();
3591×
        return Err(not_found(&format!("report {} has no file {path}; it has {}", r.name, files.join(", "))));
360
    };
3613×
    let oref = oxy_core::hash::ObjectRef::from_hex(&blob).ok_or_else(|| not_found("the file's blob is unreadable"))?;
3623×
    let bytes = app
3633×
        .repos
3643×
        .read(repo, move |r, _| Ok(r.read_blob(&oref)?))
3653×
        .await
3663×
        .map_err(|e| ApiError(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("the file could not be read: {e}")))?;
3673×
    let mut res = (axum::http::StatusCode::OK, bytes).into_response();
3683×
    let h = res.headers_mut();
3693×
    h.insert(header::CONTENT_TYPE, HeaderValue::from_static(crate::web::reports::content_type(path)));
3703×
    h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("sandbox; default-src 'none'"));
3713×
    h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
3723×
    Ok(res)
3734×
}
374
375
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/reports`: the reports
376
/// a job published (oxygenhub#348).
3771×
pub async fn job_reports(
3781×
    State(app): State<Shared>,
3791×
    headers: HeaderMap,
3801×
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
3811×
) -> Result<Json<Vec<Report>>, ApiError> {
3821×
    let c = caller(&app, &headers).await?;
3831×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
3841×
    let run = crate::db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| not_found(&format!("no run #{number}")))?;
3851×
    let j = crate::db::flow::jobs(&app.db, run.id)
3861×
        .await?
3871×
        .into_iter()
3881×
        .find(|j| j.name == job)
3891×
        .ok_or_else(|| not_found(&format!("run #{number} has no job {job}")))?;
3901×
    let base = format!("/{owner}/{name}");
3911×
    let mut out = vec![];
3921×
    for r in reports::of_job(&app.db, j.id).await? {
3931×
        out.push(report_of(&app, &base, &r, false).await?);
394
    }
3951×
    Ok(Json(out))
3961×
}
397
398
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/report[?name=]`: the
399
/// report a job published, with its files (oxygenhub#348).
4003×
pub async fn job_report_one(
4013×
    State(app): State<Shared>,
4023×
    headers: HeaderMap,
4033×
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
4043×
    Query(q): Query<ReportQuery>,
4053×
) -> Result<Json<Report>, ApiError> {
4063×
    let c = caller(&app, &headers).await?;
4073×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
4083×
    let r = job_report(&app, row.id, number, &job, q.name.as_deref()).await?;
4091×
    Ok(Json(report_of(&app, &format!("/{owner}/{name}"), &r, true).await?))
4103×
}
411
412
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/report/{*path}[?name=]`:
413
/// one file of it, as published.
4143×
pub async fn job_report_file(
4153×
    State(app): State<Shared>,
4163×
    headers: HeaderMap,
4173×
    Path((owner, name, number, job, path)): Path<(String, String, i64, String, String)>,
4183×
    Query(q): Query<ReportQuery>,
4193×
) -> Result<Response, ApiError> {
4203×
    let c = caller(&app, &headers).await?;
4213×
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
4223×
    let r = job_report(&app, row.id, number, &job, q.name.as_deref()).await?;
4233×
    report_file(&app, &repo, &r, &path).await
4243×
}
425
426
/// `GET /repos/{o}/{n}/reports[?bookmark=]`: the newest run of each report,
427
/// on `bookmark` when given, newest first (oxygenhub#348).
4281×
pub async fn latest_reports(
4291×
    State(app): State<Shared>,
4301×
    headers: HeaderMap,
4311×
    Path((owner, name)): Path<(String, String)>,
4321×
    Query(q): Query<ReportQuery>,
4331×
) -> Result<Json<Vec<Report>>, ApiError> {
4341×
    let c = caller(&app, &headers).await?;
4351×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
4361×
    let base = format!("/{owner}/{name}");
4371×
    let mut out = vec![];
4381×
    for r in reports::latest(&app.db, row.id, q.bookmark.as_deref().filter(|b| !b.is_empty())).await? {
4391×
        out.push(report_of(&app, &base, &r, false).await?);
440
    }
4411×
    Ok(Json(out))
4421×
}
443
444
/// `GET /repos/{o}/{n}/reports/{report}[?id=|?bookmark=]`: one run of a
445
/// report with its files: by id, else the newest on the bookmark.
4464×
pub async fn named_report_one(
4474×
    State(app): State<Shared>,
4484×
    headers: HeaderMap,
4494×
    Path((owner, name, report)): Path<(String, String, String)>,
4504×
    Query(q): Query<ReportQuery>,
4514×
) -> Result<Json<Report>, ApiError> {
4524×
    let c = caller(&app, &headers).await?;
4534×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
4544×
    let r = named_report(&app, &row, &report, &q).await?;
4552×
    Ok(Json(report_of(&app, &format!("/{owner}/{name}"), &r, true).await?))
4564×
}
457
458
/// `GET /repos/{o}/{n}/reports/{report}/files/{*path}[?id=|?bookmark=]`.
4591×
pub async fn named_report_file(
4601×
    State(app): State<Shared>,
4611×
    headers: HeaderMap,
4621×
    Path((owner, name, report, path)): Path<(String, String, String, String)>,
4631×
    Query(q): Query<ReportQuery>,
4641×
) -> Result<Response, ApiError> {
4651×
    let c = caller(&app, &headers).await?;
4661×
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
4671×
    let r = named_report(&app, &row, &report, &q).await?;
4681×
    report_file(&app, &repo, &r, &path).await
4691×
}