OXYGENHUB

Your sky over the API: GET /api/v1/user/sky, the dashboard backdrop's data for the Android app; 0.9.11 (refs andymccall/oxygenhub-android#23) 4dfa4599b3f7

The caller's member repositories they may read, newest-updated first, and their events of the last 30 days as [repo, kind, at_ms, n], as the web dashboard's sky reads them. 0.9.11 also takes C1 and C2 of #296 (pass reuse from any repository, on a verified receipt).
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2d011ed2eef1
When
· 2026-10-27 23:53 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
4dfa4599b3f7e3d1150681fcfca0c2e459262806e6248033cc1ae9cb27de1912
Depends on
06b756358f7e 0a3b244b748f 17ca6cddc534 17dc8de4fca8 25ab8c73514a 5714347a6233 5c6e92fd8d71 5d818ec7d5fc 93be9ce4653e a7e6e8e6094c a9088323c390 b5668108487f b64e2537d702 d23464c0ac60 df5c69cbd047 ecac6e8d9b41 f4e138301198
Depended on by
a22e4aec2e28 8f1d76f4169a 3d086bc3d69b 8306eca4e4a3 851a26a2a6d1 1ec29691b67a
In bookmarks
main
In tags
v0.9.11 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
18 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified CHANGELOG.md +3 −1
…
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 -
## Unreleased
5 +
## 0.9.11 (2026-10-28)
6 6
7 +
- **Your sky over the API (oxygenhub-android#23).** `GET /api/v1/user/sky` gives what the dashboard's backdrop draws: the caller's repositories and their pushes, proposal and issue events, releases and Flow runs of the last 30 days, so the Android app draws the same sky from the same data.
8 +
7 9
<<<<<<< oxy conflict 1 of 1, side 1 of 2: change 681d2bd9 "Approval gates and expedite for clients: a run's jobs over the API say their approval, and POST …/flow/runs/N/expedite makes a run urgent as the run page does, sharing flow::expedite (refs oxygenhub-android#5)"
8 10
- **Approval gates and expedite for clients (oxygenhub-android#5).** A run's jobs over the API say `approval` (`waiting` while one waits for a person, `approved` or `rejected` after), and `POST /api/v1/repos/OWNER/NAME/flow/runs/N/expedite {"reason": …}` makes a run urgent as the run page's Expedite does (a maintainer, with a reason, within the owner's cap of urgent runs); the page and the API share it.
9 11
…
391 unchanged lines
modified Cargo.lock +3 −3
…
1459 unchanged lines
1460 1460
1461 1461
[[package]]
1462 1462
name = "hub-api"
1463 -
version = "0.9.10"
1463 +
version = "0.9.11"
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.10"
2267 +
version = "0.9.11"
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.10"
2360 +
version = "0.9.11"
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.10"
10 +
version = "0.9.11"
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 +16 −0
…
728 unchanged lines
729 729
    pub landable: Vec<ProposalBrief>,
730 730
}
731 731
732 +
/// `GET /user/sky` (oxygenhub-android#23): Your sky's data, as the web
733 +
/// dashboard's backdrop reads it (docs/backgrounds.md §5): the caller's
734 +
/// repositories, newest-updated first, and their events of the last 30
735 +
/// days, newest first.
736 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
737 +
pub struct UserSky {
738 +
    /// The hub's clock (milliseconds), to age the events by.
739 +
    pub now: i64,
740 +
    /// `owner/name` of each repository.
741 +
    pub repos: Vec<String>,
742 +
    /// `[repo, kind, at_ms, n]`: an index into `repos`; kind 0 a push of
743 +
    /// `n` changes, 1 a proposal event, 2 an issue event, 3 a release, 4 a
744 +
    /// Flow run.
745 +
    pub events: Vec<[i64; 4]>,
746 +
}
747 +
732 748
/// `GET /user/proposals` query.
733 749
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
734 750
pub struct UserProposalsQuery {
…
923 unchanged lines
modified crates/hub/src/api/mod.rs +24 −0
…
107 unchanged lines
108 108
        .route("/user/repos", get(user_repos))
109 109
        .route("/user/orgs", get(user_orgs))
110 110
        .route("/user/proposals", get(user_proposals))
111 +
        .route("/user/sky", get(user_sky))
111 112
        .route("/user/runner-token", post(user_runner_token))
112 113
        .route("/user/token", axum::routing::delete(revoke_own_token))
113 114
        .route("/user/push", axum::routing::put(register_push).delete(forget_push))
…
653 unchanged lines
767 768
    Ok(found.into_iter().flatten().collect())
768 769
}
769 770
771 +
/// `GET /user/sky` (oxygenhub-android#23): Your sky's data for the app,
772 +
/// what the web dashboard's backdrop draws: the repositories the caller
773 +
/// belongs to and may read, and their events of the last 30 days. Only the
774 +
/// database is read, never a repository's store.
775 +
async fn user_sky(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<hub_api::UserSky>, ApiError> {
776 +
    let c = caller(&app, &headers).await?;
777 +
    let me = c.require("repo:read")?.clone();
778 +
    let member = db::accounts::visible_repo_ids(&app.db, me.id).await?;
779 +
    let mut mine = Vec::new();
780 +
    for r in db::repos::list(&app.db).await? {
781 +
        if member.contains(&r.id) && auth::roles::allows(auth::roles::role_for(&app.db, Some(&me), &r).await?, Access::Read) {
782 +
            mine.push(r);
783 +
        }
784 +
    }
785 +
    mine.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
786 +
    let ids: Vec<i64> = mine.iter().map(|r| r.id).collect();
787 +
    let now = db::now_ms();
788 +
    let since = now - crate::web::home::SKY_DAYS * 86_400_000;
789 +
    let events = db::activity::sky(&app.db, &ids, since, crate::web::home::SKY_EVENTS).await?;
790 +
    let events = events.iter().filter_map(|e| Some([ids.iter().position(|&id| id == e.repo_id)? as i64, e.kind, e.at_ms, e.n])).collect();
791 +
    Ok(Json(hub_api::UserSky { now, repos: mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect(), events }))
792 +
}
793 +
770 794
async fn user_repos(
771 795
    State(app): State<Shared>,
772 796
    headers: HeaderMap,
…
2498 unchanged lines
modified crates/hub/src/web/home.rs +2 −2
…
65 unchanged lines
66 66
/// How many activity lines the dashboard shows.
67 67
const FEED_LIMIT: i64 = 40;
68 68
/// The most events Your sky reads, and how far back (docs/backgrounds.md §5).
69 -
const SKY_EVENTS: i64 = 1500;
69 +
pub(crate) const SKY_EVENTS: i64 = 1500;
70 70
/// The most repositories the hub's sky draws, the most recently active.
71 71
const SKY_REPOS: usize = 40;
72 -
const SKY_DAYS: i64 = 30;
72 +
pub(crate) const SKY_DAYS: i64 = 30;
73 73
74 74
/// Your sky's data (docs/backgrounds.md §5): the viewer's repositories and
75 75
/// their events of the last 30 days, newest first, as
…
422 unchanged lines
modified crates/hub/tests/api.rs +9 −0
…
380 unchanged lines
381 381
    let (_, v) = api(app, "GET", "/api/v1/user/proposals?landable=true", Some(&andy_ro), None).await;
382 382
    assert_eq!((v["landable"][0]["repository"].as_str(), v["landable"][0]["number"].as_i64()), (Some("andy/demo"), Some(2)), "{v}");
383 383
    assert!(api(app, "GET", "/api/v1/user/proposals", Some(&andy_ro), None).await.1.get("landable").is_none(), "only when asked");
384 +
    // Your sky for the app (oxygenhub-android#23): the dashboard's backdrop data.
385 +
    let (st, v) = api(app, "GET", "/api/v1/user/sky", Some(&andy_ro), None).await;
386 +
    assert_eq!(st, StatusCode::OK, "{v}");
387 +
    let demo = v["repos"].as_array().unwrap().iter().position(|r| r == "andy/demo").expect("andy's repository") as i64;
388 +
    let events = v["events"].as_array().unwrap();
389 +
    assert!(events.iter().any(|e| e[0] == demo && e[1] == 0), "a push: {v}");
390 +
    assert!(events.iter().any(|e| e[0] == demo && e[1] == 1), "a proposal event: {v}");
391 +
    assert!(events.iter().all(|e| e[2].as_i64().unwrap() <= v["now"].as_i64().unwrap()));
392 +
    assert_eq!(api(app, "GET", "/api/v1/user/sky", None, None).await.0, StatusCode::UNAUTHORIZED);
384 393
    let page = call(app, "GET", "/andy/demo/proposal/2", None, Some(&andy), None, None).await;
385 394
    assert!(page.body.contains("42 tests"), "{}", page.body);
386 395
    // 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`, `/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), `/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