neosourceDocs
Search docs

Link this issue to another issue

POST/api/issues/{issue_id}/links

createIssueLink

`A blocks B`, `A blocked_by B`, `A related B`, `A duplicate_of B`, or `A duplicates B`. Always writes the symmetric back-link (e.g. `(B, A, blocked_by)`) in the same transaction so the link surface is coherent from either side.

Requires authentication using a bearer token or a session cookie — see tokens and scopes.

curl

curl -X POST 'https://neosource.dev/api/issues/ISSUE_ID/links' \
  -H 'Authorization: Bearer $NEOSOURCE_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"target_issue_id":"string","link_kind":"string"}'

fetch

fetch("https://neosource.dev/api/issues/ISSUE_ID/links", {
  method: "POST",
  headers: {
    Authorization: "Bearer $NEOSOURCE_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"target_issue_id":"string","link_kind":"string"}),
});

Path parameters

issue_idrequired

string

Request bodyrequired

application/json

CreateIssueLinkRequest

object

link_kindrequired

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"

target_issue_idrequired

string

Target issue id (the one this issue is being linked to).

Responses

201Link created

application/json

IssueLinkResponse

object

created_atrequired

integerint64

created_byrequired

string

link_kindrequired

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"

source_issue_idrequired

string

target
one of
  • null

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

target_issue_idrequired

string

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
  • 401Authentication required
  • 404Not Found — one of: not_found
  • 409Conflict — one of: already_exists, conflict, github_sync_active
  • 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.

IssueStatus

string

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