List a team's triage
GET/api/teams/{team_id}/triage
listTeamTriage
Open repository issues that no ticket tracks yet, in the repositories this team holds a grant on (ADR 0077 §1) — filtered per repository by the CALLER's own read decision, so a row is never an oracle for a repository they cannot see. Repositories GitHub is the authority for are left out: every triage action is refused there. Newest-first, keyset-paginated: pass `next_cursor` back as `cursor`. Accept a row with `POST /api/teams/{team_id}/issues` and `tracks`.
Requires authentication using a bearer token or a session cookie — see tokens and scopes.
curl
curl -X GET 'https://neosource.dev/api/teams/TEAM_ID/triage' \
-H 'Authorization: Bearer $NEOSOURCE_TOKEN'fetch
fetch("https://neosource.dev/api/teams/TEAM_ID/triage", {
method: "GET",
headers: {
Authorization: "Bearer $NEOSOURCE_TOKEN",
},
});Path parameters
team_idrequired
Query parameters
cursorOpaque pagination cursor from a previous response's `next_cursor`.
limitPage size; default 50, max 100.
Responses
200Triage page
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
- 401Authentication required
- 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
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