Docs
Search docs

The issues the caller is assigned to or filed, across every workspace

GET/api/user/issues

getUserIssues

Account-scoped (every workspace): tickets and repo-bound issues alike, most recently updated first. `filter` is `assigned` (default) or `created`; `state` is `open` (default), `closed` or `all`. Keyset-paginated: pass `next_cursor` back as `cursor`. Every row is one the caller can read — the same decision the issue page makes, asked per repo and per team — so a revoked grant, a repo gone private or a team the caller left drops its rows. The page is cut BEFORE that decision, so a page may be short or even empty while `next_cursor` is still set; keep following it.

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/issues'

fetch

fetch("https://neosource.dev/api/user/issues", {
  method: "GET",
});

Query parameters

filter

`assigned` (issues the caller is assigned to) or `created` (issues the caller filed). **Omitted means `assigned`**, the page's first tab.

IssueInvolvement

string

How the viewer is involved in an issue — the axis the account-scoped `GET /api/user/issues` lists on (the `/my-issues` page's two tabs). `Created` means the viewer is the issue's AUTHOR; `Assigned` that they are one of its assignees. Tickets and repo-bound issues alike, in every workspace.

"assigned""created"

state

`open`, `closed` or `all`. **Omitted means `open`** — unlike the team board, whose omitted state is `all`: this list has no pre-filter callers to keep whole, and "my issues" opens on the work still to do.

IssueStateFilter

string

The open/closed axis a list request filters on. Deliberately NOT `Default`: an omitted `state` on the team-issue listing means `All`, and defaulting to `Open` here would make that decision invisible at the route (it would silently drop closed rows from `ns-cli issue list --team` and from the e2e client, neither of which sends the parameter). The route maps `None => All` at one visible line instead.

"open""closed""all"

cursor

Opaque pagination cursor from a previous response's `next_cursor`.

string

limit

Page size; default 50, max 100.

integerint64

Responses

200One page of the caller's issues

application/json

UserIssuesResponse

object

`GET /api/user/issues`: one page of the caller's issues, most recently updated first.

itemsrequired

array

The readable rows of this page. May be SHORTER than the page size — even empty — while `next_cursor` is set: rows the caller may no longer read are dropped after the page is cut.

items

WorkIssue

object

One issue assigned to the viewer, with the route words its kind needs. The SPA derives the kind from `primary_repo_id` (its `issueKind` module — never from a second server-side field, which would be a restatement of the same rule), and builds the ref and href from the pair that applies: `team_owner`/`team_key` for a ticket (`KEY-N`, at `/{team_owner}/teams/{team_key}/issues/{n}`), `repo_owner`/`repo_name` for a repo-bound issue (`owner/repo#N`). The other pair is `None` — same minimal-disclosure shape as `IssueLinkTargetResponse`. # Why `number` and `primary_repo_id` are repeated FLAT They are also inside `issue`, and the duplication is the point: `issueLinkTargetRef` / `issueLinkTargetHref` (`neosource-app/src/lib/issues/kind.ts`) read `target.number` and `target.primary_repo_id` at the **same level** as `team_key` / `repo_owner` — that is `IssueLinkTargetResponse`'s shape, and a helper typed on `IssueLinkTargetLike` cannot reach into a nested object. With them nested only, `primary_repo_id` came back `undefined`, `== null` matched, and every row — repo-bound ones included — rendered as a ticket `KEY-undefined` linking to `…/issues/undefined`, silently. Flattening the two fields is what makes "the helpers accept a row unchanged" a fact rather than a claim; `user_work.rs::issue_link_href` transcribes the helper and reads ONLY these flat fields, so a re-nesting fails a test.

issuerequired
IssueResponse
numberrequired

integerint32

`N` in `KEY-N` / `owner/repo#N`. Equal to `issue.number`; flat because that is where the ref helpers look (see the type's doc).

primary_repo_id

string | null

`None` for a ticket — the field the SPA branches on to choose between `KEY-N` and `owner/repo#N`. Equal to `issue.primary_repo_id`; flat for the same reason `number` is.

repo_name

string | null

repo_owner

string | null

Repo-bound issues only: the repo route for `owner/repo#N`.

team_key

string | null

Tickets only: `KEY` in `KEY-N`.

team_owner

string | null

Tickets only: the workspace slug the ticket's route needs.

next_cursor

string | null

Pass back as `cursor` for the next (older) page; `null` on the last.

403Forbidden — one of: forbidden, needs_scope

application/json

one of
  • ErrorForbidden
  • NeedsScopeError

    object

    `403` body returned when a classic OAuth token is asked for something its granted scopes do not cover — an explicit private listing without `repo`, or the one-click GitHub mirror without `admin:public_key`. `scope` names the missing grant so the client can say exactly what the token lacks. There is no elevation route to widen it any more (a re-link goes through the GitHub App, whose tokens carry no scopes), so the client's answer is to say so, never to start a handshake.

    errorrequired

    string

    Always `needs_scope` — this body exists to carry the extra fields that kind needs.

    "needs_scope"

    scoperequired

    string

    The provider scope to request via elevation (e.g. `"repo"`).

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

object

assignees

array | null

Who 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`.

items

IssueAssigneeResponse

object

One assignee, resolved to something renderable. This used to be a bare `account_id` string; a UUID is not a UI, and every consumer would otherwise have to fan out to the account store itself.

account_idrequired

string

display_namerequired

string

handlerequired

string

kindrequired

AccountKind

string

Rust mirror of the Pg `account_kind` enum (`accounts.kind`). `Ghost` rows are imported-author placeholders (GitHub metadata import, plans/github-metadata-import.md §6): `handle = gh-<github-id>`, `personal_workspace_id` NULL, no password, no sessions. They must never flow through `get_account*` (`AccountRow::into_record` CorruptData-errors on the NULL workspace) — author rendering goes through `PgGithubImportStore::authors_projection`, and claim-by-proof later promotes or merges them.

"human""bot""service""runner""ghost"

author_display_name

string | null

author_github_login

string | null

Last-seen GitHub login for authors with a linked GitHub identity (always set for ghosts).

author_handle

string | null

Author 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_idrequired

string

author_kind
one of
  • AccountKind

    string

    Rust mirror of the Pg `account_kind` enum (`accounts.kind`). `Ghost` rows are imported-author placeholders (GitHub metadata import, plans/github-metadata-import.md §6): `handle = gh-<github-id>`, `personal_workspace_id` NULL, no password, no sessions. They must never flow through `get_account*` (`AccountRow::into_record` CorruptData-errors on the NULL workspace) — author rendering goes through `PgGithubImportStore::authors_projection`, and claim-by-proof later promotes or merges them.

    "human""bot""service""runner""ghost"

  • null

blockedrequired

boolean

closed_at

integer | nullint64

closed_by

string | null

created_atrequired

integerint64

cycle_id

string | null

The 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`].

descriptionrequired

string

github_number

integer | nullint32

The 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_idrequired

string

labels

array | null

Labels 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`].

items

LabelResponse

object

colorrequired

string

created_atrequired

integerint64

descriptionrequired

string

label_idrequired

string

namerequired

string

repo_id

string | null

team_id

string | null

numberrequired

integerint32

primary_repo_id

string | null

priorityrequired

IssuePriority

string

Linear-style priority. Stored as the `issue_priority` Postgres enum; surfaces as a domain enum so callers never juggle magic ints.

"none""urgent""high""medium""low"

statusrequired

IssueStatus

string

"backlog""todo""in_progress""in_review""done""cancelled""duplicate"

team_id

string | null

The 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.

titlerequired

string

updated_atrequired

integerint64