OXYGENHUB

A repository's sky over the API: GET /api/v1/repos/OWNER/NAME/sky (refs andymccall/oxygenhub-android#23) 851a26a2a6d1

The backdrop a repository's pages draw (#196), its events of the last 30 days, in the shape of /user/sky, for the Android app's repository screen; read access, as its pages.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 11be87c0b17c
When
· 2026-10-28 01:37 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
851a26a2a6d1b5ed058be51bf362a81b285687aa6e3f78e5ce948e5b188a4e9c
Depends on
1ec29691b67a 3f4b0ea6eabf 4dfa4599b3f7 93be9ce4653e b5668108487f b64e2537d702 ced6d3afc5cf d23464c0ac60
Depended on by
3d086bc3d69b
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
7 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A repository's sky over the API (oxygenhub-android#23).** `GET /api/v1/repos/OWNER/NAME/sky` gives the backdrop a repository's pages draw (#196), its events of the last 30 days, in the shape of `/user/sky`, for the Android app's repository screen; read access, as the repository's pages.
8 +
7 9
- **Runner updates in waves (oxygenflow#84).** A test group can be given a wave (1 to 9) on Admin › Runners › Updates or an organisation's settings: a new version reaches wave 2's runners only once every runner of wave 1 that took it has finished a number of jobs on it (5 by default) with none ending in error, or a number of hours (24) has passed since its first job there; runners in no wave come last. A job that ends in `error` on a wave's runner (the runner's or the machine's fault; failed tests do not count) stops the version: the next runner to ask holds it for every runner, and the page names the job, until an administrator lifts the hold. Each job now keeps the runner build it was claimed by (migration 0102). A runner waiting says so: "flow 0.5.1 waits for wave 1 to prove it: 1 of 2 runners on it, 3 jobs". First slice: errors are not yet weighed against the version before, and a stopped wave's runners are not rolled back by the hub.
8 10
9 11
## 0.9.11 (2026-10-28)
…
397 unchanged lines
modified crates/hub-api/src/lib.rs +1 −0
…
732 unchanged lines
733 733
/// dashboard's backdrop reads it (docs/backgrounds.md §5): the caller's
734 734
/// repositories, newest-updated first, and their events of the last 30
735 735
/// days, newest first.
736 +
/// `GET /repos/{owner}/{name}/sky` gives one repository's in the same shape.
736 737
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
737 738
pub struct UserSky {
738 739
    /// The hub's clock (milliseconds), to age the events by.
…
938 unchanged lines
modified crates/hub/src/api/mod.rs +21 −0
…
205 unchanged lines
206 206
        .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks))
207 207
        .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state))
208 208
        .route("/repos/{owner}/{name}/releases", get(releases).post(create_release))
209 +
        .route("/repos/{owner}/{name}/sky", get(repo_sky))
209 210
        .route("/repos/{owner}/{name}/releases/{tag}", axum::routing::patch(edit_release))
210 211
        .route(
211 212
            "/repos/{owner}/{name}/releases/{tag}/assets",
…
13 unchanged lines
225 226
    Ok(Json(crate::web::releases::api_views(&app, &repo, &row).await?))
226 227
}
227 228
229 +
/// `GET /repos/{owner}/{name}/sky` (oxygenhub-android#23): the
230 +
/// repository's sky, its pages' backdrop (#196), in the shape of
231 +
/// `/user/sky`: the one repository and its events of the last 30 days.
232 +
async fn repo_sky(
233 +
    State(app): State<Shared>,
234 +
    headers: HeaderMap,
235 +
    Path((owner, name)): Path<(String, String)>,
236 +
) -> Result<Json<hub_api::UserSky>, ApiError> {
237 +
    let c = caller(&app, &headers).await?;
238 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
239 +
    let now = db::now_ms();
240 +
    let since = now - crate::web::home::SKY_DAYS * 86_400_000;
241 +
    let events = db::activity::sky(&app.db, &[row.id], since, crate::web::home::SKY_EVENTS).await?;
242 +
    Ok(Json(hub_api::UserSky {
243 +
        now,
244 +
        repos: vec![format!("{}/{}", row.owner, row.name)],
245 +
        events: events.iter().map(|e| [0, e.kind, e.at_ms, e.n]).collect(),
246 +
    }))
247 +
}
248 +
228 249
async fn create_release(
229 250
    State(app): State<Shared>,
230 251
    headers: HeaderMap,
…
3065 unchanged lines
modified crates/hub/tests/api.rs +6 −0
…
389 unchanged lines
390 390
    assert!(events.iter().any(|e| e[0] == demo && e[1] == 1), "a proposal event: {v}");
391 391
    assert!(events.iter().all(|e| e[2].as_i64().unwrap() <= v["now"].as_i64().unwrap()));
392 392
    assert_eq!(api(app, "GET", "/api/v1/user/sky", None, None).await.0, StatusCode::UNAUTHORIZED);
393 +
    // One repository's, its pages' backdrop, in the same shape.
394 +
    let (st, v) = api(app, "GET", "/api/v1/repos/andy/demo/sky", Some(&andy_ro), None).await;
395 +
    assert_eq!(st, StatusCode::OK, "{v}");
396 +
    assert_eq!(v["repos"], serde_json::json!(["andy/demo"]));
397 +
    assert!(v["events"].as_array().unwrap().iter().all(|e| e[0] == 0) && v["events"].as_array().unwrap().iter().any(|e| e[1] == 0), "{v}");
398 +
    assert_eq!(api(app, "GET", "/api/v1/repos/andy/nope/sky", Some(&andy_ro), None).await.0, StatusCode::NOT_FOUND);
393 399
    let page = call(app, "GET", "/andy/demo/proposal/2", None, Some(&andy), None, None).await;
394 400
    assert!(page.body.contains("42 tests"), "{}", page.body);
395 401
    // The check and the proposal reached the webhook too.
…
1422 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), `/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), `/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.
496 496
497 497
### 12.2 Webhooks
498 498
…
396 unchanged lines