Master the gh CLI — auth, scripting, --json/--jq pipelines — then graduate to the REST API v3, GraphQL API v4, Octokit SDK, and GitHub Apps. Build practical automations: auto-assign reviewers, stale issue closers, release notifiers, and org onboarding bots. Finish with Actions + GraphQL to drive Projects v2 from CI.
The GitHub CLI (gh) is the official command-line tool that exposes the full GitHub surface — repos, PRs, issues, Actions runs, releases, gists, and the API — without opening a browser. It ships prebuilt for Windows/macOS/Linux and integrates tightly with git.
Authentication
# Interactive login (opens browser for OAuth flow)$ gh auth login
# Login to a non-github.com host (GitHub Enterprise Server)$ gh auth login --hostname github.mycompany.com
# Login with a PAT (non-interactive, useful in CI)$ echo "$GH_TOKEN" | gh auth login --with-token
# Check who you're authenticated as$ gh auth status
github.com
✓ Logged in to github.com as octocat (~/.config/gh/hosts.yml)
✓ Git operations for github.com configured to use https protocol
✓ Token: ghp_***
For automated environments set the GH_TOKEN environment variable to a PAT or to GITHUB_TOKEN from an Actions workflow — gh picks it up automatically without running auth login.
Config
# View all config values$ gh config list
# Set preferred git protocol (ssh recommended for personal machines)$ gh config set git_protocol ssh
# Set default editor for PR/issue bodies$ gh config set editor "code --wait"
# Set browser (useful when the default is wrong)$ gh config set browser firefox
# Per-host overrides (GHES)$ gh config set --host github.mycompany.com git_protocol ssh
Extensions
Extensions are standalone executables prefixed gh-. They extend gh with community-built subcommands and can be written in any language. The GitHub team and community publish dozens of extensions on GitHub Marketplace.
# Search and install an extension$ gh extension search copilot
$ gh extension install github/gh-copilot
# List installed extensions$ gh extension list
# Upgrade all extensions$ gh extension upgrade --all
# Remove an extension$ gh extension remove github/gh-copilot
Aliases
Aliases let you bind a short name to any gh invocation — including multi-flag commands you run constantly.
# Create an alias for a long command$ gh alias set prm 'pr merge --squash --auto --delete-branch'
$ gh prm # now merges with squash + auto + branch cleanup# Alias that accepts arguments (use $1, $2 ...)$ gh alias set mypr 'pr create --title "$1" --body "" --draft'
$ gh mypr "WIP: my feature"
# Shell aliases (prefix with ! to run arbitrary shell)$ gh alias set clone-and-cd '!gh repo clone "$1" && cd "$(basename $1)"'
# List and delete aliases$ gh alias list
$ gh alias delete prm
PRO TIP
Store aliases in dotfiles and commit them. Run gh alias import <file> and gh alias export to share them across machines. Aliases live in ~/.config/gh/config.yml.
12.2
gh for Daily Work: PRs, Issues & Repos
📚 Free Weekly Tutorials
Java, Spring Boot, AWS, DevOps & AI — straight to your inbox.
Once you know the verbs — create, list, view, edit, merge, close — you can compose them into fast terminal workflows that keep your hands off the browser.
Pull Request workflow
# Create a PR from the current branch (opens editor for title+body)$ gh pr create
# Create PR with all options inline$ gh pr create \
--title "feat: add dark mode toggle" \
--body "$(cat .github/pull_request_template.md)" \
--assignee @me \
--reviewer octocat,team:platform \
--label enhancement \
--draft
# View open PRs in current repo$ gh pr list
# Check out a PR locally (by number or URL)$ gh pr checkout 217
# View PR details + status checks inline$ gh pr view 217 --web # open browser$ gh pr view 217 # terminal view# Submit a review$ gh pr review 217 --approve --body "LGTM, shipping it"
$ gh pr review 217 --request-changes --body "See inline comments"
# Merge strategies$ gh pr merge 217 --squash --delete-branch
$ gh pr merge 217 --rebase
$ gh pr merge 217 --merge
# Enable auto-merge (merges when checks pass + approvals met)$ gh pr merge 217 --squash --auto
Issue workflow
# Create an issue$ gh issue create \
--title "Button click triggers page reload on Safari" \
--body "Steps to reproduce: ..." \
--label "bug,priority:high" \
--assignee octocat \
--milestone "v2.4"
# List issues with filters$ gh issue list --label "priority:high" --state open
$ gh issue list --assignee @me --limit 20
# Close an issue with a comment$ gh issue close 88 --comment "Fixed in #217. Will ship in v2.4."
# Pin/unpin, lock, transfer$ gh issue pin 88
$ gh issue lock 88 --reason resolved
Repo & gist workflow
# Clone a repo (also configures gh remote)$ gh repo clone acme/platform
# Fork + clone in one shot$ gh repo fork acme/platform --clone
# Create a new repo from the current directory$ gh repo create acme/new-service --private --source=. --push
# View repo info$ gh repo view acme/platform
# Sync a fork with upstream$ gh repo sync acme/platform --branch main
# Create a quick gist$ gh gist create snippet.py --description "rate limiter helper" --public
Actions workflow commands
# Trigger a workflow manually$ gh workflow run deploy.yml --ref main --field env=prod
# Watch a running workflow in real time$ gh run watch
# List recent runs$ gh run list --workflow=ci.yml --limit 5
# Download artifacts from a run$ gh run download 9876543 --name coverage-report
12.3
gh Scripting: --json, --jq & Shell Pipelines
Every gh list and view command accepts --json to output structured data and --jq to filter it inline. Together they turn gh into a composable building block for shell scripts, cron jobs, and CI automation.
The --json / --jq pattern
# Discover available JSON fields for a command$ gh pr list --json
error: the `--json` flag requires one or more comma separated field names
Supported fields: number, title, author, state, labels, createdAt, mergedAt, ...# Get PR numbers and titles as JSON$ gh pr list --json number,title,author
[{"number":217,"title":"feat: dark mode","author":{"login":"octocat"}}, ...]# Filter inline with --jq (jq syntax)$ gh pr list --json number,title,author \
--jq '.[] | "\(.number)\t\(.author.login)\t\(.title)"'
217 octocat feat: dark mode
214 alice fix: button reload on Safari# Extract a single value (e.g. the latest PR number)$ gh pr list --json number --jq '.[0].number'
217
Useful scripting patterns
# All open PRs created by a specific author$ gh pr list --author octocat --state open \
--json number,title,createdAt \
--jq '.[] | select(.createdAt > "2026-01-01")'
# Count open bugs by label$ gh issue list --label bug --state open --limit 200 \
--json number --jq 'length'
# Get all repos in an org (paginate with --limit)$ gh repo list acme --limit 200 --json name \
--jq '.[].name' > repos.txt
# Loop over repos to run a command on each$ while IFS= read -r repo; do
count=$(gh issue list -R "acme/$repo" --label bug --state open \
--json number --jq 'length' 2>/dev/null)
echo "$repo: $count open bugs"
done < repos.txt
# Get the SHA of the latest commit on a branch$ gh api repos/acme/platform/branches/main --jq '.commit.sha'
Template output with --template
# Go template for formatted output (alternative to --jq)$ gh pr list --template \
'{{range .}}{{tablerow .number .title .author.login .createdAt}}{{end}}'
SCRIPTING RULE
Always use --json + --jq over parsing human-readable output with grep/awk. The JSON schema is stable; the human-readable format changes between versions and locale settings.
12.4
REST API v3: Authentication, Rate Limits & Pagination
GitHub's REST API (v3) is a JSON-over-HTTPS API covering almost every GitHub resource. gh api is the easiest way to call it without managing auth headers manually.
Calling the REST API with gh api
# GET any endpoint (no auth header needed — gh handles it)$ gh api repos/acme/platform
# POST with JSON body$ gh api repos/acme/platform/issues \
--method POST \
--field title="Automated bug report" \
--field body="Found by nightly scan" \
--field 'labels[]=bug'
# PATCH to update a resource$ gh api repos/acme/platform/issues/88 \
--method PATCH \
--field state=closed
# DELETE a branch$ gh api repos/acme/platform/git/refs/heads/old-feature \
--method DELETE
Calling the REST API directly (curl / fetch)
# Fine-grained PAT in Authorization header$ curl -H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/acme/platform
# GitHub App: JWT → installation token flow (pseudocode)# 1. Sign a JWT with your app's private key# 2. Exchange JWT for an installation access token# 3. Use that token like a PAT (expires in 1 hour)$ curl -H "Authorization: Bearer $INSTALLATION_TOKEN" \
https://api.github.com/repos/acme/platform/issues
Rate limits
Auth type
Primary limit
Secondary limit
Unauthenticated
60 req/hour per IP
N/A
PAT / OAuth App
5,000 req/hour per user
100 concurrent, no burst
GitHub App (installation)
5,000 req/hour per installation + 50/hour per core for GitHub Enterprise
Same secondary rules
GITHUB_TOKEN (Actions)
1,000 req/hour per repo per workflow
Same secondary rules
# Check your current rate limit$ gh api rate_limit --jq '.rate'
{"limit":5000,"used":47,"remaining":4953,"reset":1749024000}# Also check response headers (per request)# X-RateLimit-Remaining: 4953# X-RateLimit-Reset: 1749024000 (Unix timestamp)# Retry-After: 60 (only present when 429)
Pagination
The REST API returns at most 100 items per page. Use the Link response header to traverse pages, or let gh api --paginate do it for you.
# --paginate fetches all pages and concatenates JSON arrays$ gh api repos/acme/platform/issues?state=open&per_page=100 \
--paginate --jq '.[].number'
# curl loop (for environments without gh)$ page=1
$ while :; do
body=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/acme/platform/issues?state=open&per_page=100&page=$page")
echo "$body" | jq -e 'length > 0' >/dev/null || break
echo "$body" | jq '.[].number'
((page++))
done
Common endpoints quick reference
Method
Endpoint
Purpose
GET
/repos/{owner}/{repo}
Repo metadata
GET
/repos/{owner}/{repo}/pulls?state=open
List PRs
POST
/repos/{owner}/{repo}/issues
Create issue
PATCH
/repos/{owner}/{repo}/issues/{n}
Update issue (close, label)
POST
/repos/{owner}/{repo}/issues/{n}/comments
Add comment
GET
/orgs/{org}/members
List org members
GET
/repos/{owner}/{repo}/actions/runs
List workflow runs
POST
/repos/{owner}/{repo}/releases
Create release
12.5
GraphQL API v4: Queries, Cost & Rate Limits
The GraphQL API exists because the REST API over-fetches. A REST call to list 100 issues returns every field on every issue — but you might only need title and number. GraphQL lets you declare exactly what you want, reducing payload size and enabling complex nested queries in a single request.
Why GraphQL for GitHub?
Scenario
REST
GraphQL
Fetch issue + PR + project status in one call
3 separate requests
1 query
Fetch only title+number from 100 issues
Full issue object every time
Exactly 2 fields
Query Projects v2 custom fields
Not supported
Supported
Mutations (update project item, add label)
PATCH requests
Typed mutations with input validation
Writing queries with gh api graphql
# Simple query: get the last 5 PRs$ gh api graphql -f query='
query {
repository(owner:"acme", name:"platform") {
pullRequests(last:5, states:OPEN) {
nodes {
number
title
author { login }
createdAt
}
}
}
}
'
# With variables (recommended — avoids injection risks)$ gh api graphql \
-F owner=acme \
-F repo=platform \
-F last=10 \
-f query='
query($owner:String!, $repo:String!, $last:Int!) {
repository(owner:$owner, name:$repo) {
pullRequests(last:$last, states:OPEN) {
nodes { number title author { login } }
}
}
}
' --jq '.data.repository.pullRequests.nodes'
Cursor-based pagination
# GraphQL uses cursor pagination — request a pageInfo block$ gh api graphql -f query='
query {
repository(owner:"acme", name:"platform") {
issues(first:100, states:OPEN, after:null) {
pageInfo { hasNextPage endCursor }
nodes { number title }
}
}
}
'
# Use endCursor value in next call: after:"cursor_string_here"# gh api graphql --paginate handles this automatically for list fields$ gh api graphql --paginate -f query='
query($endCursor:String) {
repository(owner:"acme", name:"platform") {
issues(first:100, after:$endCursor, states:OPEN) {
pageInfo { hasNextPage endCursor }
nodes { number title }
}
}
}
' --jq '.data.repository.issues.nodes[].number'
Mutations
# Add a label to an issue via mutation$ gh api graphql \
-F issueId="I_kwDOABcdef01A" \
-F labelId="LA_kwDOABcdef02B" \
-f query='
mutation($issueId:ID!, $labelId:ID!) {
addLabelsToLabelable(input:{labelableId:$issueId, labelIds:[$labelId]}) {
labelable { ... on Issue { number title } }
}
}
'
Cost & rate limits
GraphQL uses a node cost model instead of a request count. Each node (object) fetched costs 1 point. You get 5,000 points per hour for authenticated requests. Complex nested queries can cost hundreds of points.
A query that fetches 100 issues × 10 nested PRs each = up to 1,000 nodes = 1,000 points in a single call. Always check rateLimit.cost in development. Set first: to the minimum you actually need.
Octokit is the official GitHub SDK family. It handles auth, retries, pagination, and rate-limit throttling so you don't have to re-implement those in every script. The JavaScript/TypeScript client (@octokit/rest) is the most mature; Python (PyGithub) and Ruby (octokit.rb) are maintained but slightly less feature-rich.
# pythonfrom github import Github
g = Github(os.environ["GH_TOKEN"])
repo = g.get_repo("acme/platform")
# PaginatedList handles pagination lazily
open_issues = repo.get_issues(state="open")
for issue in open_issues:
if"bug"in [l.name for l in issue.labels]:
issue.create_comment("Triaged by automation")
A GitHub App is a first-class principal on GitHub — distinct from a user account. Apps can be installed on specific repos with fine-grained permissions, reducing the blast radius compared to a full-access PAT. For automation that needs to act across many repos or that you want to ship to others, GitHub Apps are the right model.
PAT vs OAuth App vs GitHub App
Type
Acts as
Permissions
Rate limit
Best for
Classic PAT
A user
Coarse scopes (repo, admin, etc.)
5,000/hr per user
Personal scripts, quick access
Fine-grained PAT
A user
Per-repo, per-permission
5,000/hr per user
Least-privilege personal scripts
OAuth App
A user (on behalf of)
Coarse scopes, user consent
5,000/hr per user
User-facing web apps
GitHub App
Itself (installation)
Fine-grained, per repo, per permission
5,000/hr per installation
Bots, CI integrations, multi-repo tools
Creating a GitHub App
Go to Settings → Developer settings → GitHub Apps → New GitHub App
Set Homepage URL, Webhook URL (or use smee.io for local dev), and Webhook Secret
Under Permissions, select only what you need (e.g. Issues: Read/Write, Pull requests: Read/Write)
Under Subscribe to events, select relevant webhook events
Generate a private key (.pem file) — store it securely (never commit it)
Install the app on your org or repo — this creates an installation
Authentication flow: JWT → installation token
// Node.js: sign JWT with your app's private keyimport { createAppAuth } from"@octokit/auth-app";
import { Octokit } from"@octokit/rest";
import { readFileSync } from"fs";
const octokit = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: process.env.GITHUB_APP_ID,
privateKey: readFileSync(process.env.GITHUB_APP_KEY_PATH, "utf8"),
installationId: process.env.GITHUB_INSTALLATION_ID,
},
});
// Octokit handles JWT minting and token refresh automaticallyconst { data } = await octokit.rest.repos.listForInstallation({ per_page: 100 });
console.log(`App has access to ${data.length} repos`);
# Get installation ID for your app in an org$ gh api /app/installations --jq '.[] | select(.account.login=="acme") | .id'
TOKEN LIFECYCLE
Installation access tokens expire after 1 hour. The @octokit/auth-app plugin caches them and transparently mints a new token before expiry. If you're rolling your own auth, check the expires_at field in the token response and refresh before it lapses.
12.8
Webhooks: Event Types, HMAC Verification & smee.io for Local Dev
Webhooks are HTTP POST callbacks that GitHub sends to your server when events happen in a repo or org. They are the backbone of any real-time GitHub automation — bots, CI triggers, audit pipelines, and Slack notifiers all start with a webhook.
Key event types
pull_requestOpened, closed, merged, converted to draft, labeled, review requested, synchronize (new commit pushed). The most-used event for CI bots and review automation.
pushAny push to any branch (not tags by default). Payload includes commits, before/after SHAs, and the pusher's identity.
issuesOpened, closed, assigned, labeled, milestoned, transferred, deleted. Used for triage bots and project board automation.
issue_commentCreated, edited, deleted on an issue or PR. Used for slash-command bots (e.g. /rebase triggered from a comment).
workflow_runA GitHub Actions workflow completed. Used to trigger post-CI automation: deploy on green, notify on failure.
releaseCreated, published, edited, deleted. Used to trigger external deployment pipelines, changelog publishes, and Slack announcements.
membership / organizationMember added/removed from org or team. Used for onboarding automation and audit logging.
HMAC-SHA256 payload verification
Always verify the X-Hub-Signature-256 header. Without it, any HTTP client can POST a fake event to your webhook endpoint.
Never use string equality (===) for HMAC comparison — it's vulnerable to timing attacks. Always use crypto.timingSafeEqual() (Node) or hmac.compare_digest() (Python). Webhook secrets should be randomly generated (32+ bytes).
smee.io for local development
GitHub can't send webhooks to localhost. smee.io is a hosted event relay that forwards GitHub webhook payloads to your local machine over EventSource (SSE).
# 1. Create a smee channel at https://smee.io/new — copy the URL# 2. Install the smee client$ npm install -g smee-client
# 3. Forward to your local server$ smee --url https://smee.io/your-channel-id \
--target http://localhost:3000/webhook
# 4. Set the smee URL as the Webhook URL in your GitHub App / repo settings# 5. All events now appear in your terminal + forwarded to localhost:3000
Inspecting webhook delivery logs
GitHub logs every webhook delivery attempt. Go to Repo Settings → Webhooks → Recent Deliveries to see the payload, response, and redeliver if needed. For GitHub Apps, check App Settings → Advanced → Recent Deliveries.
These four patterns cover the most common automation needs for a senior dev or lead. Each can be implemented either as a GitHub Actions workflow (simpler, no infrastructure) or as a webhook handler (more control, external trigger).
CODEOWNERS already handles reviewer requests — but you sometimes need custom logic (round-robin, on-call rotation, skill-based routing). The auto-assign action covers the common case; a custom workflow script covers the rest.
# .github/workflows/auto-assign.ymlon:
pull_request:
types: [opened, ready_for_review]
jobs:
assign:
runs-on: ubuntu-latest
if: "!github.event.pull_request.draft"steps:
- uses: actions/checkout@v4
- name: Assign reviewers
uses: hmarr/auto-approve-action@v4 # or custom scriptwith:
github-token: ${{ secrets.GITHUB_TOKEN }}
# Custom round-robin script approach:# - Read team members via gh api# - Pick reviewer based on (PR number % team size)# - gh pr edit $PR --add-reviewer $reviewer
2. Stale Issue Closer
Trigger: schedule (daily) | Implementation: actions/stale or custom script
Close issues and PRs that have had no activity after a set period. The official actions/stale action handles the most common case with a label-then-close grace period.
# .github/workflows/stale.ymlon:
schedule:
- cron: "0 2 * * *"# 2 AM UTC dailyworkflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: "This issue has been inactive for 60 days. It will be closed in 7 days unless there is new activity."stale-pr-message: "This PR has been inactive for 30 days. It will be closed in 7 days."days-before-issue-stale: 60days-before-pr-stale: 30days-before-issue-close: 7days-before-pr-close: 7stale-issue-label: "stale"exempt-issue-labels: "pinned,security,roadmap"exempt-pr-labels: "do-not-close"
Post a Slack or Teams message when a new GitHub Release is published. Link to the release notes, include the tag, and optionally diff the changelog from the last release.
When a new member joins the org: add them to a standard team, create a welcome issue in the onboarding repo, and post a welcome message. This is best implemented as a GitHub App webhook handler since it needs org-level scope.
// onboarding-bot.js (webhook handler for organization.member_added)async function handleMemberAdded(payload) {
const { login } = payload.membership.user;
const org = payload.organization.login;
// 1. Add to default "all-engineers" teamawait octokit.rest.teams.addOrUpdateMembershipForUserInOrg({
org, team_slug: "all-engineers", username: login,
});
// 2. Create welcome issue in onboarding repoawait octokit.rest.issues.create({
owner: org, repo: "onboarding",
title: `Welcome @${login}!`,
body: `Hi @${login} — here's your onboarding checklist:\n- [ ] Set up 2FA\n- [ ] Read the team handbook\n- [ ] Join #engineering on Slack`,
assignees: [login],
labels: ["onboarding"],
});
// 3. Post welcome in Slack (call your existing Slack utility)await postSlack(`👋 @${login} just joined ${org}! Say hi in #introductions.`);
}
GitHub Projects v2 has no REST API. All programmatic access goes through the GraphQL API. This section shows how to wire GraphQL calls into Actions workflows so your CI can read and update project state: set a status field on merge, move items when a release ships, or query project data for reporting.
Required permissions
# Workflow-level permissions needed for Projects v2permissions:
issues: write
pull-requests: write
repository-projects: write # needed for project mutations
IMPORTANT
GITHUB_TOKEN cannot access org-level Projects. For org projects you must create a PAT (classic, with project scope) or a GitHub App installation token and pass it as a secret.
Finding your project ID and field IDs
# Get project node IDs in an org$ gh api graphql -F org=acme -f query='
query($org:String!) {
organization(login:$org) {
projectsV2(first:20) {
nodes { id number title }
}
}
}
' --jq '.data.organization.projectsV2.nodes'
# Get field IDs for a specific project$ gh api graphql -F projectId="PVT_kwDOABcd01A" -f query='
query($projectId:ID!) {
node(id:$projectId) {
... on ProjectV2 {
fields(first:20) {
nodes {
... on ProjectV2Field { id name }
... on ProjectV2SingleSelectField { id name options { id name } }
... on ProjectV2IterationField { id name }
}
}
}
}
}
'
Complete workflow: update Status field on PR merge
Store project IDs and field IDs in repository variables (not secrets) so workflows read them with vars.PROJECT_ID. Update the variable when you rename a field without touching the workflow file.
Up Next — Phase 13: GitHub Copilot & AI-Assisted Development
Copilot tiers, Chat slash commands, PR description generation, Copilot Workspace, content exclusions, measuring ROI, and prompt engineering for better completions.