Skip to content

Issue Automation with the GitHub API

Lesson 9 of 10Intermediate11 min readGitHub Engineering · GitHub APIVerified: GitHub REST API version 2022-11-28 via gh 2.98.0, August 2026

Issue automation is the most common first API project, because triage is repetitive, rule-based, and genuinely improved by consistency.

It also contains the single most reliable way to produce wrong numbers: the Issues endpoint returns pull requests too.

GitHub models a pull request as an Issue with extra fields. /repos/OWNER/REPO/issues therefore returns both, and objects that are pull requests carry a pull_request key.

Terminal window
gh api "repos/OWNER/REPO/issues?state=open&per_page=100" --paginate \
--jq '.[] | select(has("pull_request") | not) | [.number, .title] | @tsv'

What it doesLists open Issues, excluding pull requests, across all pages.

Why we run itWithout the filter, the count includes every open pull request. Reports built on the unfiltered endpoint overstate outstanding work, often substantially.

Expected resultOnly genuine Issues.

The repository object’s open_issues_count has the same behaviour — it counts pull requests. If a dashboard’s Issue count looks too high, this is almost always why.

Terminal window
gh api --method POST repos/OWNER/REPO/issues \
-f title="Retry logic drops the final attempt" \
-f body="Full reproduction steps." \
-f "labels[]=bug" -f "labels[]=p1" \
-f "assignees[]=alice"

For a body with real Markdown, read it from a file rather than fighting shell quoting:

Terminal window
gh api --method POST repos/OWNER/REPO/issues \
-f title="Nightly audit failed" \
-F body=@report.md

-F body=@file reads the value from a file; @- reads from standard input. That is the -F capability worth remembering beyond typed booleans.

Terminal window
gh api --method PATCH repos/OWNER/REPO/issues/ISSUE_NUMBER \
-f state=closed -f state_reason=completed
gh api --method POST repos/OWNER/REPO/issues/ISSUE_NUMBER/labels \
-f "labels[]=needs-repro"
gh api --method DELETE repos/OWNER/REPO/issues/ISSUE_NUMBER/labels/needs-repro
gh api --method POST repos/OWNER/REPO/issues/ISSUE_NUMBER/comments \
-f body="Closing as we cannot reproduce. Please reopen with a minimal example."

state_reason accepts completed or not_planned, and it is worth setting. Reporting that treats both as “closed” overstates how much was actually fixed.

Note that adding labels is a POST that appends, while PATCH on the Issue with a labels array replaces the whole set. Choosing the wrong one silently removes labels.

The search API is better than listing when you need cross-repository or complex criteria:

Terminal window
gh api "search/issues?q=$(printf '%s' 'is:issue is:open no:assignee label:bug org:ORG' | jq -sRr @uri)" \
--jq '.items[] | [.repository_url, .number, .title] | @tsv'

Two caveats. Search has its own, stricter rate limit than the core API — a handful of requests per minute rather than thousands per hour. And results are capped at a maximum total, so it is unsuitable for exhaustive enumeration; use it to find things, and the list endpoints to enumerate them.

#!/usr/bin/env bash
set -euo pipefail
REPO="${REPO:?}"
DRY_RUN="${DRY_RUN:-true}"
CUTOFF=$(date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ)
gh api "repos/$REPO/issues?state=open&per_page=100" --paginate \
--jq --arg cutoff "$CUTOFF" \
'.[] | select(has("pull_request") | not)
| select(.updated_at < $cutoff)
| select([.labels[].name] | index("stale") | not)
| .number' \
| while read -r number; do
if [ "$DRY_RUN" = "true" ]; then
printf 'would label #%s stale\n' "$number"
else
gh api --method POST "repos/$REPO/issues/$number/labels" -f "labels[]=stale" --silent
printf 'labelled #%s\n' "$number"
fi
done

Three properties make it safe: pull requests are excluded, already-labelled Issues are skipped so it is idempotent, and it defaults to dry-run.

Terminal window
gh api "repos/OWNER/REPO/issues?state=open&per_page=100" --paginate --slurp \
--jq 'flatten
| map(select(has("pull_request") | not))
| map(.labels[].name)
| group_by(.)
| map({label: .[0], count: length})
| sort_by(-.count)'
[
{ "count": 41, "label": "bug" },
{ "count": 23, "label": "enhancement" },
{ "count": 12, "label": "needs-repro" }
]

Automation that files Issues will eventually run twice.

Terminal window
title="Nightly audit failed"
existing=$(gh api "repos/$REPO/issues?state=open&per_page=100" --paginate \
--jq --arg t "$title" '.[] | select(has("pull_request") | not)
| select(.title == $t) | .number' | head -1)
if [ -n "$existing" ]; then
gh api --method POST "repos/$REPO/issues/$existing/comments" \
-f body="Failed again at $(date -u +%FT%TZ)." --silent
else
gh api --method POST "repos/$REPO/issues" -f title="$title" -F body=@report.md --silent
fi

Commenting on the existing Issue rather than filing a duplicate is almost always the better behaviour — it keeps the history in one place and does not bury the tracker.

Issue automation hits secondary limits before primary ones. Content-creating requests are capped at roughly 80 per minute and 500 per hour, well below the 5,000-per-hour core limit.

A loop creating or commenting on hundreds of Issues must pace itself:

Terminal window
gh api --method POST "repos/$REPO/issues/$n/comments" -f body="$msg" --silent
sleep 1

A one-second pause is crude and sufficient. Being rate-limited mid-sweep leaves the job half done, which for a triage script means an inconsistent state you then have to reason about.

If you need Issues with their comments, labels and assignees, REST is N+1. One GraphQL query replaces it:

Terminal window
gh api graphql -f query='
query($owner: String!, $repo: String!, $endCursor: String) {
repository(owner: $owner, name: $repo) {
issues(first: 50, after: $endCursor, states: OPEN) {
pageInfo { hasNextPage endCursor }
nodes {
number title
labels(first: 10) { nodes { name } }
assignees(first: 5) { nodes { login } }
comments { totalCount }
}
}
}
}' -F owner=OWNER -F repo=REPO --paginate

GraphQL’s issues connection also excludes pull requests by default, which removes the filtering problem entirely — a genuine advantage for reporting.

Anything touching many Issues needs more care than a single call, because there is no undo and each change is a separate audit entry.

#!/usr/bin/env bash
set -euo pipefail
REPO="${REPO:?set REPO=OWNER/NAME}"
LABEL="${LABEL:?set LABEL}"
DRY_RUN="${DRY_RUN:-true}"
QUERY="${QUERY:-is:issue is:open no:assignee}"
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; }
# 1. Build the list first, and show it.
mapfile -t numbers < <(
gh issue list --repo "$REPO" --search "$QUERY" --limit 200 --json number --jq '.[].number'
)
log "${#numbers[@]} issue(s) matched: $QUERY"
[ "${#numbers[@]}" -gt 0 ] || exit 0
# 2. Refuse implausibly large matches — usually a filter mistake.
if [ "${#numbers[@]}" -gt 100 ] && [ "${ALLOW_LARGE:-false}" != "true" ]; then
log "refusing to modify ${#numbers[@]} issues; set ALLOW_LARGE=true if intended"
exit 1
fi
# 3. Act, pacing for secondary limits.
for n in "${numbers[@]}"; do
if [ "$DRY_RUN" = "true" ]; then
printf 'would label #%s with %s\n' "$n" "$LABEL"
else
gh api --method POST "repos/$REPO/issues/$n/labels" -f "labels[]=$LABEL" --silent
log "labelled #$n"
sleep 1
fi
done

The sanity check in step 2 is the part worth copying. A filter matching four hundred Issues when you expected twelve is nearly always a query mistake, and catching it before the loop beats noticing partway through.

Raw counts are less useful than distributions and trends.

Age distribution of open Issues:

Terminal window
gh api "repos/OWNER/REPO/issues?state=open&per_page=100" --paginate --slurp \
--jq 'flatten | map(select(has("pull_request") | not))
| map(((now - (.created_at | fromdateiso8601)) / 86400) | floor)
| {under_7: (map(select(. < 7)) | length),
under_30: (map(select(. >= 7 and . < 30)) | length),
under_90: (map(select(. >= 30 and . < 90)) | length),
over_90: (map(select(. >= 90)) | length)}'

Output:

{ "over_90": 87, "under_30": 14, "under_7": 9, "under_90": 22 }

A long tail past ninety days is normal and worth being honest about: those are genuinely hard, genuinely not planned, or forgotten. Closing the middle category as not_planned is what keeps the remaining count meaningful.

Label distribution, which shows where the work actually is:

Terminal window
gh api "repos/OWNER/REPO/issues?state=open&per_page=100" --paginate --slurp \
--jq 'flatten | map(select(has("pull_request") | not))
| map(.labels[].name) | group_by(.)
| map({label: .[0], count: length}) | sort_by(-.count)'

The scripts here poll. For anything that should happen when something occurs, a webhook is the better mechanism.

Good candidates for event-driven Issue automation:

  • Apply a label based on the reporter when an Issue is opened
  • Post a checklist comment when a particular label is added
  • Notify a channel when something is labelled p0

Better as scheduled sweeps:

  • Stale detection, which is inherently time-based
  • Weekly reporting
  • Reconciliation after an outage of your own handler

The distinction is whether the trigger is an event or the passage of time. Polling for events and scheduling for time-based work is the right pairing; the reverse produces both rate-limit pressure and slow reactions.

Forgetting pull requests appear in the Issues endpoint. Inflated counts.

Using PATCH with labels when you meant to append. Replaces the whole set.

Ignoring state_reason. Loses the completed/not-planned distinction.

Using search for exhaustive enumeration. Capped, and stricter limits.

No deduplication in Issue-creating automation. Duplicates on the second run.

Ignoring secondary limits. Half-finished sweeps.

Closing stale Issues automatically. Hides work rather than resolving it.

A common integration: something outside GitHub detects a problem and files an Issue. The mechanics are simple; the discipline is in not producing noise.

#!/usr/bin/env bash
set -euo pipefail
REPO="${REPO:?}"
FINGERPRINT="${FINGERPRINT:?}" # a stable identifier for this class of problem
TITLE="${TITLE:?}"
BODY_FILE="${BODY_FILE:?}"
# Find an existing open issue for this fingerprint, recorded in the body.
existing=$(gh api "repos/$REPO/issues?state=open&per_page=100" --paginate \
--jq --arg fp "$FINGERPRINT" \
'.[] | select(has("pull_request") | not)
| select(.body // "" | contains("fingerprint: " + $fp))
| .number' | head -1)
if [ -n "$existing" ]; then
gh api --method POST "repos/$REPO/issues/$existing/comments" \
-F body=@"$BODY_FILE" --silent
echo "commented on existing #$existing"
else
printf '\n\n<!-- fingerprint: %s -->\n' "$FINGERPRINT" >> "$BODY_FILE"
gh api --method POST "repos/$REPO/issues" \
-f title="$TITLE" -F body=@"$BODY_FILE" -f "labels[]=automated" --silent
echo "created a new issue"
fi

The fingerprint in an HTML comment is the key idea. It is invisible when rendered, stable across occurrences, and searchable — which gives you deduplication without a database.

Two further habits keep automated Issues welcome rather than resented. Label them so humans can filter them out of their own queries. And make the body actionable: what failed, when, a link to the evidence, and what a human should do. An automated Issue saying “check failed” wastes the time it was meant to save.

Automation that opens Issues should close them. Otherwise the tracker fills with resolved problems that nobody knows are resolved.

Terminal window
# The condition cleared — close anything matching this fingerprint
gh api "repos/$REPO/issues?state=open&per_page=100&labels=automated" --paginate \
--jq --arg fp "$FINGERPRINT" \
'.[] | select(.body // "" | contains("fingerprint: " + $fp)) | .number' \
| while read -r n; do
gh api --method POST "repos/$REPO/issues/$n/comments" \
-f body="Condition cleared at $(date -u +%FT%TZ). Closing automatically." --silent
gh api --method PATCH "repos/$REPO/issues/$n" \
-f state=closed -f state_reason=completed --silent
done

Commenting before closing matters: an Issue that closes itself with no explanation is confusing for anyone who was following it.

Transfer works within GitHub and preserves everything. Moving Issues from another system means recreating them, and the fidelity you can achieve is limited in ways worth knowing before you start.

Terminal window
gh api --method POST "repos/$REPO/issues" \
-f title="$title" \
-F body=@- <<BODY
$original_body
---
*Migrated from LEGACY-1234, originally reported by $original_author on $original_date.*
BODY

What you cannot preserve through the API: the original author (the Issue is authored by whoever holds the token), the original creation date, and reaction history. Recording the provenance in the body, as above, is the honest workaround — it makes the migration visible rather than pretending the Issue was always here.

What you can preserve: title, body, labels, assignees, milestone, state, and comments — added individually after creation, each with their own provenance footer.

Rate limits are the binding constraint on any sizeable migration. Content creation is capped at roughly 80 per minute and 500 per hour, so a thousand Issues with five comments each is six thousand creations — several hours of paced work, not a single run. Plan for resumability, and record what has already been migrated so a restart does not duplicate.

  1. Count open Issues with and without the pull_request filter and compare.
  2. Compare both against open_issues_count on the repository object.
  3. Create an Issue with -F body=@file and confirm the Markdown survived.
  4. Append a label with POST, then replace all labels with PATCH, and observe the difference.
  5. Run the stale-labelling script in dry-run.
  6. Run the deduplication snippet twice and confirm the second run comments rather than duplicating.

Steps 1 and 2 together usually produce a genuinely surprising number on any active repository.

GitHub has added structure beyond labels and milestones — issue types, and parent/child relationships between Issues. These are newer than most of the API surface and worth checking availability for before building on them.

Where available, the hierarchy is queried through GraphQL rather than REST:

Terminal window
gh api graphql -f query='
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
title
subIssues(first: 50) {
totalCount
nodes { number title state }
}
}
}
}' -F owner=OWNER -F repo=REPO -F number=42

The practical value is progress reporting: a parent Issue with eight children, five closed, gives a completion figure without a project board.

Introspecting before writing against them is the safe approach:

Terminal window
gh api graphql -f query='
query { __type(name: "Issue") { fields { name } } }' \
--jq '.data.__type.fields[].name' | grep -i 'sub\|parent\|type'

If the field is not listed, it is not available to your account — which is a faster answer than reading a documentation page that may describe a plan you are not on.

Issue automation is the category most likely to hit secondary limits rather than the primary one, because creating and commenting are content-generating requests.

The documented ceilings are roughly 80 content-creating requests per minute and 500 per hour — against a primary limit of 5,000 requests per hour. A loop creating Issues will therefore stop at 500 in an hour while its primary budget is barely touched, and the failure looks like an unexplained 403.

Three consequences for design:

Pace creation. One second between content-creating calls keeps you comfortably inside the per-minute ceiling.

Batch differently. Reading is cheap; writing is not. A sweep that reads 500 Issues and writes 20 is fine. One that writes 500 is an hour of work regardless of how fast you can read.

Make long jobs resumable. A migration or a large relabelling will span the hourly ceiling. Record what has been done so a restart continues rather than duplicating — the fingerprint approach above works for this too.

Terminal window
remaining=$(gh api rate_limit --jq '.resources.core.remaining')
log "primary budget: $remaining"
# There is no header reporting secondary limit state — pacing is the only control.

That last point is worth knowing: secondary limits are not exposed in a header you can check. You cannot measure your way to safety, only pace.

  • The REST Issues endpoint includes pull requests; open_issues_count does too.
  • POST .../labels appends; PATCH with a labels array replaces.
  • -F body=@file reads a value from a file, avoiding shell-quoting damage.
  • Search has stricter limits and a result cap; use it to find, not to enumerate.
  • Content-creating requests hit secondary limits long before the core limit.
  • GraphQL’s issues connection excludes pull requests and avoids N+1 for related data.
  • Automation that files Issues must deduplicate, because it will run twice.

Before scheduling anything that writes to an Issue tracker:

  1. Filter out pull requests. The REST Issues endpoint returns both. Every query in your script needs select(has("pull_request") | not).
  2. Paginate. Without --paginate you are acting on the first thirty of however many exist.
  3. Deduplicate. The script will run twice. A fingerprint in the body, or a check for an existing match, makes the second run harmless.
  4. Pace writes. Content creation hits secondary limits at roughly 80 per minute — far below the primary budget, and not reported in any header you can check.
  5. Dry-run by default. DRY_RUN=${DRY_RUN:-true} so the destructive mode is explicit.
  6. Sanity-check the match count. Refuse to proceed if the filter matched far more than expected.
  7. Label what you create. So humans can exclude automated Issues from their own queries.
  8. Close what you opened. Automation that only creates fills the tracker with resolved problems.

Items 1 and 3 account for most of the bugs in real Issue automation. The first produces wrong numbers that look plausible; the second produces duplicates that look like a person made a mistake.

Issue state is a snapshot. The events endpoint is the history — every label added, assignee changed, milestone set and state transition, with who did it and when.

Terminal window
gh api "repos/OWNER/REPO/issues/42/events?per_page=100" --paginate \
--jq '.[] | [.created_at[0:19], .event, (.actor.login // "-"), (.label.name // .milestone.title // "")] | @tsv'
2026-06-02T10:11:04 labeled alice bug
2026-06-02T10:11:09 labeled alice p1
2026-06-04T14:22:31 assigned alice
2026-07-19T08:03:12 unlabeled bob p1
2026-08-11T16:40:55 closed bob

Two things this makes possible that the Issue object alone cannot.

Time-in-state metrics. How long between labeled bug and closed — the number people usually mean by “time to fix”, and which the created_at-to-closed_at difference overstates because it includes triage.

Auditing automation. When a labelling script misbehaves, the events show exactly what it did and when, which is the difference between reconstructing the bug and guessing at it.

Repository-wide, the same data is available in one stream:

Terminal window
gh api "repos/OWNER/REPO/issues/events?per_page=100" --paginate \
--jq '.[] | select(.event == "labeled") | [.created_at[0:10], .label.name] | @tsv' \
| awk '{c[$2]++} END {for (l in c) printf "%5d %s\n", c[l], l}' | sort -rn

That counts label applications over the retained history — a better measure of what a repository actually deals with than a snapshot of currently-open Issues, which is biased towards whatever has not been fixed.

Professional ToolkitThe fine-grained token permissions matrix, App-vs-PAT guide and gh api recipes are in the Professional Toolkit.