Docs
Search docs

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

string

Query parameters

cursor

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

string

limit

Page size; default 50, max 100.

integerint64

Responses

200Triage page

application/json

TeamTriageResponse

object

One page of a team's triage: open repository issues, in the repositories the team holds a grant on and the caller can read, that no ticket tracks yet. Newest first; pass `next_cursor` back as `cursor` for older rows, `null` on the last page.

itemsrequired

array

Each row carries its `repo_owner`/`repo_name` route words; `team_key` and `team_owner` are always `null` (every row is a repo issue).

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

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
  • 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

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