PHASE 12 OF 14

GitHub CLI & API Automation

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.

GitHub CLI REST API GraphQL Octokit GitHub Apps Webhooks Automation
12.1

gh CLI: Authentication, Config, Extensions & Aliases

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

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 typePrimary limitSecondary limit
Unauthenticated60 req/hour per IPN/A
PAT / OAuth App5,000 req/hour per user100 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 workflowSame 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

MethodEndpointPurpose
GET/repos/{owner}/{repo}Repo metadata
GET/repos/{owner}/{repo}/pulls?state=openList PRs
POST/repos/{owner}/{repo}/issuesCreate issue
PATCH/repos/{owner}/{repo}/issues/{n}Update issue (close, label)
POST/repos/{owner}/{repo}/issues/{n}/commentsAdd comment
GET/orgs/{org}/membersList org members
GET/repos/{owner}/{repo}/actions/runsList workflow runs
POST/repos/{owner}/{repo}/releasesCreate 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?

ScenarioRESTGraphQL
Fetch issue + PR + project status in one call3 separate requests1 query
Fetch only title+number from 100 issuesFull issue object every timeExactly 2 fields
Query Projects v2 custom fieldsNot supportedSupported
Mutations (update project item, add label)PATCH requestsTyped 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.

# Request cost introspection in the response $ gh api graphql -f query=' query { rateLimit { cost remaining resetAt } repository(owner:"acme", name:"platform") { issues(first:10) { nodes { number } } } } ' --jq '.data.rateLimit' {"cost": 1, "remaining": 4987, "resetAt": "2026-06-14T10:00:00Z"}
RATE LIMIT GOTCHA

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.

12.6

Octokit SDK: Setup, Pagination Helpers & Throttling

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.

JavaScript / TypeScript setup

$ npm install @octokit/rest @octokit/plugin-throttling @octokit/plugin-retry
// octokit-client.js import { Octokit } from "@octokit/rest"; import { throttling } from "@octokit/plugin-throttling"; import { retry } from "@octokit/plugin-retry"; const MyOctokit = Octokit.plugin(throttling, retry); const octokit = new MyOctokit({ auth: process.env.GH_TOKEN, throttle: { onRateLimit: (retryAfter, options, _octokit, retryCount) => { console.warn(`Rate limited. Retry after ${retryAfter}s`); return retryCount < 3; // retry up to 3 times }, onSecondaryRateLimit: (retryAfter, options) => { console.warn(`Secondary limit hit for ${options.url}`); return true; }, }, retry: { doNotRetry: ["429"] }, // let throttle handle 429 });

Calling REST endpoints

// List all open issues — paginated automatically with paginate() const issues = await octokit.paginate(octokit.rest.issues.listForRepo, { owner: "acme", repo: "platform", state: "open", per_page: 100, }); console.log(`${issues.length} open issues`); // Create an issue const { data: newIssue } = await octokit.rest.issues.create({ owner: "acme", repo: "platform", title: "Automated: dependency drift detected", body: "See nightly scan report attached.", labels: ["dependency-update"], }); console.log(`Created #${newIssue.number}`);

GraphQL with Octokit

// graphql() uses the token automatically const { repository } = await octokit.graphql(` query($owner:String!, $repo:String!) { repository(owner:$owner, name:$repo) { pullRequests(first:50, states:OPEN) { nodes { number title createdAt } } } } `, { owner: "acme", repo: "platform" }); // Paginated GraphQL const prs = octokit.graphql.paginate(` query($owner:String!, $repo:String!, $cursor:String) { repository(owner:$owner, name:$repo) { pullRequests(first:100, states:OPEN, after:$cursor) { pageInfo { hasNextPage endCursor } nodes { number title } } } } `, { owner: "acme", repo: "platform" });

Python (PyGithub)

$ pip install PyGithub
# python from 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")
12.7

GitHub Apps: Private Key Auth, Installation Tokens & Webhook Events

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

TypeActs asPermissionsRate limitBest for
Classic PATA userCoarse scopes (repo, admin, etc.)5,000/hr per userPersonal scripts, quick access
Fine-grained PATA userPer-repo, per-permission5,000/hr per userLeast-privilege personal scripts
OAuth AppA user (on behalf of)Coarse scopes, user consent5,000/hr per userUser-facing web apps
GitHub AppItself (installation)Fine-grained, per repo, per permission5,000/hr per installationBots, CI integrations, multi-repo tools

Creating a GitHub App

  1. Go to Settings → Developer settings → GitHub Apps → New GitHub App
  2. Set Homepage URL, Webhook URL (or use smee.io for local dev), and Webhook Secret
  3. Under Permissions, select only what you need (e.g. Issues: Read/Write, Pull requests: Read/Write)
  4. Under Subscribe to events, select relevant webhook events
  5. Generate a private key (.pem file) — store it securely (never commit it)
  6. 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 key import { 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 automatically const { 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_request Opened, closed, merged, converted to draft, labeled, review requested, synchronize (new commit pushed). The most-used event for CI bots and review automation.
push Any push to any branch (not tags by default). Payload includes commits, before/after SHAs, and the pusher's identity.
issues Opened, closed, assigned, labeled, milestoned, transferred, deleted. Used for triage bots and project board automation.
issue_comment Created, edited, deleted on an issue or PR. Used for slash-command bots (e.g. /rebase triggered from a comment).
workflow_run A GitHub Actions workflow completed. Used to trigger post-CI automation: deploy on green, notify on failure.
release Created, published, edited, deleted. Used to trigger external deployment pipelines, changelog publishes, and Slack announcements.
membership / organization Member 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.

// Express.js webhook handler with signature verification import { createHmac, timingSafeEqual } from "crypto"; app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { const sig = req.headers["x-hub-signature-256"]; if (!sig) return res.status(403).send("No signature"); const expected = "sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET) .update(req.body) .digest("hex"); // timingSafeEqual prevents timing attacks if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return res.status(403).send("Signature mismatch"); } const event = req.headers["x-github-event"]; const payload = JSON.parse(req.body); if (event === "pull_request" && payload.action === "opened") { handlePrOpened(payload); } res.status(200).send("OK"); });
SECURITY REQUIREMENT

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.

12.9

Practical Automations: Auto-Assign, Stale Closer, Release Notifier & Org Onboarding Bot

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).

1. Auto-Assign Reviewers

Trigger: pull_request.opened | Implementation: Actions workflow + CODEOWNERS

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.yml on: 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 script with: 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.yml on: schedule: - cron: "0 2 * * *" # 2 AM UTC daily workflow_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: 60 days-before-pr-stale: 30 days-before-issue-close: 7 days-before-pr-close: 7 stale-issue-label: "stale" exempt-issue-labels: "pinned,security,roadmap" exempt-pr-labels: "do-not-close"

3. Release Notifier

Trigger: release.published | Implementation: Actions workflow

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.

# .github/workflows/release-notify.yml on: release: types: [published] jobs: notify: runs-on: ubuntu-latest steps: - name: Post to Slack uses: slackapi/slack-github-action@v1 with: payload: | { "text": "🚀 *${{ github.event.repository.name }} ${{ github.event.release.tag_name }} released!*\n${{ github.event.release.html_url }}" } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_RELEASE_WEBHOOK }} SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

4. Org Onboarding Bot

Trigger: organization.member_added webhook | Implementation: webhook handler (Node.js + Octokit)

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" team await octokit.rest.teams.addOrUpdateMembershipForUserInOrg({ org, team_slug: "all-engineers", username: login, }); // 2. Create welcome issue in onboarding repo await 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.`); }
12.10

GitHub Actions + GraphQL: Querying Projects v2 & Updating Custom Fields from Workflows

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 v2 permissions: 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

# .github/workflows/update-project-on-merge.yml name: Update project on merge on: pull_request: types: [closed] jobs: update-project: runs-on: ubuntu-latest if: github.event.pull_request.merged == true steps: - name: Move PR item to Done in project env: GH_TOKEN: ${{ secrets.PROJECT_PAT }} PR_ID: ${{ github.event.pull_request.node_id }} PROJECT_ID: PVT_kwDOABcd01A STATUS_FIELD_ID: PVTF_lADOABcd01A DONE_OPTION_ID: done_option_node_id run: | item_id=$(gh api graphql \ -F prId="$PR_ID" -F projectId="$PROJECT_ID" \ -f query=' query($prId:ID!, $projectId:ID!) { node(id:$prId) { ... on PullRequest { projectItems(first:10) { nodes { id project { id } } } } } } ' \ --jq ".data.node.projectItems.nodes[] | select(.project.id==\"$PROJECT_ID\") | .id") gh api graphql \ -F itemId="$item_id" -F projectId="$PROJECT_ID" \ -F fieldId="$STATUS_FIELD_ID" -F optionId="$DONE_OPTION_ID" \ -f query=' mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) { updateProjectV2ItemFieldValue(input:{ projectId:$projectId itemId:$itemId fieldId:$fieldId value:{ singleSelectOptionId:$optionId } }) { projectV2Item { id } } } ' echo "Status updated to Done"

Sprint report: count items by status

$ gh api graphql -F projectId="PVT_kwDOABcd01A" -f query=' query($projectId:ID!) { node(id:$projectId) { ... on ProjectV2 { items(first:200) { nodes { fieldValueByName(name:"Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } } } } } } } ' --jq '[.data.node.items.nodes[].fieldValueByName.name] | group_by(.) | map({status:.[0],count:length})' [ {"status":"Done","count":28}, {"status":"In Progress","count":7}, {"status":"In Review","count":4}, {"status":"Todo","count":15} ]
PATTERN

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.

Continue → Back to Hub