Issues
Issues live per repo. They share the issues table with pull
requests, but the REST surface here returns issues only (PRs land
in their own section). Markdown bodies are stored raw; the cached
HTML render comes from the same internal/markdown pipeline the
web UI uses.
Issue shape
{
"id": 17,
"number": 1,
"title": "first bug",
"body": "kaboom",
"state": "open",
"state_reason": "",
"locked": false,
"lock_reason": "",
"author_id": 42,
"labels": ["bug"],
"created_at": "2026-05-12T05:00:00Z",
"updated_at": "2026-05-12T05:00:00Z"
}
state is "open" or "closed". state_reason is one of
"completed", "not_planned", "duplicate", "reopened", or
empty when no reason has been recorded. closed_at is present
only on closed issues.
List issues
GET /api/v1/repos/{owner}/{repo}/issues
Required scope: repo:read. Paginated; ?per_page= (≤100) and
?page= apply, with the standard Link: header.
Optional ?state=open|closed|all filter; state=all (or omitted)
returns both.
Pull requests are not included on this endpoint — fetch them via the pulls surface.
Get a single issue
GET /api/v1/repos/{owner}/{repo}/issues/{number}
Required scope: repo:read. 404 when the issue doesn't exist,
when the caller can't see the repo, or when {number} belongs to
a pull request (use the pulls surface).
Create an issue
POST /api/v1/repos/{owner}/{repo}/issues
Required scope: repo:write. Policy: ActionIssueCreate.
{ "title": "first bug", "body": "kaboom" }
| Field | Type | Notes |
|---|---|---|
title |
string | Required, 1–256 chars. |
body |
string | Optional markdown body, ≤65535 chars. |
Returns 201 with the issue envelope.
Errors
| Status | When |
|---|---|
| 401 | PAT missing/invalid. |
| 403 | PAT lacks repo:write scope or policy denial. |
| 422 | Empty title, title too long, body too long. |
Update an issue
PATCH /api/v1/repos/{owner}/{repo}/issues/{number}
Required scope: repo:write. Only the fields you send are
modified; everything else stays.
{
"title": "first bug — root cause found",
"body": "see comment #3",
"state": "closed",
"state_reason": "completed",
"labels": ["bug", "needs-triage"],
"assignees": ["alice"],
"milestone": 7
}
Permission rules:
- Title / body — the issue author OR a repo collaborator with
write access. Other callers
403. - State / state_reason — any caller with
ActionIssueCloseon the repo.state_reasonmust be one ofcompleted,not_planned,duplicate,reopened(or empty). - Labels — caller needs
ActionIssueLabel. The payload is a full replace:["bug"]strips every other label;[]clears them all. Omit the field to leave labels untouched. Unknown label names return422. - Assignees — caller needs
ActionIssueAssign. Same full-replace semantics, names are usernames; unknown usernames return422. - Milestone — caller needs
ActionIssueAssign. Pass the milestoneid(see Milestones below);0clears the milestone. The milestone must belong to the same repo; cross-repo ids return422.
Returns the freshly-loaded issue.
Lock and unlock
PUT /api/v1/repos/{owner}/{repo}/issues/{number}/lock
DELETE /api/v1/repos/{owner}/{repo}/issues/{number}/lock
Required scope: repo:write. PUT body is optional:
{ "lock_reason": "off-topic" }
Returns 204. Locking refuses non-collaborator comments; the
issue itself stays visible.
Comments
List
GET /api/v1/repos/{owner}/{repo}/issues/{number}/comments
Required scope: repo:read. Returns comments in chronological
order.
Add
POST /api/v1/repos/{owner}/{repo}/issues/{number}/comments
Required scope: repo:write. Policy: ActionIssueComment. Body:
{ "body": "lgtm" }
Subject to the per-author comment rate limit (20/hour); 429
when exceeded.
Edit own comment
PATCH /api/v1/repos/{owner}/{repo}/issues/comments/{cid}
Required scope: repo:write. The comment author can edit their
own; other callers 403.
Delete a comment
DELETE /api/v1/repos/{owner}/{repo}/issues/comments/{cid}
Required scope: repo:write. The comment author can delete their
own; repo collaborators with write access can delete any comment
on the repo (moderation affordance, matches the gh shape).
Returns 204.
Milestones
GET /api/v1/repos/{owner}/{repo}/milestones[?state=open|closed|all]
POST /api/v1/repos/{owner}/{repo}/milestones
GET /api/v1/repos/{owner}/{repo}/milestones/{id}
PATCH /api/v1/repos/{owner}/{repo}/milestones/{id}
DELETE /api/v1/repos/{owner}/{repo}/milestones/{id}
Required scope: repo:read on GETs, repo:write on mutations.
Mutations gate on ActionIssueLabel (write collaborator role
on the repo).
Create payload:
{
"title": "v1.0",
"description": "first stable release",
"due_on": "2026-06-01T00:00:00Z",
"state": "open"
}
due_on is RFC3339; omit or pass null to leave it unset. The
response shape mirrors GitHub's milestone with the repo-local
id, the state (open / closed), and live open_issues /
closed_issues counters.
Identifier: the path takes the milestone primary key id
(returned by list / create), not a per-repo number — shithub's
schema doesn't carry a number column. The CLI gets the id back
from POST or list responses.
Assignees
GET /api/v1/repos/{owner}/{repo}/assignees
Required scope: repo:read. Returns the users eligible to be
assigned: the repo owner (when user-owned) plus every direct
repo collaborator. Org-level expansion of org-owned repos is a
follow-up.
Not yet shipped
POST /api/v1/repos/{o}/{r}/issues/{n}/transfer and issue
pinning are queued for a follow-up batch.
View source
| 1 | # Issues |
| 2 | |
| 3 | Issues live per repo. They share the `issues` table with pull |
| 4 | requests, but the REST surface here returns issues only (PRs land |
| 5 | in their own section). Markdown bodies are stored raw; the cached |
| 6 | HTML render comes from the same `internal/markdown` pipeline the |
| 7 | web UI uses. |
| 8 | |
| 9 | ## Issue shape |
| 10 | |
| 11 | ```json |
| 12 | { |
| 13 | "id": 17, |
| 14 | "number": 1, |
| 15 | "title": "first bug", |
| 16 | "body": "kaboom", |
| 17 | "state": "open", |
| 18 | "state_reason": "", |
| 19 | "locked": false, |
| 20 | "lock_reason": "", |
| 21 | "author_id": 42, |
| 22 | "labels": ["bug"], |
| 23 | "created_at": "2026-05-12T05:00:00Z", |
| 24 | "updated_at": "2026-05-12T05:00:00Z" |
| 25 | } |
| 26 | ``` |
| 27 | |
| 28 | `state` is `"open"` or `"closed"`. `state_reason` is one of |
| 29 | `"completed"`, `"not_planned"`, `"duplicate"`, `"reopened"`, or |
| 30 | empty when no reason has been recorded. `closed_at` is present |
| 31 | only on closed issues. |
| 32 | |
| 33 | ## List issues |
| 34 | |
| 35 | ``` |
| 36 | GET /api/v1/repos/{owner}/{repo}/issues |
| 37 | ``` |
| 38 | |
| 39 | Required scope: `repo:read`. Paginated; `?per_page=` (≤100) and |
| 40 | `?page=` apply, with the standard `Link:` header. |
| 41 | |
| 42 | Optional `?state=open|closed|all` filter; `state=all` (or omitted) |
| 43 | returns both. |
| 44 | |
| 45 | Pull requests are not included on this endpoint — fetch them via |
| 46 | the pulls surface. |
| 47 | |
| 48 | ## Get a single issue |
| 49 | |
| 50 | ``` |
| 51 | GET /api/v1/repos/{owner}/{repo}/issues/{number} |
| 52 | ``` |
| 53 | |
| 54 | Required scope: `repo:read`. `404` when the issue doesn't exist, |
| 55 | when the caller can't see the repo, or when `{number}` belongs to |
| 56 | a pull request (use the pulls surface). |
| 57 | |
| 58 | ## Create an issue |
| 59 | |
| 60 | ``` |
| 61 | POST /api/v1/repos/{owner}/{repo}/issues |
| 62 | ``` |
| 63 | |
| 64 | Required scope: `repo:write`. Policy: `ActionIssueCreate`. |
| 65 | |
| 66 | ```json |
| 67 | { "title": "first bug", "body": "kaboom" } |
| 68 | ``` |
| 69 | |
| 70 | | Field | Type | Notes | |
| 71 | |---------|--------|-----------------------------------------------| |
| 72 | | `title` | string | Required, 1–256 chars. | |
| 73 | | `body` | string | Optional markdown body, ≤65535 chars. | |
| 74 | |
| 75 | Returns `201` with the issue envelope. |
| 76 | |
| 77 | ### Errors |
| 78 | |
| 79 | | Status | When | |
| 80 | |-------:|---------------------------------------------------| |
| 81 | | 401 | PAT missing/invalid. | |
| 82 | | 403 | PAT lacks `repo:write` scope or policy denial. | |
| 83 | | 422 | Empty title, title too long, body too long. | |
| 84 | |
| 85 | ## Update an issue |
| 86 | |
| 87 | ``` |
| 88 | PATCH /api/v1/repos/{owner}/{repo}/issues/{number} |
| 89 | ``` |
| 90 | |
| 91 | Required scope: `repo:write`. Only the fields you send are |
| 92 | modified; everything else stays. |
| 93 | |
| 94 | ```json |
| 95 | { |
| 96 | "title": "first bug — root cause found", |
| 97 | "body": "see comment #3", |
| 98 | "state": "closed", |
| 99 | "state_reason": "completed", |
| 100 | "labels": ["bug", "needs-triage"], |
| 101 | "assignees": ["alice"], |
| 102 | "milestone": 7 |
| 103 | } |
| 104 | ``` |
| 105 | |
| 106 | Permission rules: |
| 107 | |
| 108 | - **Title / body** — the issue author OR a repo collaborator with |
| 109 | write access. Other callers `403`. |
| 110 | - **State / state_reason** — any caller with `ActionIssueClose` |
| 111 | on the repo. `state_reason` must be one of `completed`, |
| 112 | `not_planned`, `duplicate`, `reopened` (or empty). |
| 113 | - **Labels** — caller needs `ActionIssueLabel`. The payload is a |
| 114 | *full replace*: `["bug"]` strips every other label; |
| 115 | `[]` clears them all. Omit the field to leave labels untouched. |
| 116 | Unknown label names return `422`. |
| 117 | - **Assignees** — caller needs `ActionIssueAssign`. Same |
| 118 | full-replace semantics, names are usernames; unknown usernames |
| 119 | return `422`. |
| 120 | - **Milestone** — caller needs `ActionIssueAssign`. Pass the |
| 121 | milestone `id` (see [Milestones](#milestones) below); `0` |
| 122 | clears the milestone. The milestone must belong to the same |
| 123 | repo; cross-repo ids return `422`. |
| 124 | |
| 125 | Returns the freshly-loaded issue. |
| 126 | |
| 127 | ## Lock and unlock |
| 128 | |
| 129 | ``` |
| 130 | PUT /api/v1/repos/{owner}/{repo}/issues/{number}/lock |
| 131 | DELETE /api/v1/repos/{owner}/{repo}/issues/{number}/lock |
| 132 | ``` |
| 133 | |
| 134 | Required scope: `repo:write`. PUT body is optional: |
| 135 | |
| 136 | ```json |
| 137 | { "lock_reason": "off-topic" } |
| 138 | ``` |
| 139 | |
| 140 | Returns `204`. Locking refuses non-collaborator comments; the |
| 141 | issue itself stays visible. |
| 142 | |
| 143 | ## Comments |
| 144 | |
| 145 | ### List |
| 146 | |
| 147 | ``` |
| 148 | GET /api/v1/repos/{owner}/{repo}/issues/{number}/comments |
| 149 | ``` |
| 150 | |
| 151 | Required scope: `repo:read`. Returns comments in chronological |
| 152 | order. |
| 153 | |
| 154 | ### Add |
| 155 | |
| 156 | ``` |
| 157 | POST /api/v1/repos/{owner}/{repo}/issues/{number}/comments |
| 158 | ``` |
| 159 | |
| 160 | Required scope: `repo:write`. Policy: `ActionIssueComment`. Body: |
| 161 | |
| 162 | ```json |
| 163 | { "body": "lgtm" } |
| 164 | ``` |
| 165 | |
| 166 | Subject to the per-author comment rate limit (20/hour); `429` |
| 167 | when exceeded. |
| 168 | |
| 169 | ### Edit own comment |
| 170 | |
| 171 | ``` |
| 172 | PATCH /api/v1/repos/{owner}/{repo}/issues/comments/{cid} |
| 173 | ``` |
| 174 | |
| 175 | Required scope: `repo:write`. The comment author can edit their |
| 176 | own; other callers `403`. |
| 177 | |
| 178 | ### Delete a comment |
| 179 | |
| 180 | ``` |
| 181 | DELETE /api/v1/repos/{owner}/{repo}/issues/comments/{cid} |
| 182 | ``` |
| 183 | |
| 184 | Required scope: `repo:write`. The comment author can delete their |
| 185 | own; repo collaborators with write access can delete any comment |
| 186 | on the repo (moderation affordance, matches the gh shape). |
| 187 | |
| 188 | Returns `204`. |
| 189 | |
| 190 | ## Milestones |
| 191 | |
| 192 | ``` |
| 193 | GET /api/v1/repos/{owner}/{repo}/milestones[?state=open|closed|all] |
| 194 | POST /api/v1/repos/{owner}/{repo}/milestones |
| 195 | GET /api/v1/repos/{owner}/{repo}/milestones/{id} |
| 196 | PATCH /api/v1/repos/{owner}/{repo}/milestones/{id} |
| 197 | DELETE /api/v1/repos/{owner}/{repo}/milestones/{id} |
| 198 | ``` |
| 199 | |
| 200 | Required scope: `repo:read` on GETs, `repo:write` on mutations. |
| 201 | Mutations gate on `ActionIssueLabel` (write collaborator role |
| 202 | on the repo). |
| 203 | |
| 204 | Create payload: |
| 205 | |
| 206 | ```json |
| 207 | { |
| 208 | "title": "v1.0", |
| 209 | "description": "first stable release", |
| 210 | "due_on": "2026-06-01T00:00:00Z", |
| 211 | "state": "open" |
| 212 | } |
| 213 | ``` |
| 214 | |
| 215 | `due_on` is RFC3339; omit or pass `null` to leave it unset. The |
| 216 | response shape mirrors GitHub's milestone with the repo-local |
| 217 | `id`, the `state` (`open` / `closed`), and live `open_issues` / |
| 218 | `closed_issues` counters. |
| 219 | |
| 220 | Identifier: the path takes the milestone primary key `id` |
| 221 | (returned by list / create), not a per-repo number — shithub's |
| 222 | schema doesn't carry a number column. The CLI gets the id back |
| 223 | from `POST` or list responses. |
| 224 | |
| 225 | ## Assignees |
| 226 | |
| 227 | ``` |
| 228 | GET /api/v1/repos/{owner}/{repo}/assignees |
| 229 | ``` |
| 230 | |
| 231 | Required scope: `repo:read`. Returns the users eligible to be |
| 232 | assigned: the repo owner (when user-owned) plus every direct |
| 233 | repo collaborator. Org-level expansion of org-owned repos is a |
| 234 | follow-up. |
| 235 | |
| 236 | ## Not yet shipped |
| 237 | |
| 238 | `POST /api/v1/repos/{o}/{r}/issues/{n}/transfer` and issue |
| 239 | pinning are queued for a follow-up batch. |