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.
The pull request overlap
Section titled “The pull request overlap”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.
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.
Creating
Section titled “Creating”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:
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.
Updating
Section titled “Updating”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.
Searching
Section titled “Searching”The search API is better than listing when you need cross-repository or complex criteria:
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.
Triage patterns
Section titled “Triage patterns”Label stale Issues
Section titled “Label stale Issues”#!/usr/bin/env bashset -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 doneThree properties make it safe: pull requests are excluded, already-labelled Issues are skipped so it is idempotent, and it defaults to dry-run.
Report by label
Section titled “Report by label”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" }]Deduplicate before creating
Section titled “Deduplicate before creating”Automation that files Issues will eventually run twice.
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)." --silentelse gh api --method POST "repos/$REPO/issues" -f title="$title" -F body=@report.md --silentfiCommenting 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.
Rate limits
Section titled “Rate limits”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:
gh api --method POST "repos/$REPO/issues/$n/comments" -f body="$msg" --silentsleep 1A 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.
GraphQL for related data
Section titled “GraphQL for related data”If you need Issues with their comments, labels and assignees, REST is N+1. One GraphQL query replaces it:
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 --paginateGraphQL’s issues connection also excludes pull requests by default, which removes the filtering
problem entirely — a genuine advantage for reporting.
Bulk operations, safely
Section titled “Bulk operations, safely”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 bashset -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 1fi
# 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 fidoneThe 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.
Reporting that people read
Section titled “Reporting that people read”Raw counts are less useful than distributions and trends.
Age distribution of open Issues:
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:
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)'Reacting to events rather than sweeping
Section titled “Reacting to events rather than sweeping”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.
Common mistakes
Section titled “Common mistakes”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.
Creating Issues from external systems
Section titled “Creating Issues from external systems”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 bashset -euo pipefail
REPO="${REPO:?}"FINGERPRINT="${FINGERPRINT:?}" # a stable identifier for this class of problemTITLE="${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"fiThe 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.
Closing automated Issues
Section titled “Closing automated Issues”Automation that opens Issues should close them. Otherwise the tracker fills with resolved problems that nobody knows are resolved.
# The condition cleared — close anything matching this fingerprintgh 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 doneCommenting before closing matters: an Issue that closes itself with no explanation is confusing for anyone who was following it.
Migrating Issues between repositories
Section titled “Migrating Issues between repositories”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.
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.*BODYWhat 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.
Exercise
Section titled “Exercise”- Count open Issues with and without the
pull_requestfilter and compare. - Compare both against
open_issues_counton the repository object. - Create an Issue with
-F body=@fileand confirm the Markdown survived. - Append a label with
POST, then replace all labels withPATCH, and observe the difference. - Run the stale-labelling script in dry-run.
- 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.
Sub-issues and relationships
Section titled “Sub-issues and relationships”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:
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=42The 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:
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.
Rate limits, specifically for Issue work
Section titled “Rate limits, specifically for Issue work”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.
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.
What you learned
Section titled “What you learned”- The REST Issues endpoint includes pull requests;
open_issues_countdoes too. POST .../labelsappends;PATCHwith a labels array replaces.-F body=@filereads 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.
A checklist for Issue automation
Section titled “A checklist for Issue automation”Before scheduling anything that writes to an Issue tracker:
- Filter out pull requests. The REST Issues endpoint returns both. Every query in your script
needs
select(has("pull_request") | not). - Paginate. Without
--paginateyou are acting on the first thirty of however many exist. - Deduplicate. The script will run twice. A fingerprint in the body, or a check for an existing match, makes the second run harmless.
- 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.
- Dry-run by default.
DRY_RUN=${DRY_RUN:-true}so the destructive mode is explicit. - Sanity-check the match count. Refuse to proceed if the filter matched far more than expected.
- Label what you create. So humans can exclude automated Issues from their own queries.
- 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.
The events API
Section titled “The events API”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.
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 bug2026-06-02T10:11:09 labeled alice p12026-06-04T14:22:31 assigned alice2026-07-19T08:03:12 unlabeled bob p12026-08-11T16:40:55 closed bobTwo 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:
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 -rnThat 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.