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