The caller's open review requests, pull requests and assigned issues
GET/api/user/work
getUserWork
Account-scoped (every workspace). Each list holds at most 20 rows and `truncated` says which lists were cut. Every row is one the caller can read — the same decision the PR and issue pages make, asked per repo and per team — so nothing listed here 404s on click-through, and a revoked grant or a repo gone private drops its rows.
Authentication is not described for this operation in the spec — that does not mean it is public. Check tokens and scopes.
curl
curl -X GET 'https://neosource.dev/api/user/work'fetch
fetch("https://neosource.dev/api/user/work", {
method: "GET",
});Responses
200The caller's work
application/json
403Forbidden — one of: forbidden, needs_scope
application/json
Standard errors
Bodies documented once for the whole API — see standard errors.
- 400Bad Request — one of: invalid_input
- 429Rate limited — retry after the `Retry-After` header
- 500Internal server error
- 503Service temporarily unavailable / at capacity — retry after the `Retry-After` header
- 504Gateway timeout — the request exceeded the server's handling budget
Schemas
Referenced above. Listed here rather than expanded inline, so the same definition is not repeated at every level.
IssueResponse
assigneesWho this issue is assigned to, resolved to handle + display name. ## Why this is on the list DTO at all, when it used to be forbidden The standing objection was N+1: assignees live in `issue_assignees`, so "serve them on the list" was read as "one query per row". **N+1 is a property of the CALL SHAPE, not of the data.** Assignees for a PAGE of issues are one statement — `WHERE issue_id = ANY($1)` over the ids the listing already selected (`PgIssueStore::assignees_for_issues`) — so the cost is one round trip per page, whatever the page size. The cost of the old answer was paid entirely by the UI: the issue list row had no assignee to render, so its assignee control had no resting value to sit on, so it shipped `sr-only` with `tabindex=-1` and a pointer user could not assign anyone from any list. Four controls were in that state for this one reason. **Serving it here costs the browser NOTHING.** It rides the list response that was already being fetched, so `issues-list.fresh` in `neosource-app/e2e/request-budget/budgets.ts` — asserted by EQUALITY — is unchanged. ## `None` is NOT "unassigned" — and the type says so `None` means this response did not run the enrichment; `Some([])` means it ran and the issue has nobody on it. An `Option` rather than the `Vec` this shipped as for one day, because the flat vec made those two states the same value and something downstream had already started reading the wrong one: the SPA's optimistic label/assignee patch matches its container BY SHAPE (`$lib/queries/issueProperties.ts`), and an always-empty `assignees: []` on the un-enriched detail DTO matched vacuously — so a row-side attach painted the DETAIL cache too, and the settle refetch silently reverted it. Invisible only because `IssueDetailBody` happens to read `labelKeys.issue(id)` instead. `Array.isArray(null)` is false, so the `Option` makes that match unrepresentable rather than unlikely (CLAUDE.md § "Convention → type"). Enrichment is a LIST-route concern: the routes that populate this are the two that render `IssueListRow` — `GET /api/repos/{owner}/{repo}/issues` and `GET /api/teams/{team_id}/issues`.
itemsauthor_display_nameauthor_github_loginLast-seen GitHub login for authors with a linked GitHub identity (always set for ghosts).
author_handleAuthor fields resolved at read time (route-layer enrichment via the ghost-safe authors projection — plans/github-metadata-import.md §7). `None` on responses that don't enrich (mutation receipts) or when the account row is gone.
author_idrequiredauthor_kind- one of
blockedrequiredclosed_atclosed_bycreated_atrequiredcycle_idThe cycle this issue is bound to, or `None` when it is in no cycle. At most one, by construction — `issues.cycle_id` is a single column (ADR 0071), and `PUT /api/issues/{issue_id}/cycle` is the only writer (`SetIssueCycleRequest`, where `cycle_id: null` unbinds). A deliberately OPEN id string like `primary_repo_id` / `team_id` beside it, not a closed enum: the value names a row, not a case. Present on every issue payload because every issue SELECT already reads the column. This doc comment used to end "Assignees and labels are per-issue SUB-RESOURCES and deliberately stay off this DTO for exactly that reason" — the reason being N+1. That was a false dichotomy and it is retracted; see [`IssueResponse::assignees`].
descriptionrequiredgithub_numberThe GitHub issue number this issue mirrors, when it was imported by GitHub metadata sync (equal to `number` by construction). `None` for native issues — presence IS the provenance signal.
issue_idrequiredlabelsLabels attached to this issue, resolved to name + colour. Same batched read, same reasoning, same `None`-vs-`Some([])` rule, and the same two routes as [`IssueResponse::assignees`].
itemsnumberrequiredprimary_repo_idpriorityrequiredstatusrequiredteam_idThe team a ticket lives on; `null` for a repo issue (one with `primary_repo_id` set). Exactly one of the two is set — the wire mirror of the `issues_kind_xor` CHECK.
titlerequiredupdated_atrequired
PullRequestChecksRollup
One PR's CI roll-up, keyed by its repo-scoped `number`. Deliberately NOT a field on [`PullRequestResponse`]. A PR's title, branches and author change when somebody edits them; its check state changes every few seconds while a run is in flight. Folding the second into the first would make every CI heartbeat invalidate the whole list payload — the client would refetch titles and branches to learn that a dot went green, and a prerendered snapshot would bake in a verdict that expires before anyone reads it. Two payloads, two cache postures.
checks_summaryrequiredhead_commitThe head the roll-up was computed against (40-char Git OID hex), or `null` before the first push resolves one. Present so a client can tell "checked, nothing ran" from "there is nothing to check yet".
numberrequiredRepo-scoped PR number — the join key back to the list payload.
queue_candidateThe merge-queue candidate (40-char Git OID hex) `checks_summary` was computed against: the PR's live queue entry is speculative, frozen on the PR's current head, and its candidate has reported at least one check. `null` when the roll-up is the head's.
workflowsThe roll-up split by workflow, over the same commit and the same collapse as `checks_summary`: each workflow's newest surviving run, in row order (by path, then id). What a PR row paints one icon per.
items
PullRequestResponse
author_display_nameauthor_github_loginLast-seen GitHub login for authors with a linked GitHub identity (always set for ghosts).
author_handleAuthor fields resolved at read time (route-layer enrichment via the ghost-safe authors projection — plans/github-metadata-import.md §7), exactly as on [`crate::IssueResponse`]. Filled on every READ — list, detail, and both context packs. `None` on a mutation receipt (the caller already has the row it just wrote) or when the account row is gone.
author_idrequiredauthor_kind- one of
auto_merge_enabled_atWhen auto-merge was armed (ms since epoch); set together with `auto_merge_enabled_by`.
auto_merge_enabled_byAccount id that armed auto-merge (merge-when-green); `null` = off. On an OPEN PR this means "will merge, as this account, the moment the merge gate reports mergeable". Cleared automatically when the head advances (new commits disarm).
change_idrequired32-byte durable change identity (hex). Survives force-push / rebase.
created_atrequireddescriptionrequireddraftrequiredhead_commit40-char Git OID hex of the source branch's current tip, or `null` before the first push resolves one.
merge_commit40-char Git OID hex of the commit the merge minted, or `null` when it minted none: an import, or a merge whose source was already reachable from the target.
merge_method- one of
merge_queue- one of
merged_atWhen the merge landed (ms since epoch). Unlike `updated_at` this never moves again, so it is the field to render as "merged <when>".
merged_byAccount credited with the merge, `null` until the PR merges — and also on a merged GitHub mirror, whose upstream merger the import payload does not carry. For a merge made with a service token this is the token's OWNER; the token itself is recorded in the operation log, not here.
merged_by_display_namemerged_by_github_loginmerged_by_handleMerger fields resolved at read time, exactly as the `author_*` group above and through the same batched projection call. `None` on a mutation receipt, on an unmerged PR, and when the account row is gone.
merged_by_kind- one of
merged_via- one of
mirror_head_repoFor a fork mirror, the GitHub `owner/name` of the fork the head lives in — a non-null value means "no local branch" (fork diff renders off the pinned OID). `null` for native PRs and same-repo mirrors.
numberrequiredoriginrequiredparent_pr_idParent PR id when this PR is part of a stack (its `target_branch` matches another open PR's `source_branch`). `None` for a non-stacked PR or for the bottom of a stack.
pr_idrequiredrepo_idrequiredsource_branchrequiredsource_repo_idRepo the source branch lives in. Equals `repo_id` for a same-repo PR; the contributor's fork for a fork PR. `null` once the source repository was deleted — such a pull request is kept as history but can never merge.
statusrequiredtarget_branchrequiredtitlerequiredupdated_atrequired