Skip to content

Pull Request Automation with the GitHub API

Lesson 8 of 10Intermediate → Advanced11 min readGitHub Engineering · GitHub APIVerified: GitHub REST API version 2022-11-28 via gh 2.98.0, August 2026

Pull request automation covers reporting, review workflow, and — the part that needs the most care — merging.

Merging writes a commit to a branch other people depend on. Everything in this lesson before that section is reversible; that section is not.

Terminal window
gh api "repos/OWNER/REPO/pulls?state=open&per_page=100" --paginate \
--jq '.[] | [.number, .user.login, .title] | @tsv'
gh api repos/OWNER/REPO/pulls/PULL_NUMBER \
--jq '{number, state, draft, mergeable, mergeable_state, base: .base.ref, head: .head.ref}'

Two fields are worth understanding precisely.

mergeable — whether Git can combine the branches. It can be null while GitHub computes the answer, which happens on a freshly created or freshly updated pull request. A script reading it immediately will often get null; poll briefly rather than treating that as unmergeable.

mergeable_state — whether policy permits merging. clean, blocked, behind, dirty, unstable. This is the field that explains an approved pull request that will not merge.

Terminal window
gh api "repos/OWNER/REPO/pulls/PULL_NUMBER/files?per_page=100" --paginate \
--jq '.[] | [.status, .additions, .deletions, .filename] | @tsv'
gh api repos/OWNER/REPO/pulls/PULL_NUMBER \
-H "Accept: application/vnd.github.diff"

The Accept header changing the response format is a distinctive REST feature — application/vnd.github.diff returns a unified diff rather than JSON, and .patch returns a mailbox-format patch.

The files endpoint paginates and caps at a maximum number of files, so for very large pull requests it may not return everything. Automation that computes statistics from it should note that limit rather than reporting a confident wrong total.

Terminal window
gh api --method POST repos/OWNER/REPO/pulls \
-f title="Add retry handling" -f head=my-branch -f base=main \
-f body="Closes #42" -F draft=true
gh api --method PATCH repos/OWNER/REPO/pulls/PULL_NUMBER \
-f title="Updated title" -f state=closed
# From a fork: head is owner:branch
gh api --method POST repos/OWNER/REPO/pulls \
-f title="Fix from a fork" -f head="contributor:their-branch" -f base=main

Marking a draft ready is a GraphQL mutation rather than a REST call — one of the places where the two APIs differ in coverage:

Terminal window
gh api graphql -f query='
mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) {
pullRequest { number isDraft }
}}' -F id="$PR_NODE_ID"
Terminal window
gh api --method POST repos/OWNER/REPO/pulls/PULL_NUMBER/requested_reviewers \
-f "reviewers[]=alice" -f "team_reviewers[]=api-team"
gh api "repos/OWNER/REPO/pulls/PULL_NUMBER/reviews" \
--jq '.[] | [.user.login, .state, .submitted_at] | @tsv'
gh api --method POST repos/OWNER/REPO/pulls/PULL_NUMBER/reviews \
-f event=APPROVE -f body="Automated checks passed."

event is APPROVE, REQUEST_CHANGES or COMMENT. Omitting it creates a pending review that is not submitted, which is occasionally useful and usually a mistake.

Requesting the pull request’s author as a reviewer returns 422, as does requesting someone without repository access. Automation should handle both rather than dying.

Terminal window
gh api "repos/OWNER/REPO/commits/$SHA/check-runs" \
--jq '.check_runs[] | [.name, .status, .conclusion] | @tsv'
gh api "repos/OWNER/REPO/commits/$SHA/status" --jq '{state, total_count}'

There are two systems here for historical reasons. Check runs are the modern API used by Actions and most integrations. Commit statuses are the older mechanism, still used by some external services. A complete picture requires both, which is why mergeable_state is often easier than assembling it yourself.

Both attach to a commit, not to a pull request — which is why a new push resets them.

Terminal window
gh api --method PUT repos/OWNER/REPO/pulls/PULL_NUMBER/merge \
-f merge_method=squash \
-f sha="$EXPECTED_HEAD_SHA" \
-f commit_title="Add retry handling (#42)"

What it doesMerges a pull request with the squash strategy, but only if the head commit is still the one you validated.

Why we run it`sha` makes the merge conditional. If someone pushes between your check and your merge, the request fails with 409 rather than merging code you never examined.

Expected resultA JSON object with merged true and the resulting commit SHA, or a 409 if the head moved.

The sha parameter is the single most important safety measure in this lesson. Without it there is a race: you verify the checks passed, someone pushes, and you merge the new commit believing it was validated.

A complete guarded merge:

#!/usr/bin/env bash
set -euo pipefail
REPO="${REPO:?}" PR="${PR:?}"
DRY_RUN="${DRY_RUN:-true}"
pr=$(gh api "repos/$REPO/pulls/$PR")
head_sha=$(jq -r '.head.sha' <<<"$pr")
state=$(jq -r '.mergeable_state' <<<"$pr")
draft=$(jq -r '.draft' <<<"$pr")
[ "$draft" = "false" ] || { echo "draft; refusing" >&2; exit 1; }
[ "$state" = "clean" ] || { echo "mergeable_state=$state; refusing" >&2; exit 1; }
if [ "$DRY_RUN" = "true" ]; then
echo "would merge $REPO#$PR at $head_sha"
else
gh api --method PUT "repos/$REPO/pulls/$PR/merge" \
-f merge_method=squash -f sha="$head_sha" --silent
echo "merged $REPO#$PR"
fi

Requiring mergeable_state to be exactly clean is deliberate. It means every policy requirement is satisfied — reviews, checks, conversations, freshness — without your script having to reimplement the repository’s policy, and getting it subtly wrong.

On a repository with a merge queue, this endpoint enqueues rather than merging — the response reflects that, and a script asserting an immediate merge will be wrong.

Open pull requests grouped by why they are not merging:

Terminal window
gh api "repos/OWNER/REPO/pulls?state=open&per_page=100" --paginate --slurp \
--jq 'flatten | group_by(.mergeable_state)
| map({state: .[0].mergeable_state, count: length, numbers: [.[].number]})'
[
{ "count": 3, "numbers": [128, 131, 140], "state": "behind" },
{ "count": 2, "numbers": [122, 139], "state": "blocked" },
{ "count": 1, "numbers": [141], "state": "clean" }
]

That single view usually explains a slow-moving repository better than any dashboard: a pile of behind means the base moves faster than people update, and a pile of blocked means review is the bottleneck.

mergeable is computed asynchronously. A freshly created or freshly updated pull request returns null until GitHub finishes, and code that treats null as false will refuse perfectly mergeable changes.

Terminal window
wait_for_mergeable() {
local repo="$1" pr="$2" attempts="${3:-10}"
local state
for _ in $(seq 1 "$attempts"); do
state=$(gh api "repos/$repo/pulls/$pr" --jq '.mergeable')
if [ "$state" != "null" ]; then
echo "$state"
return 0
fi
sleep 2
done
echo "unknown"
return 1
}

Two seconds between attempts, ten attempts, is generous for the common case and bounded for the pathological one. Returning a distinguishable unknown rather than guessing means the caller decides what to do about it.

The most common legitimate use of pull request automation, and a good illustration of where automated approval is defensible.

#!/usr/bin/env bash
set -euo pipefail
REPO="${GH_REPO:?}"
DRY_RUN="${DRY_RUN:-true}"
# Only pull requests from the dependency bot, only patch-level, only passing.
gh pr list --repo "$REPO" --state open --author "app/dependabot" \
--json number,title,mergeable,mergeStateStatus,isDraft \
--jq '.[] | select(.isDraft | not)
| select(.mergeStateStatus == "CLEAN")
| select(.title | test("bump .* from [0-9]+\\.[0-9]+\\.[0-9]+ to [0-9]+\\.[0-9]+\\.[0-9]+"; "i"))
| [.number, .title] | @tsv' \
| while IFS=$'\t' read -r number title; do
if [ "$DRY_RUN" = "true" ]; then
printf 'would merge #%s — %s\n' "$number" "$title"
else
sha=$(gh pr view "$number" --repo "$REPO" --json headRefOid --jq .headRefOid)
gh api --method PUT "repos/$REPO/pulls/$number/merge" \
-f merge_method=squash -f sha="$sha" --silent
printf 'merged #%s\n' "$number"
fi
done

Four constraints make this defensible rather than reckless: it is restricted to a known bot author, to CLEAN merge state so every policy requirement is satisfied, to a title pattern matching patch-level bumps only, and it passes sha so a concurrent push cannot slip through.

Remove any one of those and it becomes a script that merges things nobody examined.

Understanding where review time goes is a few calls:

Terminal window
gh pr list --repo "$GH_REPO" --state merged --limit 100 \
--json number,reviews \
--jq '[.[] | .reviews[]? | .author.login] | group_by(.)
| map({reviewer: .[0], reviews: length}) | sort_by(-.reviews)'

Output:

[
{ "reviewer": "alice", "reviews": 47 },
{ "reviewer": "bob", "reviews": 31 },
{ "reviewer": "carol", "reviews": 8 }
]

A heavily skewed distribution is worth acting on. It usually means either that one person is the bottleneck for everything, or that CODEOWNERS routes everything to one team. Both are fixable, and neither is visible without asking the question.

Pair it with time-to-first-review from PR Best Practices — reviewer load and review latency together explain most of what makes a repository feel fast or slow.

Reporting a check result from your own system uses the commit status endpoint, which is how non-Actions CI integrates:

Terminal window
gh api --method POST "repos/OWNER/REPO/statuses/$SHA" \
-f state=pending \
-f context="acme/security-scan" \
-f description="Scan running" \
-f target_url="https://ci.example.com/build/1234"
gh api --method POST "repos/OWNER/REPO/statuses/$SHA" \
-f state=success \
-f context="acme/security-scan" \
-f description="No findings"

state is pending, success, failure or error. The context is the identifier that appears in the checks list and that branch protection matches against when you mark a check required — so it must be stable, and changing it silently makes the required check never report.

Posting pending when work starts matters more than it seems: without it, a pull request looks like it has no such check rather than one in progress, and people merge past it.

Merging without sha. Races with a concurrent push.

Treating mergeable: null as false. It means “computing”; poll briefly.

Reimplementing policy instead of checking mergeable_state. Subtly wrong, and drifts.

Automating approvals broadly. Defeats the requirement they satisfy.

Reading only check runs, or only statuses. Both exist.

Assuming the merge endpoint merges on a queue-enabled branch. It enqueues.

Trusting the files endpoint for a complete list on huge pull requests. It is capped.

A frequent pattern: a job makes a change and proposes it rather than pushing directly.

#!/usr/bin/env bash
set -euo pipefail
REPO="${GH_REPO:?}"
BRANCH="automated/dependency-refresh-$(date -u +%Y%m%d)"
BASE="${BASE:-main}"
# 1. Branch from the current base
base_sha=$(gh api "repos/$REPO/git/ref/heads/$BASE" --jq '.object.sha')
gh api --method POST "repos/$REPO/git/refs" \
-f ref="refs/heads/$BRANCH" -f sha="$base_sha" --silent
# 2. Update a file on the new branch
current=$(gh api "repos/$REPO/contents/requirements.txt?ref=$BRANCH")
gh api --method PUT "repos/$REPO/contents/requirements.txt" \
-f message="Refresh pinned dependencies" \
-f content="$(base64 -w0 < requirements.new.txt)" \
-f sha="$(jq -r '.sha' <<<"$current")" \
-f branch="$BRANCH" --silent
# 3. Propose it
number=$(gh api --method POST "repos/$REPO/pulls" \
-f title="Refresh pinned dependencies" \
-f head="$BRANCH" -f base="$BASE" \
-f body="Automated refresh. Please review the lock file diff." \
--jq '.number')
gh api --method POST "repos/$REPO/issues/$number/labels" \
-f "labels[]=dependencies" -f "labels[]=automated" --silent
echo "opened #$number"

Nothing here needs a working tree — the branch, the commit and the pull request are all created through the API. That makes it viable in environments where cloning is impractical.

The branch name includes a date so repeated runs do not collide, and labelling lets humans filter automated pull requests out of their own queries.

Automation opening pull requests raises the question of who reviews them, and the honest answer is that nobody wants to review a hundred dependency bumps.

Three approaches that work, in increasing order of trust:

Group them. One pull request refreshing all dependencies is one review, not forty. Fewer, larger pull requests is the wrong default for human work and the right one here, because the review is mechanical.

Auto-merge the safe subset. Patch-level updates that pass every check, merged automatically — with the constraints from earlier in this lesson. Minor and major versions still get a human.

Report rather than propose. For low-urgency changes, a weekly Issue listing what is out of date is less disruptive than a stream of pull requests, and it lets a human batch the work.

The failure mode to avoid is automation that opens more pull requests than the team can review. The queue grows, people stop reading it, and eventually a genuinely important update is merged unread alongside forty trivial ones.

Automation cannot resolve conflicts and should not try. What it can do is detect and report clearly.

Terminal window
state=$(gh pr view "$number" --repo "$REPO" --json mergeStateStatus --jq .mergeStateStatus)
case "$state" in
CLEAN)
gh pr merge "$number" --repo "$REPO" --squash --delete-branch
;;
DIRTY)
gh pr comment "$number" --repo "$REPO" \
--body "This has conflicts with \`$BASE\` and needs manual resolution."
gh pr edit "$number" --repo "$REPO" --add-label needs-attention
;;
BEHIND)
gh pr update-branch "$number" --repo "$REPO"
;;
BLOCKED|UNSTABLE)
echo "#$number is $state — leaving for a human" >&2
;;
*)
echo "#$number in unexpected state $state" >&2
;;
esac

Handling BEHIND by updating and everything else by reporting is the right division. BEHIND is mechanical; the others involve a judgement.

Note the explicit default case. Automation that silently ignores states it does not recognise will do nothing when GitHub adds a new one, and nothing is indistinguishable from working.

Automation that opens pull requests should close its own when they become irrelevant:

Terminal window
gh pr list --repo "$REPO" --state open --label automated \
--json number,createdAt,title \
--jq --arg cutoff "$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
'.[] | select(.createdAt < $cutoff) | [.number, .title] | @tsv' \
| while IFS=$'\t' read -r number title; do
gh pr comment "$number" --repo "$REPO" \
--body "Superseded — a newer refresh will be opened. Closing automatically."
gh pr close "$number" --repo "$REPO" --delete-branch
done

An automated pull request from six weeks ago is proposing a change against a base that has moved substantially. Closing it and opening a fresh one is cleaner than updating, and it keeps the review queue honest about what is actually current.

  1. Open a pull request via the API and read mergeable immediately — note whether it is null.
  2. Poll until it resolves, then read mergeable_state.
  3. List its changed files and compute additions and deletions.
  4. Fetch the same pull request with Accept: application/vnd.github.diff.
  5. Run the guarded merge script in dry-run.
  6. Change sha to a wrong value and confirm the merge is refused with 409.

Step 6 is the important one — seeing the guard work is what makes it feel worth including.

Automation frequently needs to reason about what changed, not just that something did.

Terminal window
# Which paths, and how much
gh api "repos/OWNER/REPO/pulls/128/files?per_page=100" --paginate \
--jq '.[] | {path: .filename, status, additions, deletions}'
# Does it touch anything sensitive?
gh api "repos/OWNER/REPO/pulls/128/files?per_page=100" --paginate --jq '.[].filename' \
| grep -qE '^(\.github/workflows/|src/auth/|infra/)' && echo "sensitive paths touched"
# Total size, for a policy on pull request size
gh api "repos/OWNER/REPO/pulls/128" --jq '.additions + .deletions'

The files endpoint paginates and is capped — very large pull requests do not return every file. Automation computing statistics should note that limit rather than reporting a confident wrong total:

Terminal window
changed=$(gh api "repos/OWNER/REPO/pulls/128" --jq '.changed_files')
listed=$(gh api "repos/OWNER/REPO/pulls/128/files?per_page=100" --paginate --slurp --jq 'flatten | length')
[ "$changed" -eq "$listed" ] || echo "warning: $listed of $changed files listed" >&2

The patch field on each file contains the diff hunks, which is enough to check for a specific added line — a debug statement, a hardcoded credential pattern, a forbidden import — without cloning anything.

A common and genuinely useful automation: apply labels based on what the pull request touches.

#!/usr/bin/env bash
set -euo pipefail
REPO="${GH_REPO:?}" PR="${PR:?}"
files=$(gh api "repos/$REPO/pulls/$PR/files?per_page=100" --paginate --jq '.[].filename')
labels=()
grep -qE '^docs/' <<<"$files" && labels+=("documentation")
grep -qE '^\.github/workflows/' <<<"$files" && labels+=("ci")
grep -qE '(package-lock\.json|requirements\.txt|go\.sum)$' <<<"$files" && labels+=("dependencies")
grep -qE '_test\.|test_|\.spec\.' <<<"$files" && labels+=("tests")
if [ "${#labels[@]}" -gt 0 ]; then
printf -v joined '%s,' "${labels[@]}"
gh pr edit "$PR" --repo "$REPO" --add-label "${joined%,}"
fi

Additive labelling is naturally idempotent — adding a label that is already present is a no-op — so this is safe to run on every synchronize event.

Keep the rules few and obvious. A labelling scheme with twenty rules produces pull requests carrying six labels, which conveys less than one well-chosen label would.

  • mergeable is Git’s answer and can be null while computing; mergeable_state is policy’s.
  • The Accept header switches responses between JSON, diff and patch.
  • Reviews, reviewers and checks are separate endpoints, and checks attach to commits.
  • Two check systems exist — check runs and commit statuses.
  • sha on the merge endpoint eliminates the race between validation and merge.
  • Requiring mergeable_state == "clean" delegates policy correctly rather than reimplementing it.
  • Merging is irreversible; dry-run everything.

Reading and reporting on pull requests is safe and useful. Merging is the operation that needs care, and two habits make it defensible.

Pass sha. It makes the merge conditional on the head commit still being the one you validated, which eliminates the race between checking and merging. Without it a concurrent push can slip through unexamined.

Require mergeable_state == "clean". That delegates policy to the repository rather than reimplementing it in your script, where it will drift and be subtly wrong.

Automating approval is a different question from automating merging, and it deserves an explicit decision rather than arriving by accident: a bot that approves satisfies a human-review requirement with no human, which means the requirement no longer means what your policy says.

Line comments form threads, and replying into an existing one is a different endpoint from starting a new comment — a distinction that matters for any bot that responds to review feedback.

Terminal window
# Start a new thread on a line
gh api --method POST "repos/OWNER/REPO/pulls/128/comments" \
-f body="This can spin unbounded." \
-f commit_id="$SHA" -f path="src/client.py" -F line=42 -f side=RIGHT
# Reply into an existing thread
gh api --method POST "repos/OWNER/REPO/pulls/128/comments/COMMENT_ID/replies" \
-f body="Fixed in a3f8c21 — the delay is now capped at MAX_BACKOFF."

Using the first form to respond creates a second thread on the same line, which is how review conversations end up fragmented into parallel discussions nobody can follow.

Finding the thread to reply to:

Terminal window
gh api "repos/OWNER/REPO/pulls/128/comments?per_page=100" --paginate \
--jq '.[] | select(.in_reply_to_id == null)
| [.id, .path, (.line // .original_line), .user.login] | @tsv'

in_reply_to_id == null selects the root comment of each thread, which is the ID the replies endpoint expects. Replies carry that field populated, and using a reply’s ID as the target is a common cause of a 404 that looks like a permissions problem.

Threads can also be resolved, which is GraphQL-only:

Terminal window
gh api graphql -f query='
mutation($id: ID!) {
resolveReviewThread(input: {threadId: $id}) { thread { isResolved } }
}' -F id="$THREAD_NODE_ID"

Automation that resolves threads should be used sparingly — resolving collapses a conversation, and a bot collapsing a human’s unanswered question is worse than leaving it open.

Check your understanding

4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

Why should a merge request via the API include the `sha` parameter?
Show answer

It makes the merge conditional on the head commit still being the one you validated, closing the race with a concurrent push — Without `sha`, a push that lands between your checks and your merge is merged unexamined. With it, GitHub refuses if the head moved.

The pull request's `mergeable` field is `null`. What does that mean?
Show answer

GitHub is still computing it; poll briefly — `null` is "computing", not "false". Treating it as false produces spurious failures on freshly updated pull requests.

What is the recommended way for a script to decide whether a pull request is ready to merge?
Show answer

Require `mergeable_state == "clean"`, delegating policy to the repository — `mergeable_state` reflects the repository's own rules — reviews, checks, conflicts. Reimplementing them is subtly wrong and drifts as settings change.

Which is true about CI results attached to a pull request?
Show answer

Two systems exist — check runs and commit statuses — and both attach to commits, not to the pull request — Checks and statuses are separate APIs and both are keyed by commit SHA. A script that reads only one can miss a failing result.

Professional ToolkitThe pull request triage script — needs-review, re-review after changes, stale — is in the Professional Toolkit.