OXYGENHUB
# 76f93eaefe45 oxygenhub / crates / hub / src / api / reports.rs
9.4 KiB · Rust · last changed Coverage from a workstation: oh coverage upload, shown as uploaded 24 days ago source blame history raw
617c2840 Andy McCall ↶ 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
27
fn coverage_of(app: &Shared, base: &str, r: &coverage::Report) -> CoverageReport {
28
    CoverageReport {
29
        id: r.id,
30
        run: r.run_number,
31
        job: r.job.clone(),
32
        bookmark: r.bookmark.clone(),
33
        state: r.state.clone(),
34
        format: r.format.clone(),
35
        lines_found: r.lines_found,
36
        lines_hit: r.lines_hit,
37
        percent: r.percent(),
38
        functions_found: r.functions_found,
39
        functions_hit: r.functions_hit,
40
        branches_found: r.branches_found,
41
        branches_hit: r.branches_hit,
42
        files: r.files,
43
        dropped: r.dropped,
44
        created_ms: r.created_ms,
45
        url: format!("{}{base}/coverage?report={}", app.config.base_url().trim_end_matches('/'), r.id),
40dd3f86 Andy McCall ↶ 46
        uploaded_by: r.uploaded().then(|| r.uploader.clone()),
617c2840 Andy McCall ↶ 47
    }
48
}
49
50
fn tests_of(app: &Shared, base: &str, r: &junit::Report) -> TestReport {
51
    TestReport {
52
        id: r.id,
53
        run: r.run_number,
54
        job: r.job.clone(),
55
        bookmark: r.bookmark.clone(),
56
        state: r.state.clone(),
57
        tests: r.tests,
58
        passed: r.passed,
59
        failed: r.failed,
60
        errors: r.errors,
61
        skipped: r.skipped,
62
        time_ms: r.time_ms,
63
        created_ms: r.created_ms,
64
        url: format!("{}{base}/tests?report={}", app.config.base_url().trim_end_matches('/'), r.id),
65
    }
66
}
67
68
/// `GET /repos/{o}/{n}/coverage`.
69
pub async fn coverage_list(
70
    State(app): State<Shared>,
71
    headers: HeaderMap,
72
    Path((owner, name)): Path<(String, String)>,
73
    Query(q): Query<ListQuery>,
74
) -> Result<Json<Vec<CoverageReport>>, ApiError> {
75
    let c = caller(&app, &headers).await?;
76
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
77
    let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
78
    let list = coverage::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?;
79
    let base = format!("/{owner}/{name}");
80
    Ok(Json(list.iter().map(|r| coverage_of(&app, &base, r)).collect()))
81
}
82
40dd3f86 Andy McCall ↶ 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 or Cobertura 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.
99
pub async fn coverage_upload(
100
    State(app): State<Shared>,
101
    headers: HeaderMap,
102
    Path((owner, name)): Path<(String, String)>,
103
    Json(u): Json<Upload>,
104
) -> Result<(axum::http::StatusCode, Json<CoverageReport>), ApiError> {
105
    use axum::http::StatusCode;
106
    use oxy_core::hash::ChangeHash;
107
    let c = caller(&app, &headers).await?;
108
    let me = c.require("repo:write")?.clone();
109
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
110
    if u.content.len() > coverage::MAX_REPORT {
111
        return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("at most {} MiB of coverage is read", coverage::MAX_REPORT >> 20)));
112
    }
113
    let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m);
114
    // The state it measured: a change's, or a bookmark's now.
115
    let (bookmark, heads) = match u.change.as_deref().map(str::trim).filter(|c| !c.is_empty()) {
116
        Some(ch) => {
117
            let h = ChangeHash::from_hex(ch.trim_start_matches('#')).ok_or_else(|| bad("give the full hash of a change".into()))?;
118
            (String::new(), vec![h])
119
        }
120
        None => {
121
            let b = u.bookmark.clone().filter(|b| !b.is_empty()).unwrap_or_else(|| row.default_bookmark.clone());
122
            let bb = b.clone();
123
            let heads = app
124
                .repos
125
                .read(&repo, move |r, _| match r.view()?.bookmarks.get(&bb) {
126
                    Some(st) => Ok(Some(r.store().get_state(st)?.heads)),
127
                    None => Ok(None),
128
                })
129
                .await
130
                .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
131
                .ok_or_else(|| bad(format!("no bookmark {b}")))?;
132
            (b, heads)
133
        }
134
    };
135
    let state =
136
        oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads.clone()).encode());
137
    let text = u.content;
138
    let (files, outside, format) = tokio::task::spawn_blocking({
139
        let t = text.clone();
140
        move || coverage::parse(&t)
141
    })
142
    .await
143
    .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
144
    .map_err(|e| bad(format!("coverage: {e}")))?;
145
    let h = heads.clone();
146
    let (blob, in_state) = app
147
        .repos
148
        .write(&repo, move |r, cache| {
149
            let files: std::collections::HashSet<String> = cache.load(r, &h)?.snapshot.files.keys().cloned().collect();
150
            Ok((r.store_blob(text.as_bytes())?, files))
151
        })
152
        .await
153
        .map_err(|e| bad(format!("that state's files could not be read: {e}")))?;
154
    let hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
155
    let origin = coverage::Origin {
156
        repo_id: row.id,
157
        run_id: None,
158
        run_number: 0,
159
        job_id: None,
160
        uploader: &me.username,
161
        job: "upload",
162
        bookmark: &bookmark,
163
        state: &state.to_hex(),
164
        heads: &hex,
165
        blob: &blob.to_hex(),
166
        format,
167
    };
168
    let id = coverage::keep(&app, &origin, files, outside, &in_state).await?;
169
    let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("the report was not kept"))?;
170
    crate::db::accounts::audit(&app.db, Some(&me), "coverage.upload", &format!("{owner}/{name}"), &r.source()).await.ok();
171
    Ok((StatusCode::CREATED, Json(coverage_of(&app, &format!("/{owner}/{name}"), &r))))
172
}
173
617c2840 Andy McCall ↶ 174
/// `GET /repos/{o}/{n}/coverage/{id}`.
175
pub async fn coverage_one(
176
    State(app): State<Shared>,
177
    headers: HeaderMap,
178
    Path((owner, name, id)): Path<(String, String, i64)>,
179
) -> Result<Json<CoverageDetail>, ApiError> {
180
    let c = caller(&app, &headers).await?;
181
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
182
    let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such coverage report"))?;
183
    let files = coverage::files(&app.db, r.id)
184
        .await?
185
        .into_iter()
186
        .map(|f| CoverageFile {
187
            percent: f.percent(),
188
            path: f.path,
189
            lines_found: f.lines_found,
190
            lines_hit: f.lines_hit,
191
            functions_found: f.functions_found,
192
            functions_hit: f.functions_hit,
193
            branches_found: f.branches_found,
194
            branches_hit: f.branches_hit,
195
        })
196
        .collect();
197
    Ok(Json(CoverageDetail { report: coverage_of(&app, &format!("/{owner}/{name}"), &r), files }))
198
}
199
200
/// `GET /repos/{o}/{n}/tests`.
201
pub async fn tests_list(
202
    State(app): State<Shared>,
203
    headers: HeaderMap,
204
    Path((owner, name)): Path<(String, String)>,
205
    Query(q): Query<ListQuery>,
206
) -> Result<Json<Vec<TestReport>>, ApiError> {
207
    let c = caller(&app, &headers).await?;
208
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
209
    let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
210
    let list = junit::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?;
211
    let base = format!("/{owner}/{name}");
212
    Ok(Json(list.iter().map(|r| tests_of(&app, &base, r)).collect()))
213
}
214
215
/// `GET /repos/{o}/{n}/tests/{id}`.
216
pub async fn tests_one(
217
    State(app): State<Shared>,
218
    headers: HeaderMap,
219
    Path((owner, name, id)): Path<(String, String, i64)>,
220
    Query(q): Query<CasesQuery>,
221
) -> Result<Json<TestReportDetail>, ApiError> {
222
    let c = caller(&app, &headers).await?;
223
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
224
    let r = junit::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such test report"))?;
225
    let statuses: &[&str] = match q.status.as_deref() {
226
        Some("failed") => &["failed", "error"],
227
        _ => &[],
228
    };
229
    let cases = junit::cases(&app.db, r.id, statuses, junit::MAX_CASES as i64)
230
        .await?
231
        .into_iter()
232
        .map(|c| TestCase {
233
            suite: c.suite,
234
            classname: c.classname,
235
            name: c.name,
236
            status: c.status,
237
            time_ms: c.time_ms,
238
            message: c.message,
239
            output: c.output,
240
        })
241
        .collect();
242
    Ok(Json(TestReportDetail { report: tests_of(&app, &format!("/{owner}/{name}"), &r), cases }))
243
}