gh issue mirrors gh pr closely — same verbs, same output conventions — which makes it easy to
learn once you know one.
The command worth knowing about specifically is gh issue develop, which creates a branch linked to
an Issue. It is the least-known command in the family and removes a step people usually forget.
The subcommands
Section titled “The subcommands”| Command | Does |
|---|---|
create | Open an Issue |
list | List Issues |
status | Issues relevant to you |
view | Show one, or fields of it |
edit | Change title, body, labels, assignees, milestone |
comment | Add a comment |
close / reopen | Change state |
develop | Create and optionally check out a linked branch |
transfer | Move to another repository |
pin / unpin | Pin to the repository’s Issue list |
lock / unlock | Control the conversation |
delete | Delete permanently |
Creating
Section titled “Creating”gh issue create --title "Client drops the final retry attempt" --body-file report.mdgh issue create --title "Add TLS configuration docs" --label docs --assignee "@me"gh issue create --web--body-file is preferable to --body for anything with structure: shell quoting mangles Markdown,
and a heredoc or file keeps formatting intact. - reads from standard input, which is what you want
when generating an Issue from another command’s output.
--web opens the browser with fields pre-filled, which is the right choice when the repository has
Issue forms — forms are a web feature and the CLI cannot render them.
Finding and triaging
Section titled “Finding and triaging”The search qualifiers are the same as the web interface, which means anything you can filter there you can script here:
gh issue list --state open --limit 30gh issue list --label bug --assignee "@me"gh issue list --search "no:assignee label:bug"gh issue list --search "is:open updated:<2026-06-01"gh issue list --json number,title,labels,createdAt \ --jq '.[] | "\(.number)\t\(.title)"'gh issue list --state open --label bug --search "no:assignee" \ --json number,title,createdAt --limit 100What it doesLists open bugs with nobody assigned, as structured data.
Why we run itUnowned bugs are the ones that quietly rot. Structured output means this can feed a weekly report rather than being read by eye.
Expected resultA JSON array of Issues with the requested fields.
Three queries worth running regularly on any repository you maintain:
# Unowned bugsgh issue list --state open --label bug --search "no:assignee"
# Stale — no update in 90 daysgh issue list --state open --search "updated:<$(date -u -d '90 days ago' +%Y-%m-%d)"
# Waiting on the reportergh issue list --state open --label needs-repro --search "updated:<$(date -u -d '30 days ago' +%Y-%m-%d)"Each identifies a different kind of decay, and all three are one line.
Editing in bulk
Section titled “Editing in bulk”gh issue edit 42 --add-label p1 --add-assignee "@me" --milestone "v2.1"gh issue edit 42 --remove-label needs-reprogh issue close 42 --reason completedgh issue close 43 --reason "not planned"The close reason matters more than it looks. “Completed” and “not planned” tell very different stories in six months, and reporting that treats them identically overstates how much was fixed.
Bulk labelling is a loop over a filtered list:
gh issue list --state open --search "label:bug no:milestone" --json number --jq '.[].number' \| while read -r n; do gh issue edit "$n" --milestone "Backlog" doneLinked branches
Section titled “Linked branches”gh issue develop creates a branch that GitHub records as linked to the Issue:
gh issue develop 42 --checkoutgh issue develop 42 --name fix/retry-final-attempt --base main --checkoutgh issue develop 42 --listThe link is visible on the Issue, so anyone looking at it can see work has started and where. It also
means the connection between work and its record exists from the first commit, rather than depending
on someone remembering Closes #42 at pull request time.
This is the command most worth adopting from this lesson. It costs nothing and it closes the most common gap in the Issue-to-code chain.
Comments and reactions
Section titled “Comments and reactions”Comments are their own objects with their own numbers, distinct from the Issue they belong to.
gh issue comment 42 --body "Reproduced on 2.1.0."gh issue comment 42 --body-file update.mdgh issue view 42 --commentsgh issue view --comments prints the whole conversation, which is useful when triaging by terminal —
you get the history without loading a page.
Reactions are not covered by the porcelain and need the API:
gh api repos/OWNER/REPO/issues/42/reactions --jq 'group_by(.content) | map({content: .[0].content, count: length})'Reaction counts are a genuinely useful triage signal on public repositories: an Issue with forty thumbs-up is telling you something about demand that its comment count does not.
Transferring, pinning and locking
Section titled “Transferring, pinning and locking”Three operations that come up in maintenance rather than daily work.
gh issue transfer 42 OWNER/OTHER-REPOgh issue pin 42gh issue unpin 42gh issue lock 42 --reason resolvedgh issue unlock 42Transfer moves an Issue to another repository, keeping its comments and history. It is the right response to something filed in the wrong place in a multi-repository project — much better than closing with “wrong repo, please refile”, which loses the report and asks the reporter to do the work again.
Pinning places an Issue at the top of the list. Up to a small number can be pinned, and the good use is a known-issues notice or a roadmap that would otherwise be asked about repeatedly.
Locking stops further comments. The --reason is recorded and displayed, and choosing it
honestly matters — resolved, off-topic, too heated, spam tell readers very different things
about why a conversation ended.
Milestones
Section titled “Milestones”Milestones group Issues, usually by release. The porcelain can assign them but not manage them, so creation goes through the API:
gh api repos/OWNER/REPO/milestones --jq '.[] | [.number, .title, .open_issues, .closed_issues] | @tsv'
gh api --method POST repos/OWNER/REPO/milestones -f title="v2.1" -f description="Retry handling and docs" -f due_on="2026-10-01T00:00:00Z"
gh issue edit 42 --milestone "v2.1"The open_issues and closed_issues counts on a milestone are the cheapest release-progress signal
available, and one API call gets them for every milestone at once.
Issues and pull requests share a number space
Section titled “Issues and pull requests share a number space”Worth knowing when scripting: a repository has one sequence covering both. There is no Issue #12 and pull request #12.
A consequence in the API — and therefore occasionally in gh — is that pull requests are also
Issues for some purposes. gh issue list excludes pull requests, but the underlying REST Issues
endpoint does not, which matters when you move from the CLI to raw API calls. See
Issue Automation.
Building a triage workflow
Section titled “Building a triage workflow”The commands become useful when combined into something you run regularly rather than ad hoc. A weekly triage session benefits from three queries, in order.
What arrived that nobody has looked at?
gh issue list --state open --search "no:label" --json number,title,createdAt,author \ --jq '.[] | [.number, .author.login, .title] | @tsv'Unlabelled Issues are the untriaged queue. If this list is never empty, labelling is not happening at intake and everything downstream is guesswork.
What is owned but not moving?
gh issue list --state open --search "assignee:* updated:<$(date -u -d '21 days ago' +%Y-%m-%d)" \ --json number,title,assignees \ --jq '.[] | [.number, ([.assignees[].login] | join(",")), .title] | @tsv'An assigned Issue with no activity for three weeks is usually one of two things: someone quietly stopped, or the assignment was aspirational. Both are worth surfacing, and neither is visible from the default list.
What is blocked on the reporter?
gh issue list --state open --label needs-repro \ --search "updated:<$(date -u -d '30 days ago' +%Y-%m-%d)" \ --json number,title --jq '.[] | [.number, .title] | @tsv'These are the closable ones. Closing with not_planned and a courteous note is honest; leaving them
open indefinitely is not, and it inflates every count you report.
Reading an Issue’s full context
Section titled “Reading an Issue’s full context”For anything you are about to act on, one call gets everything that matters:
gh issue view 42 --json number,title,state,stateReason,author,assignees,labels,milestone,comments,createdAt,updatedAt \ --jq '{ number, title, state, reason: .stateReason, author: .author.login, assignees: [.assignees[].login], labels: [.labels[].name], milestone: .milestone.title, comments: (.comments | length), age_days: (((now - (.createdAt | fromdateiso8601)) / 86400) | floor) }'Output:
{ "age_days": 47, "assignees": ["alice"], "author": "reporter", "comments": 6, "labels": ["bug", "p1"], "milestone": "v2.1", "number": 42, "reason": null, "state": "OPEN", "title": "Client drops the final retry attempt"}The computed age_days is the sort of thing worth having in a report. jq’s now and
fromdateiso8601 handle the arithmetic, so no post-processing is needed.
Cross-repository work
Section titled “Cross-repository work”gh issue list operates on one repository. For an organisation-wide view, either loop or use search.
Looping is exhaustive but expensive — one request per repository:
gh repo list ORG --limit 100 --no-archived --json nameWithOwner --jq '.[].nameWithOwner' \| while read -r repo; do count=$(gh issue list --repo "$repo" --state open --label bug --json number --jq 'length') [ "$count" -gt 0 ] && printf '%4d %s\n' "$count" "$repo" done | sort -rnSearch is one request but capped and rate-limited more strictly:
gh search issues --owner ORG --state open --label bug --limit 100 \ --json repository,number,title --jq '.[] | [.repository.nameWithOwner, .number, .title] | @tsv'gh search is a separate top-level command from gh issue list, with its own flags. Use it to find
things across repositories; use gh issue list when you need every result from one.
Templates and the CLI
Section titled “Templates and the CLI”If a repository uses Issue forms, gh issue create cannot render
them — forms are a web feature. The CLI offers the Markdown templates it finds:
gh issue create --template bug_report.mdgh issue create --web--web is the honest answer on a repository with forms: it opens the browser with the chooser, so
the reporter gets the structure the maintainer designed. Filing a form-less Issue on a repository
that expects forms produces exactly the unstructured report the forms were added to prevent.
Common mistakes
Section titled “Common mistakes”--body for structured Markdown. Shell quoting mangles it; use --body-file.
Bulk-editing without a dry run. No undo.
Closing without a reason. Loses the completed/not-planned distinction.
Using the CLI on a repository with Issue forms. Use --web so the form renders.
Parsing list output. Use --json.
Forgetting gh issue develop exists. It removes a step people routinely skip.
Exercise
Section titled “Exercise”- Create an Issue with
gh issue create --body-filefrom a small Markdown file. - Label and assign it with
gh issue edit. - Run
gh issue develop <number> --checkoutand confirm withgit branch --show-current. - Commit, push, and open a pull request whose body contains
Closes #<number>. - Merge it and confirm the Issue closed automatically.
- Run the “unowned bugs” query against a repository you work in.
Fields worth knowing
Section titled “Fields worth knowing”gh issue view --json and gh issue list --json expose more than the default output shows:
| Field | Use |
|---|---|
number, title, state | The basics |
stateReason | COMPLETED or NOT_PLANNED |
author, assignees | Who reported, who owns |
labels, milestone | Triage metadata |
comments | Full comment bodies, or a count |
createdAt, updatedAt, closedAt | Age and staleness |
body | The description, for searching |
url | For linking in reports |
Run gh issue view --json with no value for the complete list on your version.
Computing age in the query rather than afterwards keeps reports to one command:
gh issue list --state open --limit 200 --json number,title,createdAt \ --jq '.[] | {number, title, age: (((now - (.createdAt | fromdateiso8601)) / 86400) | floor)} | select(.age > 180)'Working across an organisation
Section titled “Working across an organisation”gh search issues is the cross-repository counterpart, with its own flags:
gh search issues --owner ORG --state open --label bug --limit 100 \ --json repository,number,title,createdAt \ --jq '.[] | [.repository.nameWithOwner, .number, .title] | @tsv'
gh search issues --assignee "@me" --state open --limit 50gh search issues --involves "@me" --updated ">2026-08-01"--involves is broader than --assignee — it matches Issues you authored, were assigned, mentioned
in, or commented on, which is usually what “what am I part of” means.
Remember search’s constraints: a stricter rate limit and a cap on total results. It finds things; it does not enumerate them exhaustively.
What you learned
Section titled “What you learned”gh issuemirrorsgh pr, so learning one teaches the other.--body-filepreserves Markdown that shell quoting would destroy.- Search qualifiers are identical to the web interface, which makes triage scriptable.
gh issue developcreates a linked branch and closes the work-to-record gap.- Close reasons distinguish completed from not planned, and reporting depends on it.
- Issues and pull requests share one number sequence, which matters at the API boundary.
The short version
Section titled “The short version”gh issue mirrors gh pr, so learning one teaches the other. The command most worth adopting is
gh issue develop --checkout, which creates a branch linked to the Issue — closing the gap between
work and its record at the point where it is easiest to close.
For triage, search qualifiers are identical to the web interface, so any filter you can express there
is scriptable here. Three queries are worth running regularly on anything you maintain: unowned bugs,
Issues untouched in ninety days, and needs-repro older than a month.