neosourceDocs
Search docs

Agent context pack for an issue

GET/api/issues/{issue_id}/context

getIssueContext

Read-only, single-call assembly of everything needed to act on an issue: the issue itself, its labels and links, its comments, and every linked pull request (open AND closed — closed PRs are prior attempts) each with a checks summary plus its review comments and review threads. Authorizes exactly like `GET /api/issues/{issue_id}` (deny folds to 404). Linked-PR details are best-effort: a PR whose lookup fails is omitted rather than failing the whole read.

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

fetch

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

neo

neo issue context NUMBER OWNER/REPO

Path parameters

issue_idrequired

string

Responses

200Issue context pack

application/json

IssueContextResponse

object

Everything needed to act on an issue, in a single read-only payload.

commentsrequired

array

Discussion comments on the issue, oldest first.

items

CommentResponse

object

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 handle resolved at read time (route-layer enrichment via the ghost-safe authors projection); `None` if the account row could not be resolved.

author_idrequired

string

author_kind
one of
bodyrequired

string

comment_idrequired

string

created_atrequired

integerint64

deleted_at

integer | nullint64

editedrequired

boolean

parent_id

string | null

updated_atrequired

integerint64

issuerequired

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

labelsrequired

array

Labels attached to the issue.

items

LabelResponse

object

colorrequired

string

created_atrequired

integerint64

descriptionrequired

string

label_idrequired

string

namerequired

string

repo_id

string | null

team_id

string | null

linksrequired

array

Issue-to-issue links (blocks/relates/duplicates, …).

items

IssueLinkResponse

object

created_atrequired

integerint64

created_byrequired

string

link_kindrequired
IssueLinkKind
source_issue_idrequired

string

target
target_issue_idrequired

string

pull_requestsrequired

array

Every pull request linked to this issue — open AND closed. Closed PRs are prior attempts an agent should learn from, not ignore.

items

IssueContextPullRequest

object

A single linked pull request, enriched with the signals an agent needs to judge it: head commit, a checks roll-up, and its review discussion.

checks_summaryrequired
ChecksSummary
head_commit

string | null

Resolved head commit the checks are derived against (40-char Git OID hex, same form as `PullRequestResponse::head_commit`). `null` when the PR's source branch has no resolved tip — `checks_summary` is then the zero roll-up.

pull_requestrequired
PullRequestResponse
review_commentsrequired

array

Review comments on the PR (subject = pull request), oldest first.

review_threadsrequired

array

Inline review threads on the PR.

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.

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"

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

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"

IssueLinkKind

string

How one issue relates to another. Bidirectional pairs: - `Blocks` ↔ `BlockedBy` - `Related` ↔ `Related` (symmetric) - `DuplicateOf` ↔ `Duplicates` - `Tracks` ↔ `TrackedBy` `Tracks` is the one kind with a DIRECTION the product constrains beyond what this enum can express: a team ticket tracks a repo issue, never the reverse, and `TrackedBy` is only ever the inverse row the pairing writes. `IssueService::create_link` enforces both (ADR 0071 § Amendment 2026-09-05 §3) — the type permits the shape, the service refuses it.

"blocks""blocked_by""related""duplicate_of""duplicates""tracks""tracked_by"

IssueLinkTargetResponse

object

The link TARGET, resolved to something renderable. A link row carries two UUIDs, so the rail could only ever render `target_issue_id.slice(0, 8)` — a hex prefix is not a reference. These are the fields needed to write `ENG-9` or `acme/api#1526` and route to it. Filled by the route (only it holds the stores) and **best-effort**, the same posture as `IssuePrLinkResponse`: a lookup miss leaves the resolved fields `None` and the client falls back to the bare id, rather than the whole read failing because one repo row moved. Which resolved fields are present follows the target's own kind: `team_key`/`team_owner` for a ticket (`primary_repo_id` null), `repo_owner`/`repo_name` for a repo-bound issue. Minimal disclosure — the client branches on `primary_repo_id` anyway, so the other pair would never be rendered.

issue_idrequired

string

numberrequired

integerint32

primary_repo_id

string | null

`None` for a team-scoped ticket — this is the field the client branches on to choose between `KEY-N` and `owner/repo#N`.

repo_name

string | null

repo_owner

string | null

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

statusrequired

IssueStatus

string

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

team_key

string | null

Ticket targets only: `KEY` in `KEY-N`.

team_owner

string | null

Ticket targets only: the workspace slug the ticket's route needs (`/{owner}/teams/{key}/issues/{n}`). Without it the ref can only be inert text.

titlerequired

string

ChecksSummary

object

Roll-up of the check states for the merge box. Counted over the newest-run-per-context set (`neosource_forge::head_checks`), so a run that a re-run superseded is not a second check.

failedrequired

integerint32

`failure` + `error` states — both render red and both block.

pendingrequired

integerint32

skipped

integerint32

Checks whose job never ran (gated off by a job-level `if:`, or a `needs:` predecessor did not succeed). Counted separately from `success`; `success + failed + pending + skipped == total`.

successrequired

integerint32

Checks that ran and passed. Deliberately EXCLUDES skipped ones: a skipped check carries `state: success` so it cannot block a merge, but counting it as "passed" would tell the reader that work happened when it did not — the same claim the per-check `skipped` flag exists to stop the UI making.

totalrequired

integerint32

PullRequestResponse

object

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), exactly as on [`crate::IssueResponse`]. Filled on every READ — list, detail, and both context packs. `None` on a mutation receipt (the caller already has the row it just wrote) or when the account row is gone.

author_idrequired

string

author_kind
one of
  • null

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

auto_merge_enabled_at

integer | nullint64

When auto-merge was armed (ms since epoch); set together with `auto_merge_enabled_by`.

auto_merge_enabled_by

string | null

Account id that armed auto-merge (merge-when-green); `null` = off. On an OPEN PR this means "will merge, as this account, the moment the merge gate reports mergeable". Cleared automatically when the head advances (new commits disarm).

change_idrequired

string

32-byte durable change identity (hex). Survives force-push / rebase.

created_atrequired

integerint64

descriptionrequired

string

draftrequired

boolean

head_commit

string | null

40-char Git OID hex of the source branch's current tip, or `null` before the first push resolves one.

merge_commit

string | null

40-char Git OID hex of the commit the merge minted, or `null` when it minted none: an import, or a merge whose source was already reachable from the target.

merge_method
one of
  • null

  • MergeMethod

    string

    How a pull request's commits land on its target branch. A repo carries two settings over this enum (`migrations/20260820120000_repo_merge_methods.sql`): the set it *permits* (`RepoRecord::allowed_merge_methods`, never empty) and the one the merge path reaches for when the request names none (`RepoRecord::default_merge_method`, always a member of that set — enforced by the settings route, which is the only place both columns are visible at once). `Default` is [`MergeMethod::Merge`] to match the column default, which is itself the pre-existing behaviour of every repo: before this setting existed, a merge always minted a two-parent commit. `Rebase` is deliberately absent rather than merely unimplemented — it replays N commits, can conflict per commit, and rewrites the SHAs the stacked-PR re-parent invariant leans on (`plans/archive/pr-merge-methods-2026-08.md` §"Not doing"). Adding it later is one `ALTER TYPE merge_method ADD VALUE` plus an arm here, which is why the stored shape is an enum array and not a pair of booleans.

    "merge""squash"

merged_at

integer | nullint64

When the merge landed (ms since epoch). Unlike `updated_at` this never moves again, so it is the field to render as "merged <when>".

merged_by

string | null

Account credited with the merge, `null` until the PR merges — and also on a merged GitHub mirror, whose upstream merger the import payload does not carry. For a merge made with a service token this is the token's OWNER; the token itself is recorded in the operation log, not here.

merged_by_display_name

string | null

merged_by_github_login

string | null

merged_by_handle

string | null

Merger fields resolved at read time, exactly as the `author_*` group above and through the same batched projection call. `None` on a mutation receipt, on an unmerged PR, and when the account row is gone.

merged_by_kind
one of
  • null

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

merged_via
one of
  • null

  • MergedVia

    string

    How a pull request's merge was triggered — the companion to [`MergeMethod`], which says what the merge *minted*. The two are independent: a squash can land either by hand or by an armed auto-merge, and an imported mirror carries a trigger with no method at all.

    "manual""auto""import"

mirror_head_repo

string | null

For a fork mirror, the GitHub `owner/name` of the fork the head lives in — a non-null value means "no local branch" (fork diff renders off the pinned OID). `null` for native PRs and same-repo mirrors.

numberrequired

integerint32

originrequired

PrOrigin

string

Provenance of a pull request. `Native` PRs are created in neosource and are fully mutable; `GithubMirror` PRs are read-only imports of GitHub pull requests (one-directional metadata sync). Store-level single-row lookups filter to `Native` so a fork mirror — which stores GitHub head-ref names like `main`/`patch-1` — can never mask a native PR nor be advanced by a native push.

"native""github_mirror"

parent_pr_id

string | null

Parent PR id when this PR is part of a stack (its `target_branch` matches another open PR's `source_branch`). `None` for a non-stacked PR or for the bottom of a stack.

pr_idrequired

string

repo_idrequired

string

source_branchrequired

string

source_repo_idrequired

string

Repo the source branch lives in. Equals `repo_id` for a same-repo PR; the contributor's fork for a fork PR.

statusrequired

PullRequestStatus

string

Lifecycle status of a pull request.

"open""merged""closed"

target_branchrequired

string

titlerequired

string

updated_atrequired

integerint64

CommentResponse

object

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 handle resolved at read time (route-layer enrichment via the ghost-safe authors projection); `None` if the account row could not be resolved.

author_idrequired

string

author_kind
one of
  • null

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

bodyrequired

string

comment_idrequired

string

created_atrequired

integerint64

deleted_at

integer | nullint64

editedrequired

boolean

parent_id

string | null

updated_atrequired

integerint64

PrReviewThreadResponse

object

anchor_commitrequired

string

40-char Git OID hex of the commit the line is anchored at.

anchor_linerequired

integerint32

anchor_pathrequired

string

anchor_siderequired

ThreadSide

string

Side of the diff a review thread anchors to. `Old` = the line was on the pre-image (red side); `New` = the line is on the post-image (green side). Identical encoding to GitHub's `RIGHT`/`LEFT` but spelled in our domain.

"old""new"

created_atrequired

integerint64

created_byrequired

string

outdated_at

integer | nullint64

pr_idrequired

string

resolved_at

integer | nullint64

resolved_by

string | null

thread_idrequired

string

updated_atrequired

integerint64