Get an issue by id
GET/api/issues/{issue_id}
getIssue
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/issues/ISSUE_ID'fetch
fetch("https://neosource.dev/api/issues/ISSUE_ID", {
method: "GET",
});Path parameters
issue_idrequiredstring
Responses
200Issue
application/json
IssueResponse
object
assigneesarray | nullWho 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`.
itemsIssueAssigneeResponse
objectOne 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_idrequiredstringdisplay_namerequiredstringhandlerequiredstring
author_display_namestring | nullauthor_github_loginstring | nullLast-seen GitHub login for authors with a linked GitHub identity (always set for ghosts).
author_handlestring | nullAuthor 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_idrequiredstringauthor_kind- one of
nullAccountKind
stringRust 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"
blockedrequiredbooleanclosed_atinteger | nullint64closed_bystring | nullcreated_atrequiredintegerint64cycle_idstring | nullThe 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`].
descriptionrequiredstringgithub_numberinteger | nullint32The 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_idrequiredstringlabelsarray | nullLabels 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`].
itemsLabelResponse
objectcolorrequiredstringcreated_atrequiredintegerint64descriptionrequiredstringlabel_idrequiredstringnamerequiredstringrepo_idstring | nullteam_idstring | null
numberrequiredintegerint32primary_repo_idstring | nullpriorityrequiredIssuePriority
stringLinear-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"statusrequiredIssueStatus
string"backlog""todo""in_progress""in_review""done""cancelled""duplicate"team_idrequiredstringtitlerequiredstringupdated_atrequiredintegerint64
403Forbidden — one of: forbidden, needs_scope
application/json
- ErrorForbidden
NeedsScopeError
object`403` body returned when listing private repos but the linked identity lacks the required provider scope. The SPA turns this into an incremental-authorization prompt (Tier 2) that calls the `/elevate` OAuth endpoint with this `scope`.
errorrequiredstringAlways `needs_scope` — this body exists to carry the extra fields that kind needs.
"needs_scope"scoperequiredstringThe 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
- 404Not Found — one of: not_found
- 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