OXYGENHUB
◆ v0.9.22 oxygenhub / crates / hub / src / api / reports.rs
19.7 KiB · Rust · last changed Flow job reports and timings over the API, oh run report and oh report; 0.9.14 (closes #348) 7 days ago source blame history raw
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
29
fn coverage_of(app: &Shared, base: &str, r: &coverage::Report) -> CoverageReport {
30
    CoverageReport {
31
        id: r.id,
32
        run: r.run_number,
33
        job: r.job.clone(),
34
        bookmark: r.bookmark.clone(),
35
        state: r.state.clone(),
36
        format: r.format.clone(),
37
        lines_found: r.lines_found,
38
        lines_hit: r.lines_hit,
39
        percent: r.percent(),
40
        functions_found: r.functions_found,
41
        functions_hit: r.functions_hit,
42
        branches_found: r.branches_found,
43
        branches_hit: r.branches_hit,
44
        files: r.files,
45
        dropped: r.dropped,
46
        created_ms: r.created_ms,
47
        url: format!("{}{base}/coverage?report={}", app.config.base_url().trim_end_matches('/'), r.id),
48
        uploaded_by: r.uploaded().then(|| r.uploader.clone()),
49
    }
50
}
51
52
fn tests_of(app: &Shared, base: &str, r: &junit::Report) -> TestReport {
53
    TestReport {
54
        id: r.id,
55
        run: r.run_number,
56
        job: r.job.clone(),
57
        bookmark: r.bookmark.clone(),
58
        state: r.state.clone(),
59
        tests: r.tests,
60
        passed: r.passed,
61
        failed: r.failed,
62
        errors: r.errors,
63
        skipped: r.skipped,
64
        time_ms: r.time_ms,
65
        created_ms: r.created_ms,
66
        url: format!("{}{base}/tests?report={}", app.config.base_url().trim_end_matches('/'), r.id),
67
    }
68
}
69
70
/// `GET /repos/{o}/{n}/coverage`.
71
pub async fn coverage_list(
72
    State(app): State<Shared>,
73
    headers: HeaderMap,
74
    Path((owner, name)): Path<(String, String)>,
75
    Query(q): Query<ListQuery>,
76
) -> Result<Json<Vec<CoverageReport>>, ApiError> {
77
    let c = caller(&app, &headers).await?;
78
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
79
    let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
80
    let list = coverage::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?;
81
    let base = format!("/{owner}/{name}");
82
    Ok(Json(list.iter().map(|r| coverage_of(&app, &base, r)).collect()))
83
}
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.
101
pub async fn coverage_upload(
102
    State(app): State<Shared>,
103
    headers: HeaderMap,
104
    Path((owner, name)): Path<(String, String)>,
105
    Json(u): Json<Upload>,
106
) -> Result<(axum::http::StatusCode, Json<CoverageReport>), ApiError> {
107
    use axum::http::StatusCode;
108
    use oxy_core::hash::ChangeHash;
109
    let c = caller(&app, &headers).await?;
110
    let me = c.require("repo:write")?.clone();
111
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
112
    if u.content.len() > coverage::MAX_REPORT {
113
        return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("at most {} MiB of coverage is read", coverage::MAX_REPORT >> 20)));
114
    }
115
    let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m);
116
    // The state it measured: a change's, or a bookmark's now.
117
    let (bookmark, heads) = match u.change.as_deref().map(str::trim).filter(|c| !c.is_empty()) {
118
        Some(ch) => {
119
            let h = ChangeHash::from_hex(ch.trim_start_matches('#')).ok_or_else(|| bad("give the full hash of a change".into()))?;
120
            (String::new(), vec![h])
121
        }
122
        None => {
123
            let b = u.bookmark.clone().filter(|b| !b.is_empty()).unwrap_or_else(|| row.default_bookmark.clone());
124
            let bb = b.clone();
125
            let heads = app
126
                .repos
127
                .read(&repo, move |r, _| match r.view()?.bookmarks.get(&bb) {
128
                    Some(st) => Ok(Some(r.store().get_state(st)?.heads)),
129
                    None => Ok(None),
130
                })
131
                .await
132
                .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
133
                .ok_or_else(|| bad(format!("no bookmark {b}")))?;
134
            (b, heads)
135
        }
136
    };
137
    let state =
138
        oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads.clone()).encode());
139
    let text = u.content;
140
    let (files, outside, format) = tokio::task::spawn_blocking({
141
        let t = text.clone();
142
        move || coverage::parse(&t)
143
    })
144
    .await
145
    .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
146
    .map_err(|e| bad(format!("coverage: {e}")))?;
147
    let h = heads.clone();
148
    let (blob, in_state) = app
149
        .repos
150
        .write(&repo, move |r, cache| {
151
            let files: std::collections::HashSet<String> = cache.load(r, &h)?.snapshot.files.keys().cloned().collect();
152
            Ok((r.store_blob(text.as_bytes())?, files))
153
        })
154
        .await
155
        .map_err(|e| bad(format!("that state's files could not be read: {e}")))?;
156
    let hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
157
    let origin = coverage::Origin {
158
        repo_id: row.id,
159
        run_id: None,
160
        run_number: 0,
161
        job_id: None,
162
        uploader: &me.username,
163
        job: "upload",
164
        bookmark: &bookmark,
165
        state: &state.to_hex(),
166
        heads: &hex,
167
        blob: &blob.to_hex(),
168
        format,
169
    };
170
    let id = coverage::keep(&app, &origin, files, outside, &in_state).await?;
171
    let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("the report was not kept"))?;
172
    crate::db::accounts::audit(&app.db, Some(&me), "coverage.upload", &format!("{owner}/{name}"), &r.source()).await.ok();
173
    Ok((StatusCode::CREATED, Json(coverage_of(&app, &format!("/{owner}/{name}"), &r))))
174
}
175
176
/// `GET /repos/{o}/{n}/coverage/{id}`.
177
pub async fn coverage_one(
178
    State(app): State<Shared>,
179
    headers: HeaderMap,
180
    Path((owner, name, id)): Path<(String, String, i64)>,
181
) -> Result<Json<CoverageDetail>, ApiError> {
182
    let c = caller(&app, &headers).await?;
183
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
184
    let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such coverage report"))?;
185
    let files = coverage::files(&app.db, r.id)
186
        .await?
187
        .into_iter()
188
        .map(|f| CoverageFile {
189
            percent: f.percent(),
190
            path: f.path,
191
            lines_found: f.lines_found,
192
            lines_hit: f.lines_hit,
193
            functions_found: f.functions_found,
194
            functions_hit: f.functions_hit,
195
            branches_found: f.branches_found,
196
            branches_hit: f.branches_hit,
197
        })
198
        .collect();
199
    Ok(Json(CoverageDetail { report: coverage_of(&app, &format!("/{owner}/{name}"), &r), files }))
200
}
201
202
/// `GET /repos/{o}/{n}/tests`.
203
pub async fn tests_list(
204
    State(app): State<Shared>,
205
    headers: HeaderMap,
206
    Path((owner, name)): Path<(String, String)>,
207
    Query(q): Query<ListQuery>,
208
) -> Result<Json<Vec<TestReport>>, ApiError> {
209
    let c = caller(&app, &headers).await?;
210
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
211
    let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
212
    let list = junit::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?;
213
    let base = format!("/{owner}/{name}");
214
    Ok(Json(list.iter().map(|r| tests_of(&app, &base, r)).collect()))
215
}
216
217
/// `GET /repos/{o}/{n}/tests/{id}`.
218
pub async fn tests_one(
219
    State(app): State<Shared>,
220
    headers: HeaderMap,
221
    Path((owner, name, id)): Path<(String, String, i64)>,
222
    Query(q): Query<CasesQuery>,
223
) -> Result<Json<TestReportDetail>, ApiError> {
224
    let c = caller(&app, &headers).await?;
225
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
226
    let r = junit::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such test report"))?;
227
    let statuses: &[&str] = match q.status.as_deref() {
228
        Some("failed") => &["failed", "error"],
229
        _ => &[],
230
    };
231
    let cases = junit::cases(&app.db, r.id, statuses, junit::MAX_CASES as i64)
232
        .await?
233
        .into_iter()
234
        .map(|c| TestCase {
235
            suite: c.suite,
236
            classname: c.classname,
237
            name: c.name,
238
            status: c.status,
239
            time_ms: c.time_ms,
240
            message: c.message,
241
            output: c.output,
242
        })
243
        .collect();
244
    Ok(Json(TestReportDetail { report: tests_of(&app, &format!("/{owner}/{name}"), &r), cases }))
245
}
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.
260
async fn report_of(app: &Shared, base: &str, r: &reports::Report, files: bool) -> Result<Report, ApiError> {
261
    let metrics = reports::metrics(&app.db, r.id)
262
        .await?
263
        .into_iter()
264
        .map(|m| ReportMetric {
265
            key: m.key,
266
            value: m.value,
267
            unit: m.unit,
268
            better: m.better,
269
            fail_above: m.fail_above,
270
            fail_below: m.fail_below,
271
        })
272
        .collect();
273
    let manifest = if files {
274
        reports::files_of(&app.db, r.id).await?.into_iter().map(|(path, size)| ReportFile { path, size }).collect()
275
    } else {
276
        vec![]
277
    };
278
    Ok(Report {
279
        id: r.id,
280
        name: r.name.clone(),
281
        kind: r.kind.clone(),
282
        title: r.title.clone(),
283
        summary: r.summary.clone(),
284
        metrics,
285
        context: serde_json::from_str(&r.context).unwrap_or_default(),
286
        run: r.run_number,
287
        job: r.job.clone(),
288
        bookmark: r.bookmark.clone(),
289
        state: r.state.clone(),
290
        files: r.files,
291
        bytes: r.bytes,
292
        created_ms: r.created_ms,
293
        files_expired_ms: r.files_expired_ms,
294
        url: format!("{}{base}/reports/{}?id={}", app.config.base_url().trim_end_matches('/'), r.name, r.id),
295
        manifest,
296
    })
297
}
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.
301
async fn job_report(app: &Shared, repo_id: i64, number: i64, job: &str, name: Option<&str>) -> Result<reports::Report, ApiError> {
302
    let run = crate::db::flow::run(&app.db, repo_id, number).await?.ok_or_else(|| not_found(&format!("no run #{number}")))?;
303
    let jobs = crate::db::flow::jobs(&app.db, run.id).await?;
304
    let Some(j) = jobs.iter().find(|j| j.name == job) else {
305
        let names: Vec<&str> = jobs.iter().map(|j| j.name.as_str()).collect();
306
        return Err(not_found(&format!("run #{number} has no job {job}; its jobs: {}", names.join(", "))));
307
    };
308
    let mut list = reports::of_job(&app.db, j.id).await?;
309
    let names = || list.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ");
310
    match name {
311
        Some(n) => match list.iter().position(|r| r.name == n) {
312
            Some(i) => Ok(list.swap_remove(i)),
313
            None if list.is_empty() => Err(not_found(&format!("job {job} of run #{number} published no report"))),
314
            None => Err(not_found(&format!("job {job} of run #{number} published no report {n}; it published {}", names()))),
315
        },
316
        None if list.len() == 1 => Ok(list.remove(0)),
317
        None if list.is_empty() => Err(not_found(&format!("job {job} of run #{number} published no report"))),
318
        None => Err(ApiError(
319
            axum::http::StatusCode::BAD_REQUEST,
320
            format!("job {job} of run #{number} published {} reports ({}): say which with ?name=", list.len(), names()),
321
        )),
322
    }
323
}
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.
328
async fn named_report(app: &Shared, row: &crate::db::repos::RepoRow, name: &str, q: &ReportQuery) -> Result<reports::Report, ApiError> {
329
    if let Some(id) = q.id {
330
        return reports::get(&app.db, row.id, id)
331
            .await?
332
            .filter(|r| r.name == name)
333
            .ok_or_else(|| not_found(&format!("report {name} has no run with id {id}")));
334
    }
335
    let bookmarks = reports::bookmarks_of(&app.db, row.id, name).await?;
336
    if bookmarks.is_empty() {
337
        return Err(not_found(&format!("no report {name} in {}/{}", row.owner, row.name)));
338
    }
339
    let bookmark = match q.bookmark.clone().filter(|b| !b.is_empty()) {
340
        Some(b) if bookmarks.contains(&b) => b,
341
        Some(b) => return Err(not_found(&format!("report {name} never ran on {b}; it ran on {}", bookmarks.join(", ")))),
342
        None => bookmarks.iter().find(|b| **b == row.default_bookmark).unwrap_or(&bookmarks[0]).clone(),
343
    };
344
    reports::history(&app.db, row.id, name, &bookmark, 1).await?.into_iter().next().ok_or_else(|| not_found("no such report"))
345
}
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.
350
async fn report_file(app: &Shared, repo: &crate::repos::RepoName, r: &reports::Report, path: &str) -> Result<Response, ApiError> {
351
    if let Some(when) = r.files_expired_ms {
352
        return Err(ApiError(
353
            axum::http::StatusCode::GONE,
354
            format!("report {} (id {}) lost its files under retention at {when} ms; its metrics stay", r.name, r.id),
355
        ));
356
    }
357
    let Some((blob, _)) = reports::file(&app.db, r.id, path).await? else {
358
        let files: Vec<String> = reports::files_of(&app.db, r.id).await?.into_iter().map(|(p, _)| p).take(20).collect();
359
        return Err(not_found(&format!("report {} has no file {path}; it has {}", r.name, files.join(", "))));
360
    };
361
    let oref = oxy_core::hash::ObjectRef::from_hex(&blob).ok_or_else(|| not_found("the file's blob is unreadable"))?;
362
    let bytes = app
363
        .repos
364
        .read(repo, move |r, _| Ok(r.read_blob(&oref)?))
365
        .await
366
        .map_err(|e| ApiError(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("the file could not be read: {e}")))?;
367
    let mut res = (axum::http::StatusCode::OK, bytes).into_response();
368
    let h = res.headers_mut();
369
    h.insert(header::CONTENT_TYPE, HeaderValue::from_static(crate::web::reports::content_type(path)));
370
    h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("sandbox; default-src 'none'"));
371
    h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
372
    Ok(res)
373
}
374
375
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/reports`: the reports
376
/// a job published (oxygenhub#348).
377
pub async fn job_reports(
378
    State(app): State<Shared>,
379
    headers: HeaderMap,
380
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
381
) -> Result<Json<Vec<Report>>, ApiError> {
382
    let c = caller(&app, &headers).await?;
383
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
384
    let run = crate::db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| not_found(&format!("no run #{number}")))?;
385
    let j = crate::db::flow::jobs(&app.db, run.id)
386
        .await?
387
        .into_iter()
388
        .find(|j| j.name == job)
389
        .ok_or_else(|| not_found(&format!("run #{number} has no job {job}")))?;
390
    let base = format!("/{owner}/{name}");
391
    let mut out = vec![];
392
    for r in reports::of_job(&app.db, j.id).await? {
393
        out.push(report_of(&app, &base, &r, false).await?);
394
    }
395
    Ok(Json(out))
396
}
397
398
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/report[?name=]`: the
399
/// report a job published, with its files (oxygenhub#348).
400
pub async fn job_report_one(
401
    State(app): State<Shared>,
402
    headers: HeaderMap,
403
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
404
    Query(q): Query<ReportQuery>,
405
) -> Result<Json<Report>, ApiError> {
406
    let c = caller(&app, &headers).await?;
407
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
408
    let r = job_report(&app, row.id, number, &job, q.name.as_deref()).await?;
409
    Ok(Json(report_of(&app, &format!("/{owner}/{name}"), &r, true).await?))
410
}
411
412
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/report/{*path}[?name=]`:
413
/// one file of it, as published.
414
pub async fn job_report_file(
415
    State(app): State<Shared>,
416
    headers: HeaderMap,
417
    Path((owner, name, number, job, path)): Path<(String, String, i64, String, String)>,
418
    Query(q): Query<ReportQuery>,
419
) -> Result<Response, ApiError> {
420
    let c = caller(&app, &headers).await?;
421
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
422
    let r = job_report(&app, row.id, number, &job, q.name.as_deref()).await?;
423
    report_file(&app, &repo, &r, &path).await
424
}
425
426
/// `GET /repos/{o}/{n}/reports[?bookmark=]`: the newest run of each report,
427
/// on `bookmark` when given, newest first (oxygenhub#348).
428
pub async fn latest_reports(
429
    State(app): State<Shared>,
430
    headers: HeaderMap,
431
    Path((owner, name)): Path<(String, String)>,
432
    Query(q): Query<ReportQuery>,
433
) -> Result<Json<Vec<Report>>, ApiError> {
434
    let c = caller(&app, &headers).await?;
435
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
436
    let base = format!("/{owner}/{name}");
437
    let mut out = vec![];
438
    for r in reports::latest(&app.db, row.id, q.bookmark.as_deref().filter(|b| !b.is_empty())).await? {
439
        out.push(report_of(&app, &base, &r, false).await?);
440
    }
441
    Ok(Json(out))
442
}
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.
446
pub async fn named_report_one(
447
    State(app): State<Shared>,
448
    headers: HeaderMap,
449
    Path((owner, name, report)): Path<(String, String, String)>,
450
    Query(q): Query<ReportQuery>,
451
) -> Result<Json<Report>, ApiError> {
452
    let c = caller(&app, &headers).await?;
453
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
454
    let r = named_report(&app, &row, &report, &q).await?;
455
    Ok(Json(report_of(&app, &format!("/{owner}/{name}"), &r, true).await?))
456
}
457
458
/// `GET /repos/{o}/{n}/reports/{report}/files/{*path}[?id=|?bookmark=]`.
459
pub async fn named_report_file(
460
    State(app): State<Shared>,
461
    headers: HeaderMap,
462
    Path((owner, name, report, path)): Path<(String, String, String, String)>,
463
    Query(q): Query<ReportQuery>,
464
) -> Result<Response, ApiError> {
465
    let c = caller(&app, &headers).await?;
466
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
467
    let r = named_report(&app, &row, &report, &q).await?;
468
    report_file(&app, &repo, &r, &path).await
469
}