Flow runs, jobs and logs in the API; checks by heads 90326c9601f9
GET /api/v1/repos/{o}/{n}/flow/runs (newest first, ?before=N&limit=),
/flow/runs/{n} with its jobs, and /flow/runs/{n}/jobs/{job}/log
(?after=seq&limit=), typed in hub-api as FlowRun, FlowJob and FlowLogLine.
/checks/{state} also takes a state's heads joined with +, the one head of
a single-head state (what oxy log shows) or a bookmark. Unknown routes
under /api/v1 answer with a JSON problem, not the site's page. The flow
tests wait for the checks that follow a run instead of racing them.
closes #26
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 00:51 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
90326c9601f961ba10c4501883211b726d5e2ea097819caeaf2d3e99ef9fb345- Depends on
- 1edac4b68c6d 42fa95540468 93be9ce4653e a0d4bbfbedd1 b64e2537d702 d23464c0ac60 ef20fdc14e8b
- Depended on by
- fac6853c4e46 b1860d6090ec 026a76d251ce 5d90e0e045e2 501d8b268dd4 17a3a84b00d1 4d2e44d79b03 c889911bec12 d7ce41acf22c 5533e91a2aa4 e9bfea8e26db 4f6925337848 bf3d07541368 760a7dd30d6f
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 17 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/hub-api/src/lib.rs +54 −0
| … | 427 unchanged lines |
||
| 428 | 428 | "*".into() |
|
| 429 | 429 | } |
|
| 430 | 430 | ||
| 431 | + | /// An OxygenFlow run (hub spec §13.2). |
|
| 432 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 433 | + | pub struct FlowRun { |
|
| 434 | + | /// The run's number within its repository. |
|
| 435 | + | pub number: i64, |
|
| 436 | + | pub bookmark: String, |
|
| 437 | + | /// The state's hash, as checks are keyed. |
|
| 438 | + | pub state: String, |
|
| 439 | + | /// Its heads, full change hashes. |
|
| 440 | + | pub heads: Vec<String>, |
|
| 441 | + | /// `queued`, `running`, `passed`, `failed` or `error` (an invalid pipeline). |
|
| 442 | + | pub status: String, |
|
| 443 | + | /// `push`, `manual` or `rerun:N`. |
|
| 444 | + | pub trigger: String, |
|
| 445 | + | pub actor: String, |
|
| 446 | + | pub pipeline_hash: String, |
|
| 447 | + | /// An invalid pipeline's problems. |
|
| 448 | + | pub error: String, |
|
| 449 | + | pub created_ms: i64, |
|
| 450 | + | pub finished_ms: Option<i64>, |
|
| 451 | + | pub url: String, |
|
| 452 | + | /// Filled by the single-run endpoint. |
|
| 453 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 454 | + | pub jobs: Vec<FlowJob>, |
|
| 455 | + | } |
|
| 456 | + | ||
| 457 | + | /// A job of a run. |
|
| 458 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 459 | + | pub struct FlowJob { |
|
| 460 | + | pub name: String, |
|
| 461 | + | /// `queued`, `running`, `passed`, `failed`, `skipped` or `error`. |
|
| 462 | + | pub status: String, |
|
| 463 | + | pub depends_on: Vec<String>, |
|
| 464 | + | pub runner: String, |
|
| 465 | + | pub exit_code: Option<i64>, |
|
| 466 | + | pub error: String, |
|
| 467 | + | pub started_ms: Option<i64>, |
|
| 468 | + | pub finished_ms: Option<i64>, |
|
| 469 | + | /// Log lines stored so far. |
|
| 470 | + | pub log_lines: i64, |
|
| 471 | + | } |
|
| 472 | + | ||
| 473 | + | /// A line of a job's log. |
|
| 474 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 475 | + | pub struct FlowLogLine { |
|
| 476 | + | /// Increasing within the job; pass the last one as `after` for more. |
|
| 477 | + | pub seq: i64, |
|
| 478 | + | /// Milliseconds since the job started. |
|
| 479 | + | pub t: i64, |
|
| 480 | + | /// `stdout`, `stderr` or `flow` (the runner's own notes). |
|
| 481 | + | pub stream: String, |
|
| 482 | + | pub text: String, |
|
| 483 | + | } |
|
| 484 | + | ||
| 431 | 485 | /// A check on a state hash (hub spec §13.1). |
|
| 432 | 486 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 433 | 487 | pub struct Check { |
|
| … | 395 unchanged lines |
modified crates/hub/src/api/mod.rs +123 −2
| … | 16 unchanged lines |
||
| 17 | 17 | ||
| 18 | 18 | mod projects; |
|
| 19 | 19 | ||
| 20 | + | /// Any other path under `/api/v1`: a problem document, not the site's page. |
|
| 21 | + | async fn no_route() -> ApiError { |
|
| 22 | + | ApiError(StatusCode::NOT_FOUND, "no such API route".into()) |
|
| 23 | + | } |
|
| 24 | + | ||
| 20 | 25 | pub fn router() -> Router<Shared> { |
|
| 21 | 26 | Router::new() |
|
| 27 | + | .fallback(no_route) |
|
| 22 | 28 | .route("/flow/claim", post(crate::flow::claim)) |
|
| 23 | 29 | .route("/flow/jobs/{id}/log", post(crate::flow::log)) |
|
| 24 | 30 | .route("/flow/jobs/{id}/finish", post(crate::flow::finish)) |
|
| … | 37 unchanged lines |
||
| 62 | 68 | .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook)) |
|
| 63 | 69 | .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook)) |
|
| 64 | 70 | .route("/repos/{owner}/{name}/checks", post(post_check)) |
|
| 65 | - | .route("/repos/{owner}/{name}/flow/runs", post(start_flow_run)) |
|
| 71 | + | .route("/repos/{owner}/{name}/flow/runs", get(flow_runs).post(start_flow_run)) |
|
| 72 | + | .route("/repos/{owner}/{name}/flow/runs/{number}", get(flow_run)) |
|
| 73 | + | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/log", get(flow_job_log)) |
|
| 66 | 74 | .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run)) |
|
| 67 | 75 | .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks)) |
|
| 68 | 76 | .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state)) |
|
| … | 1358 unchanged lines |
||
| 1427 | 1435 | Ok(Json(RequiredChecks { names })) |
|
| 1428 | 1436 | } |
|
| 1429 | 1437 | ||
| 1438 | + | fn flow_run_of(app: &Shared, row: &db::repos::RepoRow, r: &db::flow::Run, jobs: Vec<db::flow::Job>) -> FlowRun { |
|
| 1439 | + | FlowRun { |
|
| 1440 | + | number: r.number, |
|
| 1441 | + | bookmark: r.bookmark.clone(), |
|
| 1442 | + | state: r.state.clone(), |
|
| 1443 | + | heads: r.head_list().into_iter().map(String::from).collect(), |
|
| 1444 | + | status: r.status.clone(), |
|
| 1445 | + | trigger: r.trigger.clone(), |
|
| 1446 | + | actor: r.actor.clone(), |
|
| 1447 | + | pipeline_hash: r.pipeline_hash.clone(), |
|
| 1448 | + | error: r.error.clone(), |
|
| 1449 | + | created_ms: r.created_ms, |
|
| 1450 | + | finished_ms: r.finished_ms, |
|
| 1451 | + | url: format!("{}/{}/{}/flow/{}", app.config.base_url(), row.owner, row.name, r.number), |
|
| 1452 | + | jobs: jobs |
|
| 1453 | + | .into_iter() |
|
| 1454 | + | .map(|j| FlowJob { |
|
| 1455 | + | depends_on: j.depends_on(), |
|
| 1456 | + | name: j.name, |
|
| 1457 | + | status: j.status, |
|
| 1458 | + | runner: j.runner, |
|
| 1459 | + | exit_code: j.exit_code, |
|
| 1460 | + | error: j.error, |
|
| 1461 | + | started_ms: j.started_ms, |
|
| 1462 | + | finished_ms: j.finished_ms, |
|
| 1463 | + | log_lines: j.log_count, |
|
| 1464 | + | }) |
|
| 1465 | + | .collect(), |
|
| 1466 | + | } |
|
| 1467 | + | } |
|
| 1468 | + | ||
| 1430 | 1469 | #[derive(Deserialize)] |
|
| 1470 | + | struct RunsPage { |
|
| 1471 | + | /// Runs numbered below this one (the next page). |
|
| 1472 | + | before: Option<i64>, |
|
| 1473 | + | limit: Option<i64>, |
|
| 1474 | + | } |
|
| 1475 | + | ||
| 1476 | + | /// `GET /repos/{o}/{n}/flow/runs`: newest first, `?before=N&limit=` (max 100). |
|
| 1477 | + | async fn flow_runs( |
|
| 1478 | + | State(app): State<Shared>, |
|
| 1479 | + | headers: HeaderMap, |
|
| 1480 | + | Path((owner, name)): Path<(String, String)>, |
|
| 1481 | + | Query(q): Query<RunsPage>, |
|
| 1482 | + | ) -> Result<Json<Vec<FlowRun>>, ApiError> { |
|
| 1483 | + | let c = caller(&app, &headers).await?; |
|
| 1484 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 1485 | + | let limit = q.limit.unwrap_or(30).clamp(1, 100); |
|
| 1486 | + | let runs = db::flow::runs_by_number(&app.db, row.id, limit, q.before).await?; |
|
| 1487 | + | Ok(Json(runs.iter().map(|r| flow_run_of(&app, &row, r, vec![])).collect())) |
|
| 1488 | + | } |
|
| 1489 | + | ||
| 1490 | + | /// `GET /repos/{o}/{n}/flow/runs/{number}`: a run with its jobs. |
|
| 1491 | + | async fn flow_run( |
|
| 1492 | + | State(app): State<Shared>, |
|
| 1493 | + | headers: HeaderMap, |
|
| 1494 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1495 | + | ) -> Result<Json<FlowRun>, ApiError> { |
|
| 1496 | + | let c = caller(&app, &headers).await?; |
|
| 1497 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 1498 | + | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 1499 | + | let jobs = db::flow::jobs(&app.db, run.id).await?; |
|
| 1500 | + | Ok(Json(flow_run_of(&app, &row, &run, jobs))) |
|
| 1501 | + | } |
|
| 1502 | + | ||
| 1503 | + | #[derive(Deserialize)] |
|
| 1504 | + | struct LogPage { |
|
| 1505 | + | /// Lines after this `seq`. |
|
| 1506 | + | #[serde(default)] |
|
| 1507 | + | after: i64, |
|
| 1508 | + | limit: Option<i64>, |
|
| 1509 | + | } |
|
| 1510 | + | ||
| 1511 | + | /// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/log`: `?after=seq&limit=` (max 5000). |
|
| 1512 | + | async fn flow_job_log( |
|
| 1513 | + | State(app): State<Shared>, |
|
| 1514 | + | headers: HeaderMap, |
|
| 1515 | + | Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
|
| 1516 | + | Query(q): Query<LogPage>, |
|
| 1517 | + | ) -> Result<Json<Vec<FlowLogLine>>, ApiError> { |
|
| 1518 | + | let c = caller(&app, &headers).await?; |
|
| 1519 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 1520 | + | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 1521 | + | let j = db::flow::jobs(&app.db, run.id) |
|
| 1522 | + | .await? |
|
| 1523 | + | .into_iter() |
|
| 1524 | + | .find(|j| j.name == job) |
|
| 1525 | + | .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?; |
|
| 1526 | + | let lines = db::flow::log(&app.db, j.id, q.after, q.limit.unwrap_or(1000).clamp(1, 5000)).await?; |
|
| 1527 | + | Ok(Json(lines.into_iter().map(|l| FlowLogLine { seq: l.seq, t: l.t, stream: l.stream, text: l.text }).collect())) |
|
| 1528 | + | } |
|
| 1529 | + | ||
| 1530 | + | #[derive(Deserialize)] |
|
| 1431 | 1531 | struct StartRun { |
|
| 1432 | 1532 | /// Default: the repository's default bookmark. |
|
| 1433 | 1533 | #[serde(default)] |
|
| … | 78 unchanged lines |
||
| 1512 | 1612 | Path((owner, name, state)): Path<(String, String, String)>, |
|
| 1513 | 1613 | ) -> Result<Json<Vec<Check>>, ApiError> { |
|
| 1514 | 1614 | let c = caller(&app, &headers).await?; |
|
| 1515 | - | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 1615 | + | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 1616 | + | let state = state_key(&app, &repo, &state).await?; |
|
| 1516 | 1617 | Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &state).await?.iter().map(check_of).collect())) |
|
| 1618 | + | } |
|
| 1619 | + | ||
| 1620 | + | /// The state hash checks are keyed by, from what a client has: a state |
|
| 1621 | + | /// hash, its heads (full change hashes joined with `+`, or the one head of |
|
| 1622 | + | /// a single-head state, which is what `oxy log` shows), or a bookmark. |
|
| 1623 | + | async fn state_key(app: &Shared, repo: &RepoName, s: &str) -> Result<String, ApiError> { |
|
| 1624 | + | use oxy_core::hash::{hash_object, ChangeHash, ObjectType}; |
|
| 1625 | + | let hexes: Vec<&str> = s.split('+').collect(); |
|
| 1626 | + | let all_hashes = hexes.iter().all(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit())); |
|
| 1627 | + | if all_hashes { |
|
| 1628 | + | let row = db::repos::get(&app.db, &repo.owner, &repo.name).await?.ok_or_else(|| not_found("no such repository"))?; |
|
| 1629 | + | if hexes.len() == 1 && !db::hooks::checks_for_state(&app.db, row.id, s).await?.is_empty() { |
|
| 1630 | + | return Ok(s.to_string()); |
|
| 1631 | + | } |
|
| 1632 | + | let heads: Vec<ChangeHash> = hexes.iter().filter_map(|h| ChangeHash::from_hex(h)).collect(); |
|
| 1633 | + | return Ok(hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode()).to_hex()); |
|
| 1634 | + | } |
|
| 1635 | + | let b = s.to_string(); |
|
| 1636 | + | let found = app.repos.read(repo, move |r, _| Ok(r.view()?.bookmarks.get(&b).copied())).await?; |
|
| 1637 | + | found.map(|st| st.to_hex()).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{s:?} is not a state hash, heads or a bookmark"))) |
|
| 1517 | 1638 | } |
modified crates/hub/src/db/flow.rs +10 −0
| … | 147 unchanged lines |
||
| 148 | 148 | Ok(id) |
|
| 149 | 149 | } |
|
| 150 | 150 | ||
| 151 | + | /// A repository's runs, newest first, numbered below `before`. |
|
| 152 | + | pub async fn runs_by_number(pool: &Pool, repo_id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<Run>> { |
|
| 153 | + | sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND number < ? ORDER BY number DESC LIMIT ?") |
|
| 154 | + | .bind(repo_id) |
|
| 155 | + | .bind(before.unwrap_or(i64::MAX)) |
|
| 156 | + | .bind(limit) |
|
| 157 | + | .fetch_all(pool) |
|
| 158 | + | .await |
|
| 159 | + | } |
|
| 160 | + | ||
| 151 | 161 | pub async fn runs(pool: &Pool, repo_id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<Run>> { |
|
| 152 | 162 | sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND id < ? ORDER BY id DESC LIMIT ?") |
|
| 153 | 163 | .bind(repo_id) |
|
| … | 231 unchanged lines |
modified crates/hub/tests/flow.rs +87 −2
| … | 129 unchanged lines |
||
| 130 | 130 | vec![] |
|
| 131 | 131 | } |
|
| 132 | 132 | ||
| 133 | + | /// The checks on a state once there are `n` of them (they follow the run). |
|
| 134 | + | async fn checks_when(app: &Shared, repo_id: i64, state: &str, n: usize) -> Vec<db::hooks::Check> { |
|
| 135 | + | for _ in 0..100 { |
|
| 136 | + | let c = db::hooks::checks_for_state(&app.db, repo_id, state).await.unwrap(); |
|
| 137 | + | if c.len() >= n { |
|
| 138 | + | return c; |
|
| 139 | + | } |
|
| 140 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 141 | + | } |
|
| 142 | + | db::hooks::checks_for_state(&app.db, repo_id, state).await.unwrap() |
|
| 143 | + | } |
|
| 144 | + | ||
| 133 | 145 | #[tokio::test(flavor = "multi_thread")] |
|
| 134 | 146 | async fn a_push_runs_the_pipeline_through_a_runner() { |
|
| 135 | 147 | let h = hub().await; |
|
| … | 17 unchanged lines |
||
| 153 | 165 | assert_eq!(run.pipeline_hash.len(), 64); |
|
| 154 | 166 | let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap(); |
|
| 155 | 167 | assert_eq!(jobs.iter().map(|j| (j.name.as_str(), j.depth)).collect::<Vec<_>>(), [("build", 0), ("lint", 0), ("test", 1)]); |
|
| 156 | - | let checks = db::hooks::checks_for_state(&h.app.db, run.repo_id, &run.state).await.unwrap(); |
|
| 168 | + | // The checks are reported just after the run is recorded. |
|
| 169 | + | let checks = checks_when(&h.app, run.repo_id, &run.state, 3).await; |
|
| 157 | 170 | assert_eq!( |
|
| 158 | 171 | checks.iter().map(|c| (c.name.as_str(), c.status.as_str())).collect::<Vec<_>>(), |
|
| 159 | 172 | [("flow/build", "queued"), ("flow/lint", "queued"), ("flow/test", "queued")] |
|
| … | 371 unchanged lines |
||
| 531 | 544 | // Runs are numbered per repository: this is plain's first, whatever |
|
| 532 | 545 | // other repositories ran before. |
|
| 533 | 546 | assert_eq!(plain[0].number, 1); |
|
| 534 | - | let checks = db::hooks::checks_for_state(&h.app.db, row.id, &plain[0].state).await.unwrap(); |
|
| 547 | + | let checks = checks_when(&h.app, row.id, &plain[0].state, 3).await; |
|
| 535 | 548 | assert!(checks.iter().all(|c| c.url.ends_with("/other/plain/flow/1")), "{checks:?}"); |
|
| 536 | 549 | let (st, page) = call(&h.app, "GET", "/other/plain/flow/1", None, None).await; |
|
| 537 | 550 | assert!(st == StatusCode::OK && page.contains("Run #1"), "{st}"); |
|
| … | 66 unchanged lines |
||
| 604 | 617 | db::repos::set_flow(&h.app.db, row.id, "off").await.unwrap(); |
|
| 605 | 618 | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some("{}".into())).await; |
|
| 606 | 619 | assert!(st == StatusCode::CONFLICT && body.contains("not enabled"), "{body}"); |
|
| 620 | + | } |
|
| 621 | + | ||
| 622 | + | /// The Flow API (hub spec §13.2): runs, a run with its jobs, a job's log, |
|
| 623 | + | /// checks by the heads a client has, and JSON for unknown API routes. |
|
| 624 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 625 | + | async fn runs_jobs_and_logs_are_in_the_api() { |
|
| 626 | + | let h = hub().await; |
|
| 627 | + | create(&h.app, "andy", "demo").await; |
|
| 628 | + | let url = format!("{}/andy/demo", h.base); |
|
| 629 | + | let work = tempfile::tempdir().unwrap(); |
|
| 630 | + | let c = client(&url, work.path()); |
|
| 631 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 632 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 633 | + | let head = c.commit("Add a pipeline", &[]).unwrap(); |
|
| 634 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 635 | + | let run = runs_of(&h.app, "andy", "demo").await.remove(0); |
|
| 636 | + | ||
| 637 | + | // A job runs and logs. |
|
| 638 | + | let a = claim(&h.app).await.unwrap(); |
|
| 639 | + | let id = a["job_id"].as_i64().unwrap(); |
|
| 640 | + | let recs = |
|
| 641 | + | serde_json::json!({ "records": [{ "t": 1, "stream": "stdout", "text": "one" }, { "t": 3, "stream": "stderr", "text": "three" }] }); |
|
| 642 | + | let (st, _) = call(&h.app, "POST", &format!("/api/v1/flow/jobs/{id}/log"), Some("runner-secret"), Some(recs.to_string())).await; |
|
| 643 | + | assert!(st.is_success(), "{st}"); |
|
| 644 | + | ||
| 645 | + | let (st, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs", None, None).await; |
|
| 646 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 647 | + | let runs: Vec<hub_api::FlowRun> = serde_json::from_str(&body).unwrap(); |
|
| 648 | + | assert_eq!(runs.len(), 1); |
|
| 649 | + | assert_eq!((runs[0].number, runs[0].state.as_str(), runs[0].trigger.as_str()), (1, run.state.as_str(), "push")); |
|
| 650 | + | assert!(runs[0].url.ends_with("/andy/demo/flow/1") && runs[0].jobs.is_empty()); |
|
| 651 | + | ||
| 652 | + | let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/1", None, None).await; |
|
| 653 | + | let one: hub_api::FlowRun = serde_json::from_str(&body).unwrap(); |
|
| 654 | + | let names: Vec<(&str, &str)> = one.jobs.iter().map(|j| (j.name.as_str(), j.status.as_str())).collect(); |
|
| 655 | + | assert_eq!(names.len(), 3); |
|
| 656 | + | let running = one.jobs.iter().find(|j| j.status == "running").unwrap(); |
|
| 657 | + | assert_eq!(running.name, a["job"]["name"].as_str().unwrap()); |
|
| 658 | + | assert!(one.jobs.iter().any(|j| j.name == "test" && j.depends_on == ["build"])); |
|
| 659 | + | ||
| 660 | + | let job = running.name.clone(); |
|
| 661 | + | let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/1/jobs/{job}/log"), None, None).await; |
|
| 662 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 663 | + | let lines: Vec<hub_api::FlowLogLine> = serde_json::from_str(&body).unwrap(); |
|
| 664 | + | assert!(lines.iter().any(|l| l.text == "three"), "{body}"); |
|
| 665 | + | let last = lines.last().unwrap().seq; |
|
| 666 | + | let (_, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/1/jobs/{job}/log?after={last}"), None, None).await; |
|
| 667 | + | assert_eq!(body, "[]", "nothing after the last line"); |
|
| 668 | + | assert_eq!(call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/9", None, None).await.0, StatusCode::NOT_FOUND); |
|
| 669 | + | assert_eq!(call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/1/jobs/nope/log", None, None).await.0, StatusCode::NOT_FOUND); |
|
| 670 | + | ||
| 671 | + | // Checks by the state hash, by the head `oxy log` shows, and by bookmark. |
|
| 672 | + | let by = |key: String| { |
|
| 673 | + | let app = h.app.clone(); |
|
| 674 | + | async move { |
|
| 675 | + | let (st, body) = call(&app, "GET", &format!("/api/v1/repos/andy/demo/checks/{key}"), None, None).await; |
|
| 676 | + | assert_eq!(st, StatusCode::OK, "{key}: {body}"); |
|
| 677 | + | serde_json::from_str::<Vec<hub_api::Check>>(&body).unwrap().len() |
|
| 678 | + | } |
|
| 679 | + | }; |
|
| 680 | + | checks_when(&h.app, run.repo_id, &run.state, 3).await; |
|
| 681 | + | assert_eq!(by(run.state.clone()).await, 3); |
|
| 682 | + | assert_eq!(by(head.to_hex()).await, 3, "a single head names its state"); |
|
| 683 | + | assert_eq!(by("main".into()).await, 3); |
|
| 684 | + | let (st, _) = call(&h.app, "GET", "/api/v1/repos/andy/demo/checks/no-such-bookmark", None, None).await; |
|
| 685 | + | assert_eq!(st, StatusCode::NOT_FOUND); |
|
| 686 | + | ||
| 687 | + | // An unknown API route answers with a problem document. |
|
| 688 | + | let (st, body) = call(&h.app, "GET", "/api/v1/users/andy/repos", None, None).await; |
|
| 689 | + | assert_eq!(st, StatusCode::NOT_FOUND); |
|
| 690 | + | let v: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 691 | + | assert_eq!(v["status"], 404, "{body}"); |
|
| 607 | 692 | } |
modified docs/spec.md +1 −1
| … | 399 unchanged lines |
||
| 400 | 400 | ||
| 401 | 401 | ### 12.1 API |
|
| 402 | 402 | ||
| 403 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, 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`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files|land`, `/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`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/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, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`POST {"bookmark"}`, repo:write: run a bookmark's current state, the default bookmark when none is named) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. 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. |
|
| 403 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route 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`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files|land`, `/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`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/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, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}`, repo:write: run a bookmark's current state, the default bookmark when none is named), `/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, `/search`. 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. |
|
| 404 | 404 | ||
| 405 | 405 | ### 12.2 Webhooks |
|
| 406 | 406 | ||
| … | 178 unchanged lines |