OXYGENHUB

GET /user/issues: the open issues assigned to the caller, for apps and the Android widget (closes #359) 8f1d76f4169a

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 1ba9469cf8af
When
· 2026-11-01 14:45 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
8f1d76f4169ab0ad61c184b6889680cfe0da3d6b626f1b29f58e1b33a7903319
Depends on
041a945f55f8 1c52d940b4a0 3d086bc3d69b 4dfa4599b3f7 5714347a6233 5d818ec7d5fc 93be9ce4653e b5668108487f b64e2537d702 d23464c0ac60 ecac6e8d9b41 f4e138301198 fac6853c4e46
Depended on by
306aec1839aa a22e4aec2e28 47932fa8d6f3
In bookmarks
main
In tags
v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
8 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +4 −0
…
1 unchanged lines
2 2
3 3
OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`.
4 4
5 +
## Unreleased
6 +
7 +
- **Added: `GET /api/v1/user/issues` (#359).** The open issues assigned to you, in repositories you can see, newest-updated first, as the dashboard's "Assigned to you" lists them, each with its repository and address; for apps, first the Android widget's issue count (oxygenhub-android#31).
8 +
5 9
## 0.9.19 (2026-11-01)
6 10
7 11
- **Added: attachments on issues and comments (#350).** Screenshots and any other file: `POST /api/v1/repos/OWNER/NAME/attachments?name=FILE` with the file's bytes answers with its address and the Markdown to use, and `oh issue create` and `oh issue comment` take `--attach FILE` (repeatable), so a screenshot pasted into Claude Code can go onto the issue it files. An image (by its bytes: PNG, JPEG, GIF, WebP) shows in place; anything else, SVG included, is a download. Attachments are served under the repository's read rules and can run nothing (no sniffing, a policy that allows nothing, never in a shared cache). Pasting and dragging into the web editors come later.
…
435 unchanged lines
modified crates/hub-api/src/lib.rs +14 −0
…
880 unchanged lines
881 881
    pub error: String,
882 882
}
883 883
884 +
/// An issue in a list across repositories: `GET /user/issues` (#359),
885 +
/// the open issues assigned to the caller, newest-updated first.
886 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
887 +
pub struct IssueBrief {
888 +
    /// `owner/name`.
889 +
    pub repository: String,
890 +
    pub number: i64,
891 +
    pub title: String,
892 +
    pub updated_ms: i64,
893 +
    /// `https://hub/{owner}/{name}/issue/{number}`.
894 +
    pub url: String,
895 +
}
896 +
884 897
/// `GET /user/proposals` query.
885 898
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
886 899
pub struct UserProposalsQuery {
…
689 unchanged lines
1576 1589
        ("DropEdit", schemars::schema_for!(DropEdit)),
1577 1590
        ("UserProposals", schemars::schema_for!(UserProposals)),
1578 1591
        ("ProposalBrief", schemars::schema_for!(ProposalBrief)),
1592 +
        ("IssueBrief", schemars::schema_for!(IssueBrief)),
1579 1593
        ("StarterAssistants", schemars::schema_for!(StarterAssistants)),
1580 1594
        ("StarterStatus", schemars::schema_for!(StarterStatus)),
1581 1595
        ("StarterRequest", schemars::schema_for!(StarterRequest)),
…
300 unchanged lines
modified crates/hub/src/api/mod.rs +29 −0
…
109 unchanged lines
110 110
        .route("/user/repos", get(user_repos))
111 111
        .route("/user/orgs", get(user_orgs))
112 112
        .route("/user/proposals", get(user_proposals))
113 +
        .route("/user/issues", get(user_issues))
113 114
        .route("/user/sky", get(user_sky))
114 115
        .route("/user/starter", get(user_starter))
115 116
        .route("/user/flow/runs", get(user_flow_runs))
…
608 unchanged lines
724 725
    Ok((StatusCode::CREATED, Json(RunnerToken { token, owner: me.username })))
725 726
}
726 727
728 +
/// `GET /user/issues` (repo:read, #359): the open issues assigned to the
729 +
/// caller, in repositories it can see, as the dashboard's "Assigned to you".
730 +
async fn user_issues(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<IssueBrief>>, ApiError> {
731 +
    let c = caller(&app, &headers).await?;
732 +
    let me = c.require("repo:read")?.clone();
733 +
    let member = db::accounts::visible_repo_ids(&app.db, me.id).await?;
734 +
    let seen: Vec<i64> = db::repos::list(&app.db)
735 +
        .await?
736 +
        .into_iter()
737 +
        .filter(|r| me.is_admin || r.visibility() != Visibility::Private || member.contains(&r.id))
738 +
        .map(|r| r.id)
739 +
        .collect();
740 +
    let base = app.config.base_url().trim_end_matches('/').to_string();
741 +
    Ok(Json(
742 +
        db::activity::assigned(&app.db, me.id, &seen)
743 +
            .await?
744 +
            .into_iter()
745 +
            .map(|i| IssueBrief {
746 +
                url: format!("{base}/{}/{}/issue/{}", i.owner, i.name, i.number),
747 +
                repository: format!("{}/{}", i.owner, i.name),
748 +
                number: i.number,
749 +
                title: i.title,
750 +
                updated_ms: i.updated_ms,
751 +
            })
752 +
            .collect(),
753 +
    ))
754 +
}
755 +
727 756
/// `GET /user/proposals` (repo:read): what the dashboard lists, for apps;
728 757
/// with `?landable=true`, also what the caller can land now (#311).
729 758
async fn user_proposals(
…
2752 unchanged lines
modified crates/hub/tests/api.rs +16 −0
…
962 unchanged lines
963 963
    assert_eq!((v["review"].as_array().map(Vec::len), v["authored"][0]["title"].as_str()), (Some(0), Some("Faster startup")), "{v}");
964 964
    assert_eq!(api(app, "GET", "/api/v1/user/proposals", None, None).await.0, StatusCode::UNAUTHORIZED);
965 965
966 +
    // The open issues assigned to the caller (#359), for apps.
967 +
    let (st, v) =
968 +
        api(app, "POST", "/api/v1/repos/andy/demo/issues", Some(&a), Some(r#"{"title":"Widget counts","assignees":["andy"]}"#)).await;
969 +
    assert_eq!(st, StatusCode::CREATED, "{v}");
970 +
    let n = v["number"].as_i64();
971 +
    let (st, v) = api(app, "GET", "/api/v1/user/issues", Some(&a), None).await;
972 +
    assert_eq!(st, StatusCode::OK, "{v}");
973 +
    assert_eq!(v.as_array().map(Vec::len), Some(1), "only the assigned one: {v}");
974 +
    assert_eq!(
975 +
        (v[0]["repository"].as_str(), v[0]["number"].as_i64(), v[0]["title"].as_str()),
976 +
        (Some("andy/demo"), n, Some("Widget counts"))
977 +
    );
978 +
    assert!(v[0]["url"].as_str().unwrap().ends_with(&format!("/andy/demo/issue/{}", n.unwrap())), "{v}");
979 +
    assert_eq!(api(app, "GET", "/api/v1/user/issues", Some(&b), None).await.1.as_array().map(Vec::len), Some(0), "nothing assigned to bob");
980 +
    assert_eq!(api(app, "GET", "/api/v1/user/issues", None, None).await.0, StatusCode::UNAUTHORIZED);
981 +
966 982
    // Organisations need user:read, which this token lacks.
967 983
    assert_eq!(api(app, "GET", "/api/v1/user/orgs", Some(&a), None).await.0, StatusCode::FORBIDDEN);
968 984
…
855 unchanged lines
modified docs/spec.md +1 −1
…
491 unchanged lines
492 492
493 493
### 12.1 API
494 494
495 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
495 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
496 496
497 497
### 12.2 Webhooks
498 498
…
398 unchanged lines