Flow job reports and timings over the API, oh run report and oh report; 0.9.14 (closes #348) fac6853c4e46
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7036819b6ece
- When
- · 2026-10-28 21:16 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
fac6853c4e46a6c9edfcb845d0fb6480cfa4f57b89ab374db1a1dd264643d689- Depends on
- 166f6e9bf4f1 1edac4b68c6d 20b8fe85888b 3d086bc3d69b 40dd3f86379a 42fa95540468 50d93fa443dc 5c6e92fd8d71 617c2840446c 67040b158ae2 8aa5ef384ef1 90326c9601f9 93be9ce4653e 95b525b0b977 9bbca80f777a a36bab6b9ffc b1860d6090ec b5668108487f ba0a55bb5b3c bf1ab3458db1 c8df33a6829f d23464c0ac60 dc59d1b56c3d df71c7892d27 e8fa53694f68
- Depended on by
- 306aec1839aa 8f1d76f4169a c26267b3aba6
- In bookmarks
- main
- In tags
- v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
- Edits
- 43 atoms, 0 tree operations, 13 files
Browse the tree at this change
modified CHANGELOG.md +5 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`. |
|
| 4 | 4 | ||
| 5 | + | ## 0.9.14 (2026-10-28) |
|
| 6 | + | ||
| 7 | + | - **Read a Flow job's report over the API and with `oh` (#348).** A report a job published could only be read on its web page. Now `GET /api/v1/repos/OWNER/NAME/flow/runs/N/jobs/JOB/report` gives it with its metrics, context and files, `…/report/PATH` one file as published (`data/frames.csv`), `…/jobs/JOB/reports` every report the job published, `GET …/reports` the newest run of each report (`?bookmark=`), and `GET …/reports/NAME` one run by `?id=` (the id the web page carries) or the newest on a bookmark, with `…/files/PATH`. Files come sandboxed, so a page in one runs nothing on the hub; errors name the jobs, reports and files there are. `oh run report RUN JOB` prints its `report.toml`, `--list` what it holds, `--file PATH [-o FILE]` one file; `oh report list` and `oh report show NAME` do the same by name. |
|
| 8 | + | - **A job's timings over the API (#348).** A run's jobs now say when each was queued and claimed, the phases its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; `oh run view N --timings` prints them under each job ("waited 12.0 s · image 850 ms · run 2 min 5 s · peak disk 2.1 GiB, memory 900 MiB"). |
|
| 9 | + | ||
| 5 | 10 | ## 0.9.13 (2026-10-28) |
|
| 6 | 11 | ||
| 7 | 12 | - **An assistant drafts a new repository's starting files (#347).** Creating a repository can come with a description of what it is for: `POST /api/v1/repos/OWNER/NAME/starter {"description"}` asks the owner's assistant (the site's, or the organisation's) for a small working starting point (a README, the build files, a source layout and a first test), checks what comes back (relative paths, text, at most 40 files and 256 KiB), and commits it to the empty repository's default bookmark as the person who asked, signed by the hub as their delegate, the change naming the assistant and what was asked. It runs in the background, since a model may take longer than a proxied request may last; `GET …/starter` says how it went, and `GET /api/v1/user/starter?owner=X` which assistants may be asked. Never over files that are there. The Android app's New repository uses it. |
|
| … | 406 unchanged lines |
modified Cargo.lock +3 −3
| … | 1459 unchanged lines |
||
| 1460 | 1460 | ||
| 1461 | 1461 | [[package]] |
|
| 1462 | 1462 | name = "hub-api" |
|
| 1463 | - | version = "0.9.13" |
|
| 1463 | + | version = "0.9.14" |
|
| 1464 | 1464 | dependencies = [ |
|
| 1465 | 1465 | "schemars", |
|
| 1466 | 1466 | "serde", |
|
| … | 797 unchanged lines |
||
| 2264 | 2264 | ||
| 2265 | 2265 | [[package]] |
|
| 2266 | 2266 | name = "oh" |
|
| 2267 | - | version = "0.9.13" |
|
| 2267 | + | version = "0.9.14" |
|
| 2268 | 2268 | dependencies = [ |
|
| 2269 | 2269 | "anyhow", |
|
| 2270 | 2270 | "axum", |
|
| … | 86 unchanged lines |
||
| 2357 | 2357 | ||
| 2358 | 2358 | [[package]] |
|
| 2359 | 2359 | name = "oxygenhub" |
|
| 2360 | - | version = "0.9.13" |
|
| 2360 | + | version = "0.9.14" |
|
| 2361 | 2361 | dependencies = [ |
|
| 2362 | 2362 | "ammonia", |
|
| 2363 | 2363 | "anyhow", |
|
| … | 2513 unchanged lines |
modified Cargo.toml +1 −1
| … | 6 unchanged lines |
||
| 7 | 7 | resolver = "2" |
|
| 8 | 8 | ||
| 9 | 9 | [workspace.package] |
|
| 10 | - | version = "0.9.13" |
|
| 10 | + | version = "0.9.14" |
|
| 11 | 11 | edition = "2021" |
|
| 12 | 12 | license = "MIT OR Apache-2.0" |
|
| 13 | 13 | authors = ["Andy McCall <[email protected]>"] |
|
| … | 60 unchanged lines |
modified crates/hub-api/src/lib.rs +80 −0
| … | 605 unchanged lines |
||
| 606 | 606 | /// empty for a job with no gate. |
|
| 607 | 607 | #[serde(default, skip_serializing_if = "String::is_empty")] |
|
| 608 | 608 | pub approval: String, |
|
| 609 | + | /// When it entered the queue (made, or run again), and when a runner |
|
| 610 | + | /// claimed it: its wait is `started_ms - queued_ms` (oxygenhub#348). |
|
| 611 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 612 | + | pub queued_ms: Option<i64>, |
|
| 613 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 614 | + | pub ready_ms: Option<i64>, |
|
| 615 | + | /// Where its time went, as its runner reported (oxygenflow#35): |
|
| 616 | + | /// `image`, `prepare`, `run` and `finish`, in that order. |
|
| 617 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 618 | + | pub phases: Vec<FlowPhase>, |
|
| 619 | + | /// The most disk and memory it took, as its runner reported, in MiB. |
|
| 620 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 621 | + | pub peak_disk_mib: Option<i64>, |
|
| 622 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 623 | + | pub peak_memory_mib: Option<i64>, |
|
| 624 | + | } |
|
| 625 | + | ||
| 626 | + | /// A phase of a job and its milliseconds. |
|
| 627 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 628 | + | pub struct FlowPhase { |
|
| 629 | + | pub name: String, |
|
| 630 | + | pub ms: u64, |
|
| 631 | + | } |
|
| 632 | + | ||
| 633 | + | /// A report: what a Flow job published (OxygenFlow `output { report … }`) |
|
| 634 | + | /// or an upload, as docs/reports.md describes (oxygenhub#348). |
|
| 635 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] |
|
| 636 | + | pub struct Report { |
|
| 637 | + | /// Its id, as the web page's `?id=` carries it. |
|
| 638 | + | pub id: i64, |
|
| 639 | + | /// The name the pipeline gave it (`report "frame-timing"`). |
|
| 640 | + | pub name: String, |
|
| 641 | + | /// `coverage`, `tests`, `performance` or `custom`. |
|
| 642 | + | pub kind: String, |
|
| 643 | + | pub title: String, |
|
| 644 | + | pub summary: String, |
|
| 645 | + | pub metrics: Vec<ReportMetric>, |
|
| 646 | + | /// Its `[context]` table. |
|
| 647 | + | pub context: serde_json::Value, |
|
| 648 | + | /// The run and job that published it; none for an upload. |
|
| 649 | + | pub run: Option<i64>, |
|
| 650 | + | pub job: String, |
|
| 651 | + | pub bookmark: String, |
|
| 652 | + | /// The state it measured. |
|
| 653 | + | pub state: String, |
|
| 654 | + | /// How many files it has and their size together. |
|
| 655 | + | pub files: i64, |
|
| 656 | + | pub bytes: i64, |
|
| 657 | + | pub created_ms: i64, |
|
| 658 | + | /// When its files expired under retention (docs/reports.md §3.3): its |
|
| 659 | + | /// metrics stay, its files are gone. |
|
| 660 | + | pub files_expired_ms: Option<i64>, |
|
| 661 | + | /// Its page on the hub. |
|
| 662 | + | pub url: String, |
|
| 663 | + | /// Its files, filled when one report is asked for. |
|
| 664 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 665 | + | pub manifest: Vec<ReportFile>, |
|
| 666 | + | } |
|
| 667 | + | ||
| 668 | + | /// A metric of a report. |
|
| 669 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] |
|
| 670 | + | pub struct ReportMetric { |
|
| 671 | + | pub key: String, |
|
| 672 | + | pub value: f64, |
|
| 673 | + | pub unit: String, |
|
| 674 | + | /// `lower`, `higher`, or empty. |
|
| 675 | + | pub better: String, |
|
| 676 | + | pub fail_above: Option<f64>, |
|
| 677 | + | pub fail_below: Option<f64>, |
|
| 678 | + | } |
|
| 679 | + | ||
| 680 | + | /// A file of a report. |
|
| 681 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 682 | + | pub struct ReportFile { |
|
| 683 | + | pub path: String, |
|
| 684 | + | pub size: i64, |
|
| 609 | 685 | } |
|
| 610 | 686 | ||
| 611 | 687 | /// A line of a job's log. |
|
| … | 818 unchanged lines |
||
| 1430 | 1506 | ("FlowRun", schemars::schema_for!(FlowRun)), |
|
| 1431 | 1507 | ("FlowJob", schemars::schema_for!(FlowJob)), |
|
| 1432 | 1508 | ("FlowLogLine", schemars::schema_for!(FlowLogLine)), |
|
| 1509 | + | ("FlowPhase", schemars::schema_for!(FlowPhase)), |
|
| 1510 | + | ("Report", schemars::schema_for!(Report)), |
|
| 1511 | + | ("ReportMetric", schemars::schema_for!(ReportMetric)), |
|
| 1512 | + | ("ReportFile", schemars::schema_for!(ReportFile)), |
|
| 1433 | 1513 | ("AssetBuild", schemars::schema_for!(AssetBuild)), |
|
| 1434 | 1514 | ("ReleaseAsset", schemars::schema_for!(ReleaseAsset)), |
|
| 1435 | 1515 | ("NewRelease", schemars::schema_for!(NewRelease)), |
|
| … | 301 unchanged lines |
modified crates/hub/src/api/mod.rs +11 −0
| … | 195 unchanged lines |
||
| 196 | 196 | .route("/repos/{owner}/{name}/flow/runs", get(flow_runs).post(start_flow_run)) |
|
| 197 | 197 | .route("/repos/{owner}/{name}/flow/runs/{number}", get(flow_run)) |
|
| 198 | 198 | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/log", get(flow_job_log)) |
|
| 199 | + | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/reports", get(reports::job_reports)) |
|
| 200 | + | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/report", get(reports::job_report_one)) |
|
| 201 | + | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/report/{*path}", get(reports::job_report_file)) |
|
| 202 | + | .route("/repos/{owner}/{name}/reports", get(reports::latest_reports)) |
|
| 203 | + | .route("/repos/{owner}/{name}/reports/{report}", get(reports::named_report_one)) |
|
| 204 | + | .route("/repos/{owner}/{name}/reports/{report}/files/{*path}", get(reports::named_report_file)) |
|
| 199 | 205 | .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run)) |
|
| 200 | 206 | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/rerun", post(rerun_flow_job)) |
|
| 201 | 207 | .route("/repos/{owner}/{name}/flow/runs/{number}/cancel", post(cancel_flow_run)) |
|
| … | 2475 unchanged lines |
||
| 2677 | 2683 | jobs: jobs |
|
| 2678 | 2684 | .into_iter() |
|
| 2679 | 2685 | .map(|j| FlowJob { |
|
| 2686 | + | phases: j.phase_list().into_iter().map(|(name, ms)| hub_api::FlowPhase { name, ms }).collect(), |
|
| 2687 | + | queued_ms: j.queued_ms, |
|
| 2688 | + | ready_ms: j.ready_ms, |
|
| 2689 | + | peak_disk_mib: j.peak_disk_mib, |
|
| 2690 | + | peak_memory_mib: j.peak_memory_mib, |
|
| 2680 | 2691 | depends_on: j.depends_on(), |
|
| 2681 | 2692 | name: j.name, |
|
| 2682 | 2693 | status: j.status, |
|
| … | 778 unchanged lines |
modified crates/hub/src/api/reports.rs +231 −5
| 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. |
|
| 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. |
|
| 3 | 4 | ||
| 4 | 5 | use super::{caller, not_found, reach, ApiError}; |
|
| 5 | 6 | use crate::auth::Access; |
|
| 6 | - | use crate::{coverage, junit, Shared}; |
|
| 7 | + | use crate::{coverage, junit, reports, Shared}; |
|
| 7 | 8 | use axum::extract::{Path, Query, State}; |
|
| 8 | - | use axum::http::HeaderMap; |
|
| 9 | + | use axum::http::{header, HeaderMap, HeaderValue}; |
|
| 10 | + | use axum::response::{IntoResponse, Response}; |
|
| 9 | 11 | use axum::Json; |
|
| 10 | - | use hub_api::{CoverageDetail, CoverageFile, CoverageReport, TestCase, TestReport, TestReportDetail}; |
|
| 12 | + | use hub_api::{CoverageDetail, CoverageFile, CoverageReport, Report, ReportFile, ReportMetric, TestCase, TestReport, TestReportDetail}; |
|
| 11 | 13 | use serde::Deserialize; |
|
| 12 | 14 | ||
| 13 | 15 | #[derive(Deserialize, Default)] |
|
| … | 227 unchanged lines |
||
| 241 | 243 | .collect(); |
|
| 242 | 244 | Ok(Json(TestReportDetail { report: tests_of(&app, &format!("/{owner}/{name}"), &r), cases })) |
|
| 243 | 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 | + | } |
modified crates/hub/src/reports.rs +10 −0
| … | 411 unchanged lines |
||
| 412 | 412 | sqlx::query_as("SELECT blob, size FROM report_files WHERE report_id = $1 AND path = $2").bind(id).bind(path).fetch_optional(pool).await |
|
| 413 | 413 | } |
|
| 414 | 414 | ||
| 415 | + | /// Report `id`'s files, by path, with their sizes. |
|
| 416 | + | pub async fn files_of(pool: &Pool, id: i64) -> sqlx::Result<Vec<(String, i64)>> { |
|
| 417 | + | sqlx::query_as("SELECT path, size FROM report_files WHERE report_id = $1 ORDER BY path").bind(id).fetch_all(pool).await |
|
| 418 | + | } |
|
| 419 | + | ||
| 420 | + | /// The reports a job published, by name. |
|
| 421 | + | pub async fn of_job(pool: &Pool, job_id: i64) -> sqlx::Result<Vec<Report>> { |
|
| 422 | + | sqlx::query_as("SELECT * FROM reports WHERE job_id = $1 ORDER BY name").bind(job_id).fetch_all(pool).await |
|
| 423 | + | } |
|
| 424 | + | ||
| 415 | 425 | /// The capability a report's files are served under: they are read from a |
|
| 416 | 426 | /// sandboxed frame, which carries no session, so the link itself is what |
|
| 417 | 427 | /// lets it in, and only readers of the repository are given it. |
|
| … | 111 unchanged lines |
modified crates/hub/src/web/reports.rs +1 −1
| … | 418 unchanged lines |
||
| 419 | 419 | } |
|
| 420 | 420 | ||
| 421 | 421 | /// A file's type, by its extension. |
|
| 422 | - | fn content_type(path: &str) -> &'static str { |
|
| 422 | + | pub(crate) fn content_type(path: &str) -> &'static str { |
|
| 423 | 423 | match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() { |
|
| 424 | 424 | "html" | "htm" => "text/html; charset=utf-8", |
|
| 425 | 425 | "js" | "mjs" => "text/javascript; charset=utf-8", |
|
| … | 88 unchanged lines |
modified crates/hub/tests/flow.rs +55 −0
| … | 1171 unchanged lines |
||
| 1172 | 1172 | assert!(page.contains("data-play=\"3\""), "playing"); |
|
| 1173 | 1173 | assert!(stop.contains("play") && stop.contains('3'), "playing carries on from stop to stop: {stop}"); |
|
| 1174 | 1174 | assert!(page.contains("data-report-runs="), "the frame interface knows the runs"); |
|
| 1175 | + | ||
| 1176 | + | // The same over the API (#348): the job's report with its files, one |
|
| 1177 | + | // file as published, the newest of each, one by id, and plain 404s. |
|
| 1178 | + | let n = run.number; |
|
| 1179 | + | let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/report"), None, None).await; |
|
| 1180 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1181 | + | let r: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 1182 | + | assert_eq!((r["id"].as_i64(), r["name"].as_str(), r["kind"].as_str()), (Some(first), Some("frame-timing"), Some("performance"))); |
|
| 1183 | + | assert_eq!((r["metrics"][0]["key"].as_str(), r["metrics"][0]["fail_above"].as_f64()), (Some("median_ms"), Some(20.0))); |
|
| 1184 | + | assert_eq!(r["context"]["target_fps"], 30); |
|
| 1185 | + | let paths: Vec<&str> = r["manifest"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect(); |
|
| 1186 | + | assert_eq!(paths, ["data/frames.csv", "index.html", "report.toml"]); |
|
| 1187 | + | assert!(r["url"].as_str().unwrap().ends_with(&format!("/andy/demo/reports/frame-timing?id={first}"))); |
|
| 1188 | + | let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/reports"), None, None).await; |
|
| 1189 | + | assert!(st == StatusCode::OK && body.contains("\"frame-timing\"") && !body.contains("manifest"), "{st} {body}"); |
|
| 1190 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 1191 | + | .oneshot( |
|
| 1192 | + | Request::get(format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/report/data/frames.csv")).body(Body::empty()).unwrap(), |
|
| 1193 | + | ) |
|
| 1194 | + | .await |
|
| 1195 | + | .unwrap(); |
|
| 1196 | + | assert_eq!(res.status(), StatusCode::OK); |
|
| 1197 | + | assert_eq!(res.headers()[header::CONTENT_TYPE], "text/csv; charset=utf-8"); |
|
| 1198 | + | assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], "sandbox; default-src 'none'", "a page in it runs nothing on the hub"); |
|
| 1199 | + | assert_eq!(res.into_body().collect().await.unwrap().to_bytes().to_vec(), data_bytes); |
|
| 1200 | + | let toml_path = format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/report/report.toml?name=frame-timing"); |
|
| 1201 | + | assert_eq!(get_bytes(&h.app, &toml_path).await, (StatusCode::OK, toml.to_vec())); |
|
| 1202 | + | let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/report/nope.csv"), None, None).await; |
|
| 1203 | + | assert!(st == StatusCode::NOT_FOUND && body.contains("has no file nope.csv") && body.contains("report.toml"), "{st} {body}"); |
|
| 1204 | + | let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/perf/report?name=other"), None, None).await; |
|
| 1205 | + | assert!(st == StatusCode::NOT_FOUND && body.contains("it published frame-timing"), "{st} {body}"); |
|
| 1206 | + | let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}/jobs/nojob/report"), None, None).await; |
|
| 1207 | + | assert!(st == StatusCode::NOT_FOUND && body.contains("its jobs: perf"), "{st} {body}"); |
|
| 1208 | + | // The newest of each is the second run; ?id= reaches the first. |
|
| 1209 | + | let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/reports", None, None).await; |
|
| 1210 | + | let latest: Vec<serde_json::Value> = serde_json::from_str(&body).unwrap(); |
|
| 1211 | + | assert_eq!((latest.len(), latest[0]["id"].as_i64(), latest[0]["run"].as_i64()), (1, Some(second), Some(2))); |
|
| 1212 | + | assert_eq!(latest[0]["metrics"][0]["value"].as_f64(), Some(18.0)); |
|
| 1213 | + | let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/reports/frame-timing", None, None).await; |
|
| 1214 | + | assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["id"].as_i64(), Some(second), "newest on its bookmark"); |
|
| 1215 | + | let (_, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/reports/frame-timing?id={first}"), None, None).await; |
|
| 1216 | + | assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["summary"], "Median 16.4 ms"); |
|
| 1217 | + | let page_path = format!("/api/v1/repos/andy/demo/reports/frame-timing/files/index.html?id={first}"); |
|
| 1218 | + | assert_eq!(get_bytes(&h.app, &page_path).await, (StatusCode::OK, page_bytes.clone()), "as published, no frame script"); |
|
| 1219 | + | let (st, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/reports/frame-timing?bookmark=elsewhere", None, None).await; |
|
| 1220 | + | assert!(st == StatusCode::NOT_FOUND && body.contains("never ran on elsewhere"), "{st} {body}"); |
|
| 1221 | + | let (st, _) = call(&h.app, "GET", "/api/v1/repos/andy/demo/reports/nothing", None, None).await; |
|
| 1222 | + | assert_eq!(st, StatusCode::NOT_FOUND); |
|
| 1223 | + | // A run's jobs carry their timings, phases in the order taken. |
|
| 1224 | + | let phases = std::collections::BTreeMap::from([("run".to_string(), 1200u64), ("image".to_string(), 300)]); |
|
| 1225 | + | db::flow::set_phases(&h.app.db, id, &phases).await.unwrap(); |
|
| 1226 | + | let (_, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/{n}"), None, None).await; |
|
| 1227 | + | let v: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 1228 | + | assert!(v["jobs"][0]["queued_ms"].as_i64().is_some(), "{body}"); |
|
| 1229 | + | assert_eq!(v["jobs"][0]["phases"], serde_json::json!([{ "name": "image", "ms": 300 }, { "name": "run", "ms": 1200 }])); |
|
| 1175 | 1230 | let forged = src.replacen("/files/", "/files/9", 1); |
|
| 1176 | 1231 | assert_eq!(get_bytes(&h.app, &forged).await.0, StatusCode::NOT_FOUND); |
|
| 1177 | 1232 | assert_eq!(get_bytes(&h.app, &src.replace("/index.html", "/secret.txt")).await.0, StatusCode::NOT_FOUND); |
|
| … | 6111 unchanged lines |
modified crates/oh/src/client.rs +12 −0
| … | 79 unchanged lines |
||
| 80 | 80 | } |
|
| 81 | 81 | } |
|
| 82 | 82 | ||
| 83 | + | /// GET raw bytes from the API (a report's file). |
|
| 84 | + | pub fn get_bytes(&self, path: &str) -> Result<Vec<u8>> { |
|
| 85 | + | match self.req("GET", path).call() { |
|
| 86 | + | Ok(r) => { |
|
| 87 | + | let mut out = Vec::new(); |
|
| 88 | + | std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?; |
|
| 89 | + | Ok(out) |
|
| 90 | + | } |
|
| 91 | + | Err(e) => Self::finish(Err(e), &format!("GET {path}")).map(String::into_bytes), |
|
| 92 | + | } |
|
| 93 | + | } |
|
| 94 | + | ||
| 83 | 95 | /// GET a path at the hub's root, outside the API (`/.well-known/…`). |
|
| 84 | 96 | pub fn get_root_bytes(&self, path: &str) -> Result<Vec<u8>> { |
|
| 85 | 97 | let hub = self.base.trim_end_matches("/api/v1"); |
|
| … | 14 unchanged lines |
modified crates/oh/src/main.rs +273 −5
| … | 8 unchanged lines |
||
| 9 | 9 | use anyhow::{bail, Context, Result}; |
|
| 10 | 10 | use clap::{Parser, Subcommand}; |
|
| 11 | 11 | use client::Client; |
|
| 12 | - | use hub_api::{FlowLogLine, FlowRun, Issue, IssueStateChange, NewComment, NewIssue, NewProposal, NewRelease, Proposal, Release, Tag}; |
|
| 12 | + | use hub_api::{ |
|
| 13 | + | FlowJob, FlowLogLine, FlowRun, Issue, IssueStateChange, NewComment, NewIssue, NewProposal, NewRelease, Proposal, Release, Report, Tag, |
|
| 14 | + | }; |
|
| 13 | 15 | use std::time::Duration; |
|
| 14 | 16 | use target::{Choice, Config, Target}; |
|
| 15 | 17 | ||
| … | 51 unchanged lines |
||
| 67 | 69 | #[command(subcommand)] |
|
| 68 | 70 | cmd: RunCmd, |
|
| 69 | 71 | }, |
|
| 72 | + | /// Reports Flow jobs publish: the newest of each, one by name (oxygenhub#348) |
|
| 73 | + | Report { |
|
| 74 | + | #[command(subcommand)] |
|
| 75 | + | cmd: ReportCmd, |
|
| 76 | + | }, |
|
| 70 | 77 | /// Releases |
|
| 71 | 78 | Release { |
|
| 72 | 79 | #[command(subcommand)] |
|
| … | 584 unchanged lines |
||
| 657 | 664 | limit: i64, |
|
| 658 | 665 | }, |
|
| 659 | 666 | /// A run and its jobs |
|
| 660 | - | View { number: i64 }, |
|
| 667 | + | View { |
|
| 668 | + | number: i64, |
|
| 669 | + | /// Each job's wait, phases (image, prepare, run, finish) and peak disk and memory |
|
| 670 | + | #[arg(long)] |
|
| 671 | + | timings: bool, |
|
| 672 | + | }, |
|
| 661 | 673 | /// Run the pipeline without a push: a bookmark's current state (default: the default bookmark), or one change's |
|
| 662 | 674 | Start { |
|
| 663 | 675 | #[arg(long)] |
|
| … | 12 unchanged lines |
||
| 676 | 688 | Watch { number: i64 }, |
|
| 677 | 689 | /// A job's log |
|
| 678 | 690 | Log { number: i64, job: String }, |
|
| 691 | + | /// A report a job published: its report.toml; --list for what it holds, --file for one file |
|
| 692 | + | Report { |
|
| 693 | + | number: i64, |
|
| 694 | + | job: String, |
|
| 695 | + | #[command(flatten)] |
|
| 696 | + | pick: ReportPick, |
|
| 697 | + | }, |
|
| 679 | 698 | /// Run a finished run's state again; with --job, only that job (and the |
|
| 680 | 699 | /// jobs that depend on it) in the same run |
|
| 681 | 700 | Rerun { |
|
| … | 15 unchanged lines |
||
| 697 | 716 | #[arg(long)] |
|
| 698 | 717 | job: String, |
|
| 699 | 718 | }, |
|
| 719 | + | } |
|
| 720 | + | ||
| 721 | + | /// Which report of a job, and what of it to show. |
|
| 722 | + | #[derive(clap::Args)] |
|
| 723 | + | struct ReportPick { |
|
| 724 | + | /// Which report, when the job published more than one |
|
| 725 | + | #[arg(long)] |
|
| 726 | + | name: Option<String>, |
|
| 727 | + | /// What it holds: its metrics, context and files |
|
| 728 | + | #[arg(long, conflicts_with = "file")] |
|
| 729 | + | list: bool, |
|
| 730 | + | /// One file of it (`data/frames.csv`), as published |
|
| 731 | + | #[arg(long, value_name = "PATH")] |
|
| 732 | + | file: Option<String>, |
|
| 733 | + | /// Write the file here instead of to stdout |
|
| 734 | + | #[arg(long, short, value_name = "FILE", requires = "file")] |
|
| 735 | + | output: Option<std::path::PathBuf>, |
|
| 700 | 736 | } |
|
| 701 | 737 | ||
| 702 | 738 | #[derive(Subcommand)] |
|
| 739 | + | enum ReportCmd { |
|
| 740 | + | /// The newest run of each report, newest first |
|
| 741 | + | List { |
|
| 742 | + | /// Only runs on this bookmark |
|
| 743 | + | #[arg(long)] |
|
| 744 | + | bookmark: Option<String>, |
|
| 745 | + | }, |
|
| 746 | + | /// One report: its newest run on the default bookmark (or --bookmark), or the run --id names |
|
| 747 | + | Show { |
|
| 748 | + | report: String, |
|
| 749 | + | #[arg(long, conflicts_with = "bookmark")] |
|
| 750 | + | id: Option<i64>, |
|
| 751 | + | #[arg(long)] |
|
| 752 | + | bookmark: Option<String>, |
|
| 753 | + | /// What it holds: its metrics, context and files |
|
| 754 | + | #[arg(long, conflicts_with = "file")] |
|
| 755 | + | list: bool, |
|
| 756 | + | /// One file of it, as published |
|
| 757 | + | #[arg(long, value_name = "PATH")] |
|
| 758 | + | file: Option<String>, |
|
| 759 | + | /// Write the file here instead of to stdout |
|
| 760 | + | #[arg(long, short, value_name = "FILE", requires = "file")] |
|
| 761 | + | output: Option<std::path::PathBuf>, |
|
| 762 | + | }, |
|
| 763 | + | } |
|
| 764 | + | ||
| 765 | + | #[derive(Subcommand)] |
|
| 703 | 766 | enum TagCmd { |
|
| 704 | 767 | /// Tags, newest name first |
|
| 705 | 768 | List, |
|
| … | 133 unchanged lines |
||
| 839 | 902 | Cmd::Issue { cmd } => issue(&c, &t, cmd, json), |
|
| 840 | 903 | Cmd::Proposal { cmd } => proposal(&c, &t, cmd, json), |
|
| 841 | 904 | Cmd::Run { cmd } => flow(&c, &t, cmd, json), |
|
| 905 | + | Cmd::Report { cmd } => report(&c, &t, cmd, json), |
|
| 842 | 906 | Cmd::Coverage { cmd: CoverageCmd::Upload { file, bookmark, change } } => { |
|
| 843 | 907 | let content = std::fs::read_to_string(&file).with_context(|| format!("read {}", file.display()))?; |
|
| 844 | 908 | let mut body = serde_json::json!({ "content": content }); |
|
| … | 768 unchanged lines |
||
| 1613 | 1677 | ) |
|
| 1614 | 1678 | } |
|
| 1615 | 1679 | ||
| 1616 | - | fn print_run(r: &FlowRun) { |
|
| 1680 | + | fn print_run(r: &FlowRun, timings: bool) { |
|
| 1617 | 1681 | println!("{}", run_line(r)); |
|
| 1618 | 1682 | for j in &r.jobs { |
|
| 1619 | 1683 | let dur = match (j.started_ms, j.finished_ms) { |
|
| … | 9 unchanged lines |
||
| 1629 | 1693 | } else if !j.placed.is_empty() { |
|
| 1630 | 1694 | println!(" {}", ui::dim(&format!("placed: {}", j.placed))); |
|
| 1631 | 1695 | } |
|
| 1696 | + | if timings { |
|
| 1697 | + | let t = timing_line(j); |
|
| 1698 | + | if !t.is_empty() { |
|
| 1699 | + | println!(" {}", ui::dim(&t)); |
|
| 1700 | + | } |
|
| 1701 | + | } |
|
| 1632 | 1702 | } |
|
| 1633 | 1703 | if !r.error.is_empty() { |
|
| 1634 | 1704 | println!("{}", ui::bad(&r.error)); |
|
| … | 1 unchanged lines |
||
| 1636 | 1706 | println!("{}", ui::dim(&r.url)); |
|
| 1637 | 1707 | } |
|
| 1638 | 1708 | ||
| 1709 | + | /// Milliseconds as a person reads them: "850 ms", "3.1 s", "2 min 5 s". |
|
| 1710 | + | fn took(ms: i64) -> String { |
|
| 1711 | + | match ms { |
|
| 1712 | + | ..=999 => format!("{ms} ms"), |
|
| 1713 | + | 1000..=59_999 => format!("{:.1} s", ms as f64 / 1000.0), |
|
| 1714 | + | _ => format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000), |
|
| 1715 | + | } |
|
| 1716 | + | } |
|
| 1717 | + | ||
| 1718 | + | /// MiB as a person reads them. |
|
| 1719 | + | fn mib(m: i64) -> String { |
|
| 1720 | + | if m >= 1024 { |
|
| 1721 | + | format!("{:.1} GiB", m as f64 / 1024.0) |
|
| 1722 | + | } else { |
|
| 1723 | + | format!("{m} MiB") |
|
| 1724 | + | } |
|
| 1725 | + | } |
|
| 1726 | + | ||
| 1727 | + | /// Where a job's time went (oxygenhub#348): its wait for a runner, each |
|
| 1728 | + | /// phase its runner reported, and the most disk and memory it took. |
|
| 1729 | + | fn timing_line(j: &FlowJob) -> String { |
|
| 1730 | + | let mut parts = vec![]; |
|
| 1731 | + | if let (Some(q), Some(s)) = (j.queued_ms, j.started_ms) { |
|
| 1732 | + | parts.push(format!("waited {}", took((s - q).max(0)))); |
|
| 1733 | + | } |
|
| 1734 | + | parts.extend(j.phases.iter().map(|p| format!("{} {}", p.name, took(p.ms as i64)))); |
|
| 1735 | + | let peaks: Vec<String> = [("disk", j.peak_disk_mib), ("memory", j.peak_memory_mib)] |
|
| 1736 | + | .into_iter() |
|
| 1737 | + | .filter_map(|(what, m)| m.map(|m| format!("{what} {}", mib(m)))) |
|
| 1738 | + | .collect(); |
|
| 1739 | + | if !peaks.is_empty() { |
|
| 1740 | + | parts.push(format!("peak {}", peaks.join(", "))); |
|
| 1741 | + | } |
|
| 1742 | + | parts.join(" · ") |
|
| 1743 | + | } |
|
| 1744 | + | ||
| 1745 | + | /// A report's path, its segments encoded and its slashes kept. |
|
| 1746 | + | fn enc_path(path: &str) -> String { |
|
| 1747 | + | path.split('/').map(enc).collect::<Vec<_>>().join("/") |
|
| 1748 | + | } |
|
| 1749 | + | ||
| 1750 | + | /// `oh run report` and `oh report show`: the report at `at` (JSON), whose |
|
| 1751 | + | /// files are at `files(path)`. By default its report.toml; with `list` |
|
| 1752 | + | /// what it holds; with `file` one file, to stdout or `output`. |
|
| 1753 | + | fn show_report( |
|
| 1754 | + | c: &Client, |
|
| 1755 | + | at: &str, |
|
| 1756 | + | files: impl Fn(&str) -> String, |
|
| 1757 | + | list: bool, |
|
| 1758 | + | file: Option<String>, |
|
| 1759 | + | output: Option<std::path::PathBuf>, |
|
| 1760 | + | json: bool, |
|
| 1761 | + | ) -> Result<()> { |
|
| 1762 | + | use std::io::Write; |
|
| 1763 | + | if let Some(path) = file { |
|
| 1764 | + | let bytes = c.get_bytes(&files(&path))?; |
|
| 1765 | + | match output { |
|
| 1766 | + | Some(out) => { |
|
| 1767 | + | std::fs::write(&out, &bytes).with_context(|| format!("writing {}", out.display()))?; |
|
| 1768 | + | eprintln!("{} {path}: {} bytes to {}", ui::ok("✔"), bytes.len(), out.display()); |
|
| 1769 | + | } |
|
| 1770 | + | None => std::io::stdout().write_all(&bytes)?, |
|
| 1771 | + | } |
|
| 1772 | + | return Ok(()); |
|
| 1773 | + | } |
|
| 1774 | + | if json { |
|
| 1775 | + | println!("{}", c.get_text(at)?); |
|
| 1776 | + | return Ok(()); |
|
| 1777 | + | } |
|
| 1778 | + | if !list { |
|
| 1779 | + | std::io::stdout().write_all(&c.get_bytes(&files("report.toml"))?)?; |
|
| 1780 | + | return Ok(()); |
|
| 1781 | + | } |
|
| 1782 | + | let r: Report = c.get(at)?; |
|
| 1783 | + | print_report(&r); |
|
| 1784 | + | if r.files_expired_ms.is_some() { |
|
| 1785 | + | println!("{}", ui::warn("its files expired under retention; its metrics stay")); |
|
| 1786 | + | } |
|
| 1787 | + | for f in &r.manifest { |
|
| 1788 | + | println!(" {:>10} {}", ui::dim(&f.size.to_string()), f.path); |
|
| 1789 | + | } |
|
| 1790 | + | println!("{}", ui::dim(&r.url)); |
|
| 1791 | + | Ok(()) |
|
| 1792 | + | } |
|
| 1793 | + | ||
| 1794 | + | /// A report's heading, metrics and context. |
|
| 1795 | + | fn print_report(r: &Report) { |
|
| 1796 | + | let from = match r.run { |
|
| 1797 | + | Some(n) => format!("run #{n} {} on {}", r.job, r.bookmark), |
|
| 1798 | + | None => format!("upload on {}", if r.bookmark.is_empty() { "a change" } else { &r.bookmark }), |
|
| 1799 | + | }; |
|
| 1800 | + | println!( |
|
| 1801 | + | "{} {} {}", |
|
| 1802 | + | ui::strong(&r.name), |
|
| 1803 | + | ui::dim(&format!("{} · {:?}", r.kind, r.title)), |
|
| 1804 | + | ui::dim(&format!("· {from} · {} files", r.files)) |
|
| 1805 | + | ); |
|
| 1806 | + | if !r.summary.is_empty() { |
|
| 1807 | + | println!(" {}", r.summary); |
|
| 1808 | + | } |
|
| 1809 | + | for m in &r.metrics { |
|
| 1810 | + | let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) }; |
|
| 1811 | + | let mut notes = vec![]; |
|
| 1812 | + | if !m.better.is_empty() { |
|
| 1813 | + | notes.push(format!("{} is better", m.better)); |
|
| 1814 | + | } |
|
| 1815 | + | if let Some(a) = m.fail_above { |
|
| 1816 | + | notes.push(format!("fails above {a}")); |
|
| 1817 | + | } |
|
| 1818 | + | if let Some(b) = m.fail_below { |
|
| 1819 | + | notes.push(format!("fails below {b}")); |
|
| 1820 | + | } |
|
| 1821 | + | let notes = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join(", ")) }; |
|
| 1822 | + | println!(" {} {}{unit}{}", m.key, m.value, ui::dim(¬es)); |
|
| 1823 | + | } |
|
| 1824 | + | if let Some(ctx) = r.context.as_object().filter(|m| !m.is_empty()) { |
|
| 1825 | + | let line: Vec<String> = |
|
| 1826 | + | ctx.iter().map(|(k, v)| format!("{k}={}", v.as_str().map(String::from).unwrap_or_else(|| v.to_string()))).collect(); |
|
| 1827 | + | println!(" {}", ui::dim(&line.join(" "))); |
|
| 1828 | + | } |
|
| 1829 | + | } |
|
| 1830 | + | ||
| 1831 | + | /// `oh report …` (oxygenhub#348). |
|
| 1832 | + | fn report(c: &Client, t: &Target, cmd: ReportCmd, json: bool) -> Result<i32> { |
|
| 1833 | + | let base = t.repo_path()?; |
|
| 1834 | + | match cmd { |
|
| 1835 | + | ReportCmd::List { bookmark } => { |
|
| 1836 | + | let path = format!("{base}/reports{}", bookmark.map(|b| format!("?bookmark={}", enc(&b))).unwrap_or_default()); |
|
| 1837 | + | if json { |
|
| 1838 | + | println!("{}", c.get_text(&path)?); |
|
| 1839 | + | return Ok(0); |
|
| 1840 | + | } |
|
| 1841 | + | let list: Vec<Report> = c.get(&path)?; |
|
| 1842 | + | if list.is_empty() { |
|
| 1843 | + | println!("{}", ui::dim("No reports yet: a Flow job publishes one with `output { report \"name\" { path … } }`.")); |
|
| 1844 | + | } |
|
| 1845 | + | for r in &list { |
|
| 1846 | + | print_report(r); |
|
| 1847 | + | } |
|
| 1848 | + | } |
|
| 1849 | + | ReportCmd::Show { report, id, bookmark, list, file, output } => { |
|
| 1850 | + | let at = format!("{base}/reports/{}", enc(&report)); |
|
| 1851 | + | let query = match (id, bookmark) { |
|
| 1852 | + | (Some(id), _) => format!("?id={id}"), |
|
| 1853 | + | (None, Some(b)) => format!("?bookmark={}", enc(&b)), |
|
| 1854 | + | (None, None) => String::new(), |
|
| 1855 | + | }; |
|
| 1856 | + | let files = |path: &str| format!("{at}/files/{}{query}", enc_path(path)); |
|
| 1857 | + | show_report(c, &format!("{at}{query}"), files, list, file, output, json)?; |
|
| 1858 | + | } |
|
| 1859 | + | } |
|
| 1860 | + | Ok(0) |
|
| 1861 | + | } |
|
| 1862 | + | ||
| 1639 | 1863 | fn live(status: &str) -> bool { |
|
| 1640 | 1864 | matches!(status, "queued" | "running") |
|
| 1641 | 1865 | } |
|
| … | 55 unchanged lines |
||
| 1697 | 1921 | println!("{}", run_line(r)); |
|
| 1698 | 1922 | } |
|
| 1699 | 1923 | } |
|
| 1700 | - | RunCmd::View { number } => { |
|
| 1924 | + | RunCmd::View { number, timings } => { |
|
| 1701 | 1925 | let path = format!("{base}/flow/runs/{number}"); |
|
| 1702 | 1926 | if json { |
|
| 1703 | 1927 | println!("{}", c.get_text(&path)?); |
|
| 1704 | 1928 | return Ok(0); |
|
| 1705 | 1929 | } |
|
| 1706 | - | print_run(&c.get(&path)?); |
|
| 1930 | + | print_run(&c.get(&path)?, timings); |
|
| 1707 | 1931 | } |
|
| 1708 | 1932 | RunCmd::Start { bookmark, change, jobs, watch: follow } => { |
|
| 1709 | 1933 | let mut body = match (bookmark, change) { |
|
| … | 37 unchanged lines |
||
| 1747 | 1971 | } |
|
| 1748 | 1972 | after = lines.last().map(|l| l.seq).unwrap_or(after); |
|
| 1749 | 1973 | } |
|
| 1974 | + | } |
|
| 1975 | + | RunCmd::Report { number, job, pick } => { |
|
| 1976 | + | let at = format!("{base}/flow/runs/{number}/jobs/{}/report", enc(&job)); |
|
| 1977 | + | let query = pick.name.as_deref().map(|n| format!("?name={}", enc(n))).unwrap_or_default(); |
|
| 1978 | + | let files = |path: &str| format!("{at}/{}{query}", enc_path(path)); |
|
| 1979 | + | show_report(c, &format!("{at}{query}"), files, pick.list, pick.file, pick.output, json)?; |
|
| 1750 | 1980 | } |
|
| 1751 | 1981 | RunCmd::Rerun { number, job: Some(job) } => { |
|
| 1752 | 1982 | let queued: serde_json::Value = |
|
| … | 501 unchanged lines |
||
| 2254 | 2484 | ||
| 2255 | 2485 | #[cfg(test)] |
|
| 2256 | 2486 | mod tests { |
|
| 2487 | + | ||
| 2488 | + | #[test] |
|
| 2489 | + | fn job_timings_and_report_commands_read_plainly() { |
|
| 2490 | + | use clap::Parser; |
|
| 2491 | + | let job = hub_api::FlowJob { |
|
| 2492 | + | name: "perf".into(), |
|
| 2493 | + | status: "passed".into(), |
|
| 2494 | + | depends_on: vec![], |
|
| 2495 | + | runner: "earth-1".into(), |
|
| 2496 | + | exit_code: Some(0), |
|
| 2497 | + | error: String::new(), |
|
| 2498 | + | started_ms: Some(13_000), |
|
| 2499 | + | finished_ms: Some(200_000), |
|
| 2500 | + | log_lines: 10, |
|
| 2501 | + | waiting: String::new(), |
|
| 2502 | + | placed: String::new(), |
|
| 2503 | + | approval: String::new(), |
|
| 2504 | + | queued_ms: Some(1_000), |
|
| 2505 | + | ready_ms: Some(12_500), |
|
| 2506 | + | phases: vec![hub_api::FlowPhase { name: "image".into(), ms: 850 }, hub_api::FlowPhase { name: "run".into(), ms: 125_400 }], |
|
| 2507 | + | peak_disk_mib: Some(2150), |
|
| 2508 | + | peak_memory_mib: Some(900), |
|
| 2509 | + | }; |
|
| 2510 | + | assert_eq!(super::timing_line(&job), "waited 12.0 s · image 850 ms · run 2 min 5 s · peak disk 2.1 GiB, memory 900 MiB"); |
|
| 2511 | + | let bare = hub_api::FlowJob { queued_ms: None, phases: vec![], peak_disk_mib: None, peak_memory_mib: None, ..job }; |
|
| 2512 | + | assert_eq!(super::timing_line(&bare), ""); |
|
| 2513 | + | assert_eq!(super::enc_path("data/frame times.csv"), "data/frame%20times.csv"); |
|
| 2514 | + | ||
| 2515 | + | let cli = |
|
| 2516 | + | super::Cli::try_parse_from(["oh", "run", "report", "26", "performance", "--file", "data/frames.csv", "-o", "f.csv"]).unwrap(); |
|
| 2517 | + | let super::Cmd::Run { cmd: super::RunCmd::Report { number, job, pick } } = cli.cmd else { panic!("run report") }; |
|
| 2518 | + | assert_eq!((number, job.as_str(), pick.file.as_deref(), pick.list), (26, "performance", Some("data/frames.csv"), false)); |
|
| 2519 | + | assert!(super::Cli::try_parse_from(["oh", "run", "report", "26", "perf", "--list", "--file", "x"]).is_err(), "one or the other"); |
|
| 2520 | + | assert!(super::Cli::try_parse_from(["oh", "run", "report", "26", "perf", "-o", "x"]).is_err(), "-o is for --file"); |
|
| 2521 | + | let cli = super::Cli::try_parse_from(["oh", "report", "show", "performance", "--bookmark", "main", "--list"]).unwrap(); |
|
| 2522 | + | assert!(matches!(cli.cmd, super::Cmd::Report { cmd: super::ReportCmd::Show { list: true, .. } })); |
|
| 2523 | + | assert!(super::Cli::try_parse_from(["oh", "run", "view", "3", "--timings"]).is_ok()); |
|
| 2524 | + | } |
|
| 2257 | 2525 | ||
| 2258 | 2526 | #[test] |
|
| 2259 | 2527 | fn runner_update_scopes_resolve_by_name_or_id() { |
|
| … | 64 unchanged lines |
modified docs/reports.md +32 −9
| … | 156 unchanged lines |
||
| 157 | 157 | ||
| 158 | 158 | ## 6. The API and `oh` |
|
| 159 | 159 | ||
| 160 | - | - `GET /repos/{o}/{n}/reports` (`?bookmark=`): the latest of each. |
|
| 161 | - | - `GET /repos/{o}/{n}/reports/{name}` (`?bookmark=&limit=&before=`): its |
|
| 162 | - | history, metrics included. |
|
| 163 | - | - `GET /repos/{o}/{n}/reports/{name}/{id}`: one report, and |
|
| 164 | - | `…/{id}/files/{path}` its files. |
|
| 165 | - | - `POST /repos/{o}/{n}/reports/{name}` (repo:write): an upload. |
|
| 166 | - | - `oh report list | view NAME | upload NAME DIR`. |
|
| 160 | + | Built (oxygenhub#348), read access as the repository's pages: |
|
| 167 | 161 | ||
| 162 | + | - `GET /repos/{o}/{n}/flow/runs/{n}/jobs/{job}/reports`: the reports a job |
|
| 163 | + | published, with their metrics and context. |
|
| 164 | + | - `GET /repos/{o}/{n}/flow/runs/{n}/jobs/{job}/report` (`?name=` when the |
|
| 165 | + | job published more than one): the report with its files (`manifest`: |
|
| 166 | + | path and size), and `…/report/{path}` one file as published. |
|
| 167 | + | - `GET /repos/{o}/{n}/reports` (`?bookmark=`): the newest run of each. |
|
| 168 | + | - `GET /repos/{o}/{n}/reports/{name}` (`?id=`, the page's run id, or |
|
| 169 | + | `?bookmark=`, default the repository's default bookmark, else wherever |
|
| 170 | + | it ran last): one run with its files, and `…/{name}/files/{path}` one |
|
| 171 | + | file of it. |
|
| 172 | + | ||
| 173 | + | A file over the API comes as published (an HTML page without the frame |
|
| 174 | + | script), with its type and `Content-Security-Policy: sandbox; |
|
| 175 | + | default-src 'none'`, so nothing in it runs on the hub's origin. A run |
|
| 176 | + | whose files expired answers `410` for them; its metrics stay. The errors |
|
| 177 | + | name what there is: the run's jobs, the job's reports, the report's files. |
|
| 178 | + | ||
| 179 | + | - `oh run report RUN JOB` prints the report's `report.toml`; `--list` |
|
| 180 | + | shows its metrics, context and files; `--file data/frames.csv` writes |
|
| 181 | + | one file to stdout or `-o FILE`; `--name` picks among a job's reports; |
|
| 182 | + | `--json` gives the API's answer. |
|
| 183 | + | - `oh report list [--bookmark B]` and `oh report show NAME [--bookmark B | |
|
| 184 | + | --id ID] [--list | --file PATH [-o FILE]]`. |
|
| 185 | + | ||
| 186 | + | Still to come: a report's history over the API (`?limit=&before=`) and |
|
| 187 | + | uploads from anywhere else (`POST /repos/{o}/{n}/reports/{name}`, `oh |
|
| 188 | + | report upload NAME DIR`, §2). |
|
| 189 | + | ||
| 168 | 190 | ## 7. For a project adopting it |
|
| 169 | 191 | ||
| 170 | 192 | 1. Make the test or benchmark job write `out/report/` with a `report.toml` |
|
| … | 44 unchanged lines |
||
| 215 | 237 | collection: every run on the default bookmark keeps its files, and the |
|
| 216 | 238 | newest 50 runs of each report on any other bookmark (proposal and tag |
|
| 217 | 239 | rules, and a per-repository setting, follow); an expired run keeps its |
|
| 218 | - | metrics and summary, and its page says its files expired. `oh report` |
|
| 219 | - | (4) follows. |
|
| 240 | + | metrics and summary, and its page says its files expired. *2026-10-28:* |
|
| 241 | + | reading reports over the API and `oh run report` / `oh report` (4) are |
|
| 242 | + | built (§6); uploads and history over the API follow. |
|
| 220 | 243 | ||
| 221 | 244 | ||
| 222 | 245 | 1. Storage and index: the `report` output kind in OxygenFlow, the runner's |
|
| … | 5 unchanged lines |
modified docs/spec.md +1 −1
| … | 491 unchanged lines |
||
| 492 | 492 | ||
| 493 | 493 | ### 12.1 API |
|
| 494 | 494 | ||
| 495 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 495 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 496 | 496 | ||
| 497 | 497 | ### 12.2 Webhooks |
|
| 498 | 498 | ||
| … | 396 unchanged lines |