Flow: start one manual job, with the jobs it depends on 0650d94ede82
POST /flow/runs takes "jobs": [NAME..] and oh run start --job NAME (repeatable): a manual run of only those jobs and what they depend on, so asking for a report does not start an hour-long test job too. A job the pipeline lacks, or one no manual run starts, is refused with the names of those that are. closes #130
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-11 11:29 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
0650d94ede82feeb89b960cb8dcfe5b99d5830ad61369b4ae67f9629144c9779- Depends on
- 1edac4b68c6d 348f8c4baccf 42fa95540468 4f6925337848 5c6e92fd8d71 93be9ce4653e b1e92a6233ee c889911bec12 d23464c0ac60 d7ce41acf22c
- Depended on by
- 40dd3f86379a
- 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.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
- 25 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/hub/src/api/mod.rs +14 −3
| … | 1928 unchanged lines |
||
| 1929 | 1929 | /// of a bookmark's. |
|
| 1930 | 1930 | #[serde(default)] |
|
| 1931 | 1931 | change: Option<String>, |
|
| 1932 | + | /// Only these jobs, with what they depend on (oxygenhub#130); default: |
|
| 1933 | + | /// every job a manual run starts. |
|
| 1934 | + | #[serde(default)] |
|
| 1935 | + | jobs: Vec<String>, |
|
| 1932 | 1936 | } |
|
| 1933 | 1937 | ||
| 1934 | 1938 | fn started(app: &Shared, row: &db::repos::RepoRow, number: i64) -> (StatusCode, Json<serde_json::Value>) { |
|
| … | 12 unchanged lines |
||
| 1947 | 1951 | let c = caller(&app, &headers).await?; |
|
| 1948 | 1952 | let me = c.require("repo:write")?.clone(); |
|
| 1949 | 1953 | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
|
| 1950 | - | let (n, what) = match body.change.filter(|c| !c.trim().is_empty()) { |
|
| 1954 | + | let (n, what) = match body.change.clone().filter(|c| !c.trim().is_empty()) { |
|
| 1951 | 1955 | Some(change) => { |
|
| 1952 | - | let n = crate::flow::run_change(&app, &row, &change, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 1956 | + | let n = crate::flow::run_change(&app, &row, &change, &me.username, &body.jobs) |
|
| 1957 | + | .await |
|
| 1958 | + | .map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 1953 | 1959 | (n, format!("change {}", change.trim())) |
|
| 1954 | 1960 | } |
|
| 1955 | 1961 | None => { |
|
| 1956 | 1962 | let bookmark = body.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 1957 | - | (crate::flow::run_bookmark(&app, &row, &bookmark, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?, bookmark) |
|
| 1963 | + | ( |
|
| 1964 | + | crate::flow::run_bookmark(&app, &row, &bookmark, &me.username, &body.jobs) |
|
| 1965 | + | .await |
|
| 1966 | + | .map_err(|m| ApiError(StatusCode::CONFLICT, m))?, |
|
| 1967 | + | bookmark, |
|
| 1968 | + | ) |
|
| 1958 | 1969 | } |
|
| 1959 | 1970 | }; |
|
| 1960 | 1971 | db::accounts::audit(&app.db, Some(&me), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {what}")).await.ok(); |
|
| … | 413 unchanged lines |
modified crates/hub/src/flow.rs +29 −13
| … | 53 unchanged lines |
||
| 54 | 54 | ||
| 55 | 55 | /// "Run pipeline" (hub spec §13.2): run a bookmark's current state without |
|
| 56 | 56 | /// moving it. Returns the new run's number. |
|
| 57 | - | pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> { |
|
| 57 | + | /// `jobs`, when not empty, are the only jobs to start (oxygenhub#130), with |
|
| 58 | + | /// the jobs they depend on. |
|
| 59 | + | pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str, jobs: &[String]) -> Result<i64, String> { |
|
| 58 | 60 | if !enabled(app, row) { |
|
| 59 | 61 | return Err("OxygenFlow is not enabled for this repository".into()); |
|
| 60 | 62 | } |
|
| … | 11 unchanged lines |
||
| 72 | 74 | if heads.is_empty() { |
|
| 73 | 75 | return Err(format!("{bookmark} has no changes yet")); |
|
| 74 | 76 | } |
|
| 75 | - | start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual") |
|
| 77 | + | start_run_at(app, row, bookmark, &state.to_hex(), &heads, actor, "manual", None, None, jobs) |
|
| 76 | 78 | .await? |
|
| 77 | 79 | .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}")) |
|
| 78 | 80 | } |
|
| 79 | 81 | ||
| 80 | 82 | /// "Run pipeline" on one committed change: the state whose only head is |
|
| 81 | 83 | /// `change`, whatever bookmarks hold it. Returns the new run's number. |
|
| 82 | - | pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> { |
|
| 84 | + | pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str, jobs: &[String]) -> Result<i64, String> { |
|
| 83 | 85 | if !enabled(app, row) { |
|
| 84 | 86 | return Err("OxygenFlow is not enabled for this repository".into()); |
|
| 85 | 87 | } |
|
| … | 4 unchanged lines |
||
| 90 | 92 | return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())])); |
|
| 91 | 93 | } |
|
| 92 | 94 | let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode()); |
|
| 93 | - | start_run(app, row, "", &state.to_hex(), &[h], actor, "manual") |
|
| 95 | + | start_run_at(app, row, "", &state.to_hex(), &[h], actor, "manual", None, None, jobs) |
|
| 94 | 96 | .await? |
|
| 95 | 97 | .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}")) |
|
| 96 | 98 | } |
|
| … | 172 unchanged lines |
||
| 269 | 271 | trigger: &str, |
|
| 270 | 272 | touched: Option<&[String]>, |
|
| 271 | 273 | ) -> Result<Option<i64>, String> { |
|
| 272 | - | start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None).await |
|
| 274 | + | start_run_at(app, row, bookmark, state, heads, actor, trigger, touched, None, &[]).await |
|
| 273 | 275 | } |
|
| 274 | 276 | ||
| 275 | - | /// [`start_run_with`], for a scheduled run due at `at` on the hub's clock. |
|
| 277 | + | /// [`start_run_with`], for a scheduled run due at `at` on the hub's clock; |
|
| 278 | + | /// `only`, when not empty, the jobs a manual run starts (oxygenhub#130). |
|
| 276 | 279 | #[allow(clippy::too_many_arguments)] |
|
| 277 | 280 | async fn start_run_at( |
|
| 278 | 281 | app: &Shared, |
|
| … | 5 unchanged lines |
||
| 284 | 287 | trigger: &str, |
|
| 285 | 288 | touched: Option<&[String]>, |
|
| 286 | 289 | at: Option<flow_core::pipeline::When>, |
|
| 290 | + | only: &[String], |
|
| 287 | 291 | ) -> Result<Option<i64>, String> { |
|
| 288 | 292 | let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
|
| 289 | 293 | let h = heads.to_vec(); |
|
| … | 28 unchanged lines |
||
| 318 | 322 | Some(w) => flow_core::pipeline::Event::ScheduleAt(w), |
|
| 319 | 323 | None => event_of(trigger, bookmark), |
|
| 320 | 324 | }; |
|
| 321 | - | let mut keep: std::collections::HashSet<String> = p |
|
| 322 | - | .order() |
|
| 323 | - | .into_iter() |
|
| 324 | - | .filter(|j| p.on_of(j).matches(event) && in_scope(&j.scope, touched)) |
|
| 325 | - | .map(|j| j.name.clone()) |
|
| 326 | - | .collect(); |
|
| 325 | + | let starts = |j: &flow_core::Job| p.on_of(j).matches(event) && in_scope(&j.scope, touched); |
|
| 326 | + | let mut keep: std::collections::HashSet<String> = if only.is_empty() { |
|
| 327 | + | p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.clone()).collect() |
|
| 328 | + | } else { |
|
| 329 | + | // Named jobs (oxygenhub#130): each one this event starts. |
|
| 330 | + | let startable: Vec<&str> = p.order().into_iter().filter(|j| starts(j)).map(|j| j.name.as_str()).collect(); |
|
| 331 | + | for name in only { |
|
| 332 | + | if !startable.contains(&name.as_str()) { |
|
| 333 | + | let what = if p.jobs.contains_key(name) { |
|
| 334 | + | "is not started by hand (no `on { manual }`)" |
|
| 335 | + | } else { |
|
| 336 | + | "is not in the pipeline" |
|
| 337 | + | }; |
|
| 338 | + | return Err(format!("job {name:?} {what}; jobs a manual run can start: {}", startable.join(", "))); |
|
| 339 | + | } |
|
| 340 | + | } |
|
| 341 | + | only.iter().cloned().collect() |
|
| 342 | + | }; |
|
| 327 | 343 | loop { |
|
| 328 | 344 | let needed: Vec<String> = p |
|
| 329 | 345 | .order() |
|
| … | 910 unchanged lines |
||
| 1240 | 1256 | if heads.is_empty() { |
|
| 1241 | 1257 | continue; |
|
| 1242 | 1258 | } |
|
| 1243 | - | match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when)).await { |
|
| 1259 | + | match start_run_at(app, &row, &row.default_bookmark, &state.to_hex(), &heads, &row.owner, "schedule", None, Some(when), &[]).await { |
|
| 1244 | 1260 | Ok(Some(_)) => started += 1, |
|
| 1245 | 1261 | Ok(None) => {} |
|
| 1246 | 1262 | Err(e) => tracing::warn!("flow: scheduled run of {}/{}: {e}", row.owner, row.name), |
|
| … | 277 unchanged lines |
modified crates/hub/src/web/flow.rs +2 −2
| … | 128 unchanged lines |
||
| 129 | 129 | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 130 | 130 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 131 | 131 | let (started, what) = if f.change.trim().is_empty() { |
|
| 132 | - | (crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username).await, f.bookmark.trim().to_string()) |
|
| 132 | + | (crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username, &[]).await, f.bookmark.trim().to_string()) |
|
| 133 | 133 | } else { |
|
| 134 | - | (crate::flow::run_change(&app, &row, &f.change, &me.account.username).await, format!("change {}", f.change.trim())) |
|
| 134 | + | (crate::flow::run_change(&app, &row, &f.change, &me.account.username, &[]).await, format!("change {}", f.change.trim())) |
|
| 135 | 135 | }; |
|
| 136 | 136 | Ok(match started { |
|
| 137 | 137 | Ok(n) => { |
|
| … | 610 unchanged lines |
modified crates/hub/tests/flow.rs +12 −0
| … | 668 unchanged lines |
||
| 669 | 669 | assert!(page.contains("re-run of #1 by andy")); |
|
| 670 | 670 | assert!(!page.contains("RE-RUN"), "anonymous viewers get no button"); |
|
| 671 | 671 | ||
| 672 | + | // oxygenhub#130: one job, with what it depends on; an unknown one is |
|
| 673 | + | // refused, naming the jobs a manual run can start. |
|
| 674 | + | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some(r#"{"jobs":["test"]}"#.into())).await; |
|
| 675 | + | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| 676 | + | let n = serde_json::from_str::<serde_json::Value>(&body).unwrap()["number"].as_i64().unwrap(); |
|
| 677 | + | let only = db::flow::run(&h.app.db, first[0].repo_id, n).await.unwrap().unwrap(); |
|
| 678 | + | let mut names: Vec<String> = db::flow::jobs(&h.app.db, only.id).await.unwrap().into_iter().map(|j| j.name).collect(); |
|
| 679 | + | names.sort(); |
|
| 680 | + | assert_eq!(names, ["build", "test"], "test and the job it depends on, not lint"); |
|
| 681 | + | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some(r#"{"jobs":["deploy"]}"#.into())).await; |
|
| 682 | + | assert!(st == StatusCode::CONFLICT && body.contains("not in the pipeline") && body.contains("build, lint, test"), "{st} {body}"); |
|
| 683 | + | ||
| 672 | 684 | // With Flow off, nothing starts. |
|
| 673 | 685 | db::repos::set_flow(&h.app.db, row.id, "off").await.unwrap(); |
|
| 674 | 686 | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some("{}".into())).await; |
|
| … | 1678 unchanged lines |
modified crates/oh/src/main.rs +8 −2
| … | 463 unchanged lines |
||
| 464 | 464 | /// A change's full hash |
|
| 465 | 465 | #[arg(long, conflicts_with = "bookmark")] |
|
| 466 | 466 | change: Option<String>, |
|
| 467 | + | /// Only this job, with the jobs it depends on (repeatable; default: every job a manual run starts) |
|
| 468 | + | #[arg(long = "job", value_name = "NAME")] |
|
| 469 | + | jobs: Vec<String>, |
|
| 467 | 470 | /// Wait for the run and exit 1 unless it passes |
|
| 468 | 471 | #[arg(long)] |
|
| 469 | 472 | watch: bool, |
|
| … | 568 unchanged lines |
||
| 1038 | 1041 | } |
|
| 1039 | 1042 | print_run(&c.get(&path)?); |
|
| 1040 | 1043 | } |
|
| 1041 | - | RunCmd::Start { bookmark, change, watch: follow } => { |
|
| 1042 | - | let body = match (bookmark, change) { |
|
| 1044 | + | RunCmd::Start { bookmark, change, jobs, watch: follow } => { |
|
| 1045 | + | let mut body = match (bookmark, change) { |
|
| 1043 | 1046 | (_, Some(ch)) => serde_json::json!({ "change": ch }), |
|
| 1044 | 1047 | (Some(b), None) => serde_json::json!({ "bookmark": b }), |
|
| 1045 | 1048 | (None, None) => serde_json::json!({}), |
|
| 1046 | 1049 | }; |
|
| 1050 | + | if !jobs.is_empty() { |
|
| 1051 | + | body["jobs"] = serde_json::json!(jobs); |
|
| 1052 | + | } |
|
| 1047 | 1053 | let started: serde_json::Value = c.send("POST", &format!("{base}/flow/runs"), &body)?; |
|
| 1048 | 1054 | let n = started["number"].as_i64().context("the hub named no run")?; |
|
| 1049 | 1055 | if !json || !follow { |
|
| … | 428 unchanged lines |
modified docs/spec.md +1 −1
| … | 459 unchanged lines |
||
| 460 | 460 | ||
| 461 | 461 | ### 12.1 API |
|
| 462 | 462 | ||
| 463 | - | 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`, `/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, 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; `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=`), `/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), `/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}/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. |
|
| 463 | + | 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`, `/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, 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; `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=`), `/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}/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. |
|
| 464 | 464 | ||
| 465 | 465 | ### 12.2 Webhooks |
|
| 466 | 466 | ||
| … | 285 unchanged lines |