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.
Listing and reading
Section titled “Listing and reading”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.
Changed files and diffs
Section titled “Changed files and diffs”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.
Creating and updating
Section titled “Creating and updating”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:branchgh api --method POST repos/OWNER/REPO/pulls \ -f title="Fix from a fork" -f head="contributor:their-branch" -f base=mainMarking a draft ready is a GraphQL mutation rather than a REST call — one of the places where the two APIs differ in coverage:
gh api graphql -f query=' mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { pullRequest { number isDraft } }}' -F id="$PR_NODE_ID"Reviewers and reviews
Section titled “Reviewers and reviews”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.
Checks
Section titled “Checks”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.
Merging safely
Section titled “Merging safely”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 bashset -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"fiRequiring 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.
A useful report
Section titled “A useful report”Open pull requests grouped by why they are not merging:
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.
Waiting for mergeability
Section titled “Waiting for mergeability”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.
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.
Automating dependency updates
Section titled “Automating dependency updates”The most common legitimate use of pull request automation, and a good illustration of where automated approval is defensible.
#!/usr/bin/env bashset -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 doneFour 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.
Reporting on review load
Section titled “Reporting on review load”Understanding where review time goes is a few calls:
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.
Checks and commit status
Section titled “Checks and commit status”Reporting a check result from your own system uses the commit status endpoint, which is how non-Actions CI integrates:
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.
Common mistakes
Section titled “Common mistakes”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.
Creating a pull request from automation
Section titled “Creating a pull request from automation”A frequent pattern: a job makes a change and proposes it rather than pushing directly.
#!/usr/bin/env bashset -euo pipefail
REPO="${GH_REPO:?}"BRANCH="automated/dependency-refresh-$(date -u +%Y%m%d)"BASE="${BASE:-main}"
# 1. Branch from the current basebase_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 branchcurrent=$(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 itnumber=$(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.
Reviewing automated changes
Section titled “Reviewing automated changes”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.
Handling merge conflicts in automation
Section titled “Handling merge conflicts in automation”Automation cannot resolve conflicts and should not try. What it can do is detect and report clearly.
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 ;;esacHandling 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.
Closing stale automated pull requests
Section titled “Closing stale automated pull requests”Automation that opens pull requests should close its own when they become irrelevant:
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 doneAn 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.
Exercise
Section titled “Exercise”- Open a pull request via the API and read
mergeableimmediately — note whether it isnull. - Poll until it resolves, then read
mergeable_state. - List its changed files and compute additions and deletions.
- Fetch the same pull request with
Accept: application/vnd.github.diff. - Run the guarded merge script in dry-run.
- Change
shato 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.
Reading a diff programmatically
Section titled “Reading a diff programmatically”Automation frequently needs to reason about what changed, not just that something did.
# Which paths, and how muchgh 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 sizegh 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:
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" >&2The 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.
Labelling by content
Section titled “Labelling by content”A common and genuinely useful automation: apply labels based on what the pull request touches.
#!/usr/bin/env bashset -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%,}"fiAdditive 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.
What you learned
Section titled “What you learned”mergeableis Git’s answer and can benullwhile computing;mergeable_stateis policy’s.- The
Acceptheader 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.
shaon 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.
The short version
Section titled “The short version”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.
Replying in a review thread
Section titled “Replying in a review thread”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.
# Start a new thread on a linegh 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 threadgh 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:
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:
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.
Related lessons
Section titled “Related lessons”Check your understanding
4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.