GitHub API Automation skill for Claude Code
Bulk GitHub changes with dry runs and throttling. Claude scripts GitHub the careful way: it picks the least-privileged credential (GITHUB_TOKEN, fine-grained PAT, or GitHub App), prototypes with gh api, paginates every list, throttles writes under primary and secondary limits, and runs a dry run you approve before mutating anything. App webhooks are verified against the raw body and deduped by delivery ID.
- name
github-api-automation - size215 lines · ≈2,600 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Automates GitHub with the REST and GraphQL APIs through Octokit and gh CLI scripting, choosing between GITHUB_TOKEN, fine-grained tokens, and GitHub Apps, handling pagination, rate limits, and secondary limits, and receiving webhooks with X-Hub-Signature-256 verification. Use when scripting bulk issue, PR, label, or repo changes, building a GitHub App or bot, querying GitHub data with GraphQL, writing gh api one-liners, or debugging 403 rate limit and webhook signature failures. Try asking
- Write a script that closes needs-info issues with no activity in 30 days across our org
- Build a GitHub App that labels new issues and comments on first-time contributors' PRs
- Give me a gh api command listing open PRs waiting on review with their authors
Use it when
- Scripting bulk changes to issues, PRs, labels, or repositories
- Building a GitHub App, bot, or webhook receiver
- Querying GitHub data that needs pagination or GraphQL
- Hitting 403 rate limit or webhook signature errors
Skip it when
- You are writing CI/CD workflow YAML — use github-actions instead
- You need provider-agnostic webhook design and replay protection — use webhook-handling instead
How to check the result
The dry run lists exactly the intended objects, a re-query after the run shows the expected end state, and the webhook endpoint rejects a payload signed with the wrong secret.
The common mistake it prevents
Forgetting pagination, so the script only touches the first 30 results and reports success.
Done well means: the automation uses the least-privileged credential that works, every list call paginates, bulk writes are throttled and dry-run first, webhooks are verified against the raw body and deduped by delivery ID, and nothing destructive runs without confirmation.
Ground rules
- Tokens come from env or the runner (
GITHUB_TOKEN,GH_TOKEN, App private key from a secret store). Never paste tokens into scripts, URLs, or logs. - Default to a dry-run flag on any script that writes. Print what would change, get the user’s confirmation, then run for real. Deleting repos, branches, releases, or closing issues in bulk always requires explicit confirmation.
- Paginate every list endpoint. Default page size is 30; a script without pagination silently processes the first page only.
- Prefer GraphQL when one screen of data needs many REST calls (nested PRs, reviews, checks); prefer REST for writes and simple lists.
Choose the credential
| Situation | Credential | Why |
|---|---|---|
| Inside a GitHub Actions workflow on the same repo | GITHUB_TOKEN with explicit permissions: | Auto-expires; scoped to the repo; no secret management |
| One-off personal script | Fine-grained PAT limited to named repos and permissions, short expiry | Classic PATs grant broad scopes across all repos |
| Org-wide bot, multi-repo automation, webhooks, service | GitHub App (installation tokens) | Not tied to a person; per-install permissions; higher rate limits; 1-hour tokens |
| Web app acting as the signed-in user | GitHub App user-to-server token (OAuth flow) | Respects both user and app permissions |
| Interactive terminal work | gh auth login, then gh / gh api | Token in OS keychain |
Events from GITHUB_TOKEN do not trigger new workflow runs (except workflow_dispatch and
repository_dispatch). If automation must trigger workflows, use a GitHub App token.
Workflow
- [ ] 1. State the goal as API objects (repos, issues, PRs, checks) and read/write needs
- [ ] 2. Pick credential and minimum permissions (table above)
- [ ] 3. Prototype the query with gh api; inspect real payloads
- [ ] 4. Write the script/app with Octokit: pagination, throttling, dry run
- [ ] 5. For apps: webhook endpoint with signature verification and delivery dedupe
- [ ] 6. Run read-only / dry run; review the planned changes with the user
- [ ] 7. Execute; log every mutation with object URL; verify end state
3. Prototype with gh
gh auth status
gh api rate_limit --jq '.resources | {core: .core.remaining, graphql: .graphql.remaining, search: .search.remaining}'
# REST with pagination; --slurp merges pages into one array (recent gh versions)
gh api --paginate "repos/OWNER/REPO/issues?state=open&per_page=100" --jq '.[] | select(.pull_request | not) | [.number, .title] | @tsv'
# Typed fields: -f sends strings, -F converts numbers/booleans/null and reads @file
gh api -X PATCH repos/OWNER/REPO/issues/123 -f state=closed -f state_reason=not_planned
# GraphQL with variables
gh api graphql -F owner=OWNER -F name=REPO -f query='
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
pullRequests(states: OPEN, first: 50) { nodes { number title reviewDecision author { login } } }
}
}' --jq '.data.repository.pullRequests.nodes[]'
# High-level commands with JSON output
gh pr list --state open --json number,title,author,updatedAt --jq '.[] | select(.updatedAt < "2026-01-01")'
gh issue list --label bug --limit 500 --json number,title
gh run list --workflow ci.yml --status failure --limit 20
4. Octokit script (Node, octokit package, ESM)
The octokit package bundles REST, GraphQL, pagination, retry, and throttling plugins.
import { Octokit } from "octokit";
const DRY_RUN = process.env.DRY_RUN !== "false";
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
throttle: {
onRateLimit: (retryAfter, options, _o, retryCount) => retryCount < 2, // retry twice
onSecondaryRateLimit: (retryAfter, options) => { console.warn(`secondary limit ${options.method} ${options.url}`); return true; },
},
});
const [owner, repo] = process.argv.slice(2);
const stale = await octokit.paginate(octokit.rest.issues.listForRepo, {
owner, repo, state: "open", labels: "needs-info", per_page: 100,
}, (res) => res.data.filter(i => !i.pull_request && Date.parse(i.updated_at) < Date.now() - 30 * 864e5));
for (const issue of stale) {
console.log(`${DRY_RUN ? "[dry-run] " : ""}close #${issue.number} ${issue.html_url}`);
if (DRY_RUN) continue;
await octokit.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: "Closing: no response in 30 days." });
await octokit.rest.issues.update({ owner, repo, issue_number: issue.number, state: "closed", state_reason: "not_planned" });
}
console.log(`${stale.length} issues ${DRY_RUN ? "would be" : ""} closed`);
For large result sets use octokit.paginate.iterator(...) to stream pages instead of
holding everything in memory. GraphQL cursors: octokit.graphql.paginate(query, vars) with
pageInfo { hasNextPage endCursor } and a $cursor: String variable.
Python: PyGithub or plain httpx with Authorization: Bearer, following the Link
header for pagination.
5. GitHub App with webhooks
import { App, createNodeMiddleware } from "octokit";
import { createServer } from "node:http";
const app = new App({
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY!, // PEM from secret store
webhooks: { secret: process.env.GITHUB_WEBHOOK_SECRET! },
});
app.webhooks.on("issues.opened", async ({ id, octokit, payload }) => {
if (await seen(id)) return; // id = X-GitHub-Delivery
await octokit.rest.issues.addLabels({
owner: payload.repository.owner.login, repo: payload.repository.name,
issue_number: payload.issue.number, labels: ["triage"],
});
});
app.webhooks.onError((e) => console.error(e));
createServer(createNodeMiddleware(app)).listen(3000); // serves POST /api/github/webhooks
createNodeMiddleware verifies X-Hub-Signature-256 before dispatching. For a custom
framework, verify yourself with @octokit/webhooks
(await webhooks.verifyAndReceive({ id, name, signature, payload: rawBody })) or HMAC-SHA256
of the raw body with the webhook secret, compared in constant time to the header after
sha256=.
Webhook rules:
- Respond within 10 seconds; enqueue slow work.
- GitHub does not automatically retry failed deliveries. Monitor failures and redeliver from the App settings “Recent deliveries” page or the deliveries REST API.
- Dedupe on
X-GitHub-Delivery. Ignore events from your own app (payload.sender.type === "Bot"and your app’s login) to avoid loops. - Subscribe only to needed events; permissions must match (e.g.
issues: writefor labels).
Test signatures locally with the bundled script:
export GITHUB_WEBHOOK_SECRET=... # same secret as the App
python3 scripts/github_webhook_sig.py sign payload.json # prints sha256=... header
python3 scripts/github_webhook_sig.py verify payload.json "sha256=abc..." # exit 0 if valid
Combine with curl to replay a captured delivery:
curl -X POST localhost:3000/api/github/webhooks -H "X-GitHub-Event: issues" -H "X-GitHub-Delivery: test-1" -H "X-Hub-Signature-256: $(python3 scripts/github_webhook_sig.py sign payload.json)" -H "Content-Type: application/json" --data-binary @payload.json.
Rate limits
| Limit | Value (2025–2026 docs) |
|---|---|
| REST, PAT/OAuth user | 5,000 requests/hour |
REST, GITHUB_TOKEN | 1,000 requests/hour per repo (higher on Enterprise Cloud) |
| REST, App installation | 5,000/hour, scaling with repos/users up to 12,500 (15,000 on Enterprise Cloud) |
| GraphQL | 5,000 points/hour per user/installation; cost depends on first: sizes |
| Search API | 30 requests/minute authenticated |
| Secondary | No more than 100 concurrent requests; about 900 REST points/minute; content-creating requests about 80/minute and 500/hour |
Practices:
- Run writes serially (or with small concurrency) with a pause between content-creating
calls; the throttling plugin handles 403/429
retry-afterbut cannot undo a burst. - Read
x-ratelimit-remaining/x-ratelimit-reset; stop early instead of hammering. - Use conditional requests (
If-None-Matchwith the ETag) for polling; 304 responses do not count against the primary limit. Better: replace polling with webhooks. - Request smaller GraphQL pages if you hit
RESOURCE_LIMITS_EXCEEDEDor timeouts. - Send
X-GitHub-Api-Versionto pin REST behavior; Octokit sets a default.
Gotchas
- Issues list endpoints return PRs too; filter out items with
pull_request. 403can mean permission missing, SSO authorization required for the token (X-GitHub-SSOheader), secondary rate limit, or a fine-grained PAT not approved by the org. Read the response body before retrying.- Fine-grained PATs and App tokens cannot see repos outside their selected set; 404 is the “not found or no access” answer.
- App private key with escaped newlines in env (
\nliteral): replace before use. - Search results cap at 1,000 per query; split by date ranges for more.
- Writing the same label/comment twice when a script is rerun: make scripts idempotent by checking current state first.
Verification
- Dry run output lists exactly the intended objects; spot-check three URLs in the browser.
- After the real run, re-query with the same filter: the expected count is zero (or the new state matches).
gh api rate_limitshows headroom remained; no secondary-limit warnings in logs.- Webhook endpoint returns 401/400 for a wrong signature (script
signwith a different secret) and processes a valid redelivery once.
Output contract
**Goal:** Close stale needs-info issues older than 30 days in org/repo
**Credential:** fine-grained PAT (issues: write on org/repo, expires in 7 days)
**Dry run:** 42 issues listed (attached), confirmed by user
**Executed:** 42 commented + closed, 0 errors; log in stale-close-2026-10-19.txt
**Rate limit:** core remaining 4,870/5,000 after run
**Rerun safety:** script skips issues already closed Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/github_webhook_sig.py
#!/usr/bin/env python3
"""Sign or verify GitHub webhook payloads (X-Hub-Signature-256).
Usage:
GITHUB_WEBHOOK_SECRET=... python3 github_webhook_sig.py sign <payload-file>
GITHUB_WEBHOOK_SECRET=... python3 github_webhook_sig.py verify <payload-file> <signature-header>
The payload file must contain the exact raw request body bytes. `sign` prints the
header value (sha256=<hex>). `verify` exits 0 when the signature matches, 1 otherwise.
The secret is read from the GITHUB_WEBHOOK_SECRET environment variable only, so it never
appears in shell history or process listings.
"""
import hashlib
import hmac
import os
import sys
def signature(secret: bytes, body: bytes) -> str:
return "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
def main(argv: list[str]) -> int:
if len(argv) < 3 or argv[1] not in ("sign", "verify"):
print(__doc__.strip(), file=sys.stderr)
return 2
secret = os.environ.get("GITHUB_WEBHOOK_SECRET")
if not secret:
print("error: set GITHUB_WEBHOOK_SECRET", file=sys.stderr)
return 2
try:
with open(argv[2], "rb") as fh:
body = fh.read()
except OSError as exc:
print(f"error: cannot read payload: {exc}", file=sys.stderr)
return 2
expected = signature(secret.encode(), body)
if argv[1] == "sign":
print(expected)
return 0
if len(argv) < 4:
print("error: verify needs the signature header value", file=sys.stderr)
return 2
given = argv[3].strip()
if not given.startswith("sha256="):
print("FAIL: header must start with 'sha256=' (X-Hub-Signature-256, not the legacy SHA-1 header)")
return 1
if hmac.compare_digest(expected, given):
print("OK: signature valid")
return 0
print("FAIL: signature mismatch (wrong secret, or body was re-serialized instead of raw bytes)")
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.