Skip to content

gh pr: The Pull Request Lifecycle from the Terminal

Lesson 4 of 10Intermediate6 min readGitHub Engineering · GitHub CLIVerified: gh 2.98.0 on Ubuntu 24.04, August 2026

gh pr is the largest command family in the CLI, and the one that changes daily work most.

Everything in the Pull Requests cluster has a command here. This lesson covers the operations worth knowing and the output fields that make them scriptable.

CommandDoes
createOpen a pull request
listList pull requests
statusShow pull requests relevant to you
viewShow one, or fields of it
diffShow the diff
checkoutCheck the branch out locally
checksShow CI status
reviewApprove, request changes or comment
commentAdd a comment
editChange title, body, reviewers, labels
readyMark a draft ready, or --undo
update-branchBring the branch up to date with base
mergeMerge, squash or rebase
close / reopenClose without merging, or reopen
revertRevert a merged pull request
lock / unlockControl the conversation

Most commands take a number, a URL, or a branch name. Inside a checkout with a branch that has a pull request, they take nothing at all and operate on the current branch — convenient interactively, and worth being explicit about in scripts.

Terminal window
gh pr create --base main --title "Add retry handling" --body "Closes #42"
gh pr create --fill
gh pr create --draft --title "Spike: alternative approach" --body "Seeking direction"
gh pr create --repo OWNER/REPO --base main --head you:my-feature --fill

--fill takes the title and body from your commits, which rewards good commit messages and bypasses any pull request template.

The last form is the cross-repository case: --head you:my-feature names the fork owner as well as the branch, which is required when the head branch is not in the target repository.

Terminal window
gh pr status
gh pr list --state open --limit 30
gh pr list --author "@me"
gh pr list --search "review-requested:@me"
gh pr list --label bug --state open
gh pr list --json number,title,author,createdAt --jq '.[] | "\(.number)\t\(.author.login)\t\(.title)"'

gh pr status is the single most useful interactive command in the family: it shows what you have open, what is waiting on you, and what is relevant to your branch, in one screen.

review-requested:@me is worth an alias. It is a far more reliable review queue than notifications.

Terminal window
gh pr view PULL_NUMBER --json mergeable,mergeStateStatus,reviewDecision,statusCheckRollup

What it doesFetches the fields that determine whether a pull request can merge, and why.

Why we run itThese three fields answer different questions and frequently disagree. Reading them together is how you diagnose 'approved but the button is disabled'.

Expected resultA JSON object; mergeStateStatus is usually the one explaining a block.

Other useful views:

Terminal window
gh pr view PULL_NUMBER
gh pr view PULL_NUMBER --comments
gh pr diff PULL_NUMBER
gh pr diff PULL_NUMBER --name-only
gh pr view PULL_NUMBER --json files --jq '.files[] | "\(.additions)+ \(.deletions)- \(.path)"'

--name-only is the quick way to judge scope before committing to a review — a pull request touching forty files across six directories is a different proposition from one touching three.

Terminal window
gh pr checkout PULL_NUMBER
gh pr review PULL_NUMBER --approve --body "Looks good."
gh pr review PULL_NUMBER --request-changes --body "The retry loop can spin unbounded."
gh pr review PULL_NUMBER --comment --body "One question, no objection."
gh pr comment PULL_NUMBER --body "Deployed to staging for testing."

gh pr checkout works for pull requests from forks too, which is its most useful property — it handles the remote configuration that would otherwise be several manual steps.

Remember that --comment records no verdict; see Pull Request Reviews.

Terminal window
gh pr checks
gh pr checks --watch
gh pr checks --required
gh pr checks --json name,state,link --jq '.[] | select(.state != "SUCCESS")'

--watch blocks until checks complete, which makes it usable in a script that should wait before merging. --required filters to the checks that actually gate the merge — useful on repositories with many advisory checks.

Terminal window
gh pr update-branch PULL_NUMBER
gh pr ready PULL_NUMBER
gh pr merge PULL_NUMBER --squash --delete-branch
gh pr merge PULL_NUMBER --merge
gh pr merge PULL_NUMBER --rebase
gh pr merge PULL_NUMBER --auto --squash

--auto enables auto-merge: the pull request merges by itself once every requirement is satisfied. On a repository with slow checks this removes a great deal of waiting-and-returning, and it is GitHub doing the merge rather than a script polling.

On a repository with a merge queue, gh pr merge enqueues rather than merging.

Two that come up constantly.

Merge every approved, passing, non-draft pull request:

Terminal window
gh pr list --state open --json number,isDraft,reviewDecision,statusCheckRollup \
--jq '.[] | select(.isDraft | not)
| select(.reviewDecision == "APPROVED")
| select([.statusCheckRollup[].state] | all(. == "SUCCESS"))
| .number' \
| while read -r pr; do
echo "merging #$pr"
gh pr merge "$pr" --squash --delete-branch
done

Report pull requests waiting on review for more than two days:

Terminal window
gh pr list --state open --json number,title,createdAt,reviewDecision \
--jq --arg cutoff "$(date -u -d '2 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
'.[] | select(.reviewDecision == null) | select(.createdAt < $cutoff) | "\(.number)\t\(.title)"'

Both rely entirely on --json and --jq. Neither would survive a change to gh’s human-readable table format, which is precisely the point.

A complete review cycle, entirely in the shell:

Terminal window
# What is waiting on me?
gh pr list --search "review-requested:@me" --json number,title,author \
--jq '.[] | [.number, .author.login, .title] | @tsv'
# How big is it?
gh pr view 128 --json additions,deletions,changedFiles \
--jq '"\(.changedFiles) files, +\(.additions) -\(.deletions)"'
# What changed, at a glance
gh pr diff 128 --name-only
# Read it properly
gh pr diff 128
# Run it
gh pr checkout 128
# ...build, test, poke at it...
# Respond
gh pr review 128 --request-changes --body "The retry loop can spin unbounded; see the comment on line 42."
# Return to where you were
git switch -

The size check before reading is worth the two seconds. A pull request with four files and sixty lines is a different commitment from one with forty files and two thousand, and knowing which you are about to open changes whether you do it now or schedule it.

gh pr checkout is what makes terminal review genuinely competitive with the web interface. Reading a diff tells you what changed; running the branch tells you whether it works, and some categories of problem — behaviour under real input, performance, integration — are invisible in a diff.

gh pr comment adds a conversation comment, not a line comment. Line comments need the API:

Terminal window
gh api --method POST repos/OWNER/REPO/pulls/128/comments \
-f body="This can spin unbounded when the server keeps closing the connection." \
-f commit_id="$(gh pr view 128 --json headRefOid --jq .headRefOid)" \
-f path="src/client.py" \
-F line=42 \
-f side=RIGHT

side=RIGHT comments on the new version of the line; LEFT comments on the old one. line is the line number in the diff’s target file, and commit_id must be the current head — which is why it is fetched rather than hardcoded, since a comment against a stale commit is rejected.

Suggested changes are line comments with a specially formatted body:

Terminal window
gh api --method POST repos/OWNER/REPO/pulls/128/comments \
-f path="src/client.py" -F line=42 -f side=RIGHT \
-f commit_id="$(gh pr view 128 --json headRefOid --jq .headRefOid)" \
-f body='Cap the delay so this cannot grow without bound.
```suggestion
delay = min(base_delay * (2 ** attempt), MAX_BACKOFF)
```'

That is more ceremony than clicking, and it is the primitive any review automation ultimately uses.

The single most common question — “why can I not merge this?” — has a one-command answer:

Terminal window
gh pr view 128 --json mergeable,mergeStateStatus,reviewDecision,statusCheckRollup \
--jq '{
git: .mergeable,
policy: .mergeStateStatus,
review: .reviewDecision,
failing: [.statusCheckRollup[] | select(.conclusion != "SUCCESS" and .conclusion != null) | .name]
}'

Output:

{
"failing": ["integration-tests"],
"git": "MERGEABLE",
"policy": "BLOCKED",
"review": "APPROVED"
}

Read together: Git can merge it, review approved it, and one check is failing. Each field alone would have been misleading — which is exactly why “it is approved, why is the button greyed out” is such a persistent question.

The mergeStateStatus values are worth memorising: CLEAN is ready, BLOCKED means a policy requirement is unmet, BEHIND means the base moved, DIRTY means conflicts, and UNSTABLE means something non-required failed. UNSTABLE is mergeable — and habitually merging through it is how non-required checks become decorative.

On a repository with slow checks, --auto removes a great deal of waiting:

Terminal window
gh pr merge 128 --squash --auto --delete-branch

The pull request merges by itself once every requirement is satisfied. GitHub performs the merge, so there is no script polling and nothing to keep running.

Two conditions: auto-merge must be enabled on the repository, and the pull request must have at least one unmet requirement — enabling it on something already mergeable just merges immediately.

It pairs well with --delete-branch, giving a genuine fire-and-forget: request review, enable auto-merge, and the change lands when it is ready without another visit.

Using --comment when you mean approve or request changes. Records no state.

Relying on the current branch in scripts. Be explicit with a number and --repo.

Forgetting the owner prefix in --head for forks. GitHub looks in the wrong repository.

Parsing gh pr list output. Use --json.

Bulk-merging without a dry run. Test with echo.

Assuming gh pr merge merges on a queue-enabled branch. It enqueues.

The commands become a habit when they fit into one sequence. A morning routine:

Terminal window
# What needs my attention?
gh pr status
# Reviews waiting on me, oldest first
gh pr list --search "review-requested:@me sort:created-asc" \
--json number,title,author,createdAt \
--jq '.[] | [.number, .author.login, .title] | @tsv'
# My own open pull requests and their state
gh pr list --author "@me" --json number,title,reviewDecision,mergeStateStatus \
--jq '.[] | [.number, (.reviewDecision // "PENDING"), .mergeStateStatus, .title] | @tsv'

The third is the useful one. It tells you, in one line each, which of your changes are approved and merge-ready, which are blocked, and which nobody has looked at — so you know whether to chase a review, fix a check, or merge.

Worth turning into aliases:

Terminal window
gh alias set mine 'pr list --author "@me" --json number,title,reviewDecision,mergeStateStatus --jq ".[] | [.number, (.reviewDecision // \"PENDING\"), .mergeStateStatus, .title] | @tsv"'
gh alias set todo 'pr list --search "review-requested:@me sort:created-asc"'

Contributions arrive from forks, and the commands handle it with one adjustment each.

Terminal window
# Check out a contributor's branch — handles the remote configuration for you
gh pr checkout 128
# See where it came from
gh pr view 128 --json headRepositoryOwner,headRefName,isCrossRepository \
--jq '{fork: .isCrossRepository, owner: .headRepositoryOwner.login, branch: .headRefName}'
# Push a fix to their branch, if they allowed maintainer edits
git commit -am "Fix the failing test"
git push

gh pr checkout is what makes reviewing fork contributions practical. Doing it by hand means adding a remote, fetching, and creating a tracking branch; gh resolves it from the pull request.

Whether you can push back depends on the contributor leaving allow edits from maintainers enabled. When they have, fixing a small problem yourself is usually kinder than a review round trip — but it is their branch, so say what you changed.

Anything touching many pull requests needs the same care as bulk Issue operations.

Terminal window
# Everything approved, passing and not draft
gh pr list --state open --json number,isDraft,reviewDecision,mergeStateStatus \
--jq '.[] | select(.isDraft | not)
| select(.reviewDecision == "APPROVED")
| select(.mergeStateStatus == "CLEAN")
| .number'
# Stale — no update in three weeks
gh pr list --state open --json number,title,updatedAt \
--jq --arg cutoff "$(date -u -d '21 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
'.[] | select(.updatedAt < $cutoff) | [.number, .title] | @tsv'
# Behind the base branch
gh pr list --state open --json number,mergeStateStatus \
--jq '.[] | select(.mergeStateStatus == "BEHIND") | .number'

Each is a report before it is an action. Run them, read the output, and only then decide whether to act on the list — with echo substituted for the real command until the list is exactly right.

The CLEAN filter in the first is doing important work: it means every policy requirement is satisfied, so the script is not reimplementing your repository’s rules and getting them subtly wrong.

  1. Create a branch, push it, and open a pull request with gh pr create --fill.
  2. Run gh pr status and find it.
  3. Inspect it with gh pr view --json mergeable,mergeStateStatus,reviewDecision.
  4. Run gh pr checks --watch and observe it block until checks finish.
  5. Write the “waiting on review” query above and run it against a repository you work in.
  6. Merge with gh pr merge --squash --delete-branch, then confirm the branch is gone with git fetch --prune.

gh pr view --json and gh pr list --json expose a large field set. The ones that answer real questions:

FieldAnswers
mergeableCan Git combine the branches?
mergeStateStatusDoes policy permit merging?
reviewDecisionAPPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, or null
statusCheckRollupEvery check and its conclusion
isDraftReady or not
isCrossRepositoryFrom a fork?
headRefOidThe head commit SHA — needed for guarded merges
reviewRequestsWho has been asked
additions / deletions / changedFilesSize, for triage
autoMergeRequestIs auto-merge enabled, and with what method

Run gh pr view --json with no value for the full list on your version.

Combining them produces a single view that explains any pull request’s state:

Terminal window
gh pr view 128 --json number,title,isDraft,reviewDecision,mergeStateStatus,statusCheckRollup,headRefOid \
--jq '{
pr: .number, title: .title, draft: .isDraft,
review: (.reviewDecision // "PENDING"),
policy: .mergeStateStatus,
failing: [.statusCheckRollup[] | select(.conclusion == "FAILURE") | .name],
head: .headRefOid[0:7]
}'

Scattered across subcommands, and easy to miss:

Terminal window
gh pr list --limit 200 # default is 30
gh pr list --state all # open, closed and merged
gh pr view --web # open in a browser
gh pr diff --color always | less -R # paged diff with colour preserved
gh pr checks --required # only checks that gate the merge
gh pr merge --admin # bypass, if you have permission
gh pr create --dry-run # show what would be created

--admin deserves care. It bypasses branch protection where your permissions allow, which is occasionally the correct emergency action and should never be routine — every use is a policy requirement being skipped, and it is recorded as such.

--dry-run on gh pr create is worth knowing: it prints the title, body and target branches without opening anything, which catches a wrong base branch before it becomes a pull request you have to close.

  • gh pr status is the fastest interactive overview of what needs your attention.
  • mergeable, mergeStateStatus and reviewDecision together explain any blocked merge.
  • gh pr checkout handles fork remotes automatically.
  • --watch blocks until checks finish; --auto lets GitHub merge when requirements are met.
  • Scripted workflows depend on --json and --jq, never on table output.
  • Bulk operations should be dry-run with echo before they act.

Three commands do most of the work: gh pr status for what needs your attention, gh pr checkout for reviewing something properly, and gh pr view --json mergeable,mergeStateStatus,reviewDecision for understanding why something will not merge.

For anything scripted, use --json and --jq. The human-readable table changes between releases and formats differently when output is not a terminal — so a script tested interactively can behave differently in CI, which is a genuinely unpleasant class of bug.

gh pr revert deserves a demonstration rather than a table entry, because reverting by hand is error-prone in a specific way.

Terminal window
gh pr revert 128
gh pr revert 128 --title "Revert: retry handling" --body "Caused a regression in reconnect handling."
gh pr revert 128 --draft

It creates a new pull request containing the inverse of the merge. That is safer than reverting locally for two reasons: it gets the parent selection right on a merge commit, which git revert -m requires you to specify and which is easy to get wrong; and the revert goes through review and CI like any other change rather than being pushed directly.

The --draft form is useful during an incident — open the revert immediately so it exists and is visible, then mark it ready once someone has confirmed it is the correct response.

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