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