| 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 |
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), |
| 46 |
uploaded_by: r.uploaded().then(|| r.uploader.clone()), |
| 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 |
|
| 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 |
|
| 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 |
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 |
|
| 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 |
|
| 174 |
|
| 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 |
|
| 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 |
|
| 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 |
} |