Skip to content

gh issue: Managing Issues from the Terminal

Lesson 5 of 10Beginner → Intermediate8 min readGitHub Engineering · GitHub CLIVerified: gh 2.98.0 on Ubuntu 24.04, August 2026

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.

CommandDoes
createOpen an Issue
listList Issues
statusIssues relevant to you
viewShow one, or fields of it
editChange title, body, labels, assignees, milestone
commentAdd a comment
close / reopenChange state
developCreate and optionally check out a linked branch
transferMove to another repository
pin / unpinPin to the repository’s Issue list
lock / unlockControl the conversation
deleteDelete permanently
Terminal window
gh issue create --title "Client drops the final retry attempt" --body-file report.md
gh 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.

The search qualifiers are the same as the web interface, which means anything you can filter there you can script here:

Terminal window
gh issue list --state open --limit 30
gh 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)"'
Terminal window
gh issue list --state open --label bug --search "no:assignee" \
--json number,title,createdAt --limit 100

What 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:

Terminal window
# Unowned bugs
gh issue list --state open --label bug --search "no:assignee"
# Stale — no update in 90 days
gh issue list --state open --search "updated:<$(date -u -d '90 days ago' +%Y-%m-%d)"
# Waiting on the reporter
gh 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.

Terminal window
gh issue edit 42 --add-label p1 --add-assignee "@me" --milestone "v2.1"
gh issue edit 42 --remove-label needs-repro
gh issue close 42 --reason completed
gh 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:

Terminal window
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"
done

gh issue develop creates a branch that GitHub records as linked to the Issue:

Terminal window
gh issue develop 42 --checkout
gh issue develop 42 --name fix/retry-final-attempt --base main --checkout
gh issue develop 42 --list

The 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 are their own objects with their own numbers, distinct from the Issue they belong to.

Terminal window
gh issue comment 42 --body "Reproduced on 2.1.0."
gh issue comment 42 --body-file update.md
gh issue view 42 --comments

gh 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:

Terminal window
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.

Three operations that come up in maintenance rather than daily work.

Terminal window
gh issue transfer 42 OWNER/OTHER-REPO
gh issue pin 42
gh issue unpin 42
gh issue lock 42 --reason resolved
gh issue unlock 42

Transfer 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 group Issues, usually by release. The porcelain can assign them but not manage them, so creation goes through the API:

Terminal window
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.

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?

Terminal window
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?

Terminal window
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?

Terminal window
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.

For anything you are about to act on, one call gets everything that matters:

Terminal window
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.

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:

Terminal window
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 -rn

Search is one request but capped and rate-limited more strictly:

Terminal window
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.

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:

Terminal window
gh issue create --template bug_report.md
gh 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.

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

  1. Create an Issue with gh issue create --body-file from a small Markdown file.
  2. Label and assign it with gh issue edit.
  3. Run gh issue develop <number> --checkout and confirm with git branch --show-current.
  4. Commit, push, and open a pull request whose body contains Closes #<number>.
  5. Merge it and confirm the Issue closed automatically.
  6. Run the “unowned bugs” query against a repository you work in.

gh issue view --json and gh issue list --json expose more than the default output shows:

FieldUse
number, title, stateThe basics
stateReasonCOMPLETED or NOT_PLANNED
author, assigneesWho reported, who owns
labels, milestoneTriage metadata
commentsFull comment bodies, or a count
createdAt, updatedAt, closedAtAge and staleness
bodyThe description, for searching
urlFor 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:

Terminal window
gh issue list --state open --limit 200 --json number,title,createdAt \
--jq '.[] | {number, title,
age: (((now - (.createdAt | fromdateiso8601)) / 86400) | floor)}
| select(.age > 180)'

gh search issues is the cross-repository counterpart, with its own flags:

Terminal window
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 50
gh 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.

  • gh issue mirrors gh pr, so learning one teaches the other.
  • --body-file preserves Markdown that shell quoting would destroy.
  • Search qualifiers are identical to the web interface, which makes triage scriptable.
  • gh issue develop creates 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.

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.

Professional ToolkitThe gh api recipes and the PR triage and release-notes scripts are in the Professional Toolkit.