neosourceDocs
Search docs

List issues belonging to a team

GET/api/teams/{team_id}/issues

listTeamIssues

Newest-first, keyset-paginated. Pass a previous response's `next_cursor` back as `cursor` for the next (older) page. Filters are optional and combine conjunctively; an omitted `state` means `all`. `counts` are exact over the WHOLE board regardless of the filters and are returned on the first page only — that is what lets a client tell "this team has filed nothing" from "this tab is empty". The board lists the team's TICKETS (`KEY-N`); a repo-bound issue lives on its repo's issue list and is never a board row, so a cursor naming one is a 400. An unparseable `assignee_id`/`label_id`/`cycle_id` is a 400; a well-formed id that names nothing is a 200 with no 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/teams/TEAM_ID/issues'

fetch

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

Path parameters

team_idrequired

string

Query parameters

state

`open`, `closed` or `all`. **Omitted means `all`** — this endpoint returned every state before it had filters, and defaulting to `open` would silently drop closed rows from callers that send nothing.

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"

status

Only issues with this exact status.

IssueStatus

string

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

blocked

Only blocked (`true`) or unblocked (`false`) issues.

boolean

priority

Only issues with this exact priority.

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"

assignee_id

Only issues assigned to this account (UUID). An id that parses but names nothing returns an empty page, not an error.

string

label_id

Only issues carrying this label (UUID).

string

cycle_id

Only issues bound to this cycle (UUID).

string

cursor

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

string

limit

Page size; default 500, max 500.

integerint64

Responses

200Issue page

application/json

TeamIssueListResponse

object

One page of a team board. Newest-first and keyset-paginated: pass `next_cursor` back as `cursor` to fetch older issues, `null` when this is the last page.

counts
one of
  • null

  • TeamIssueCounts

    object

    The team board's exact `(status, blocked)` cross-tab, plus the scalars derived from it. The cells are the one source; the scalars are arithmetic over them (see [`TeamIssueCounts::from`]), so a scalar cannot drift from the cross-tab. Clients that need the digest's groups compute them from `cells` — "blocked wins one group per row" is a UI rule, not a fact about the data.

    blocked_openrequired

    integerint64

    Open AND blocked — the digest's blocked group.

    cellsrequired
    closedrequired

    integerint64

    Terminal issues.

    openrequired

    integerint64

    Non-terminal issues (`IssueStatus::is_terminal()` is false).

    totalrequired

    integerint64

issuesrequired

array

items

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

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

numberrequired

integerint32

primary_repo_id

string | null

priorityrequired
IssuePriority
statusrequired
IssueStatus
team_idrequired

string

titlerequired

string

updated_atrequired

integerint64

next_cursor

string | null

403Forbidden — one of: forbidden, needs_scope

application/json

one of
  • 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`.

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

TeamIssueCountCell

object

One `(status, blocked)` cell of [`TeamIssueCounts`]. Only non-empty cells are returned.

blockedrequired

boolean

countrequired

integerint64

statusrequired

IssueStatus

string

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

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

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"

LabelResponse

object

colorrequired

string

created_atrequired

integerint64

descriptionrequired

string

label_idrequired

string

namerequired

string

repo_id

string | null

team_id

string | null

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"

IssueStatus

string

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