# gh api — pagination and filtering recipes

Working patterns for `gh api` with `--paginate`, `--slurp` and `--jq`. Each recipe
states what it assumes. Lesson: https://moderngitacademy.com/github/cli/gh-api/

**Assumes:** gh ≥ 2.42 (`--slurp` arrived in 2.42; `gh --version` to check), `jq`
installed for the local recipes, and a token whose permissions cover the
endpoint — see `token-permissions-matrix.md`.

**Outputs are not shown.** They depend on your repositories; run each recipe
against a repository you own before wiring it into anything.

---

## 1. The three flags, and how they combine

| Flag | What it does | Combine with |
|---|---|---|
| `--paginate` | Follows `Link: rel="next"` until the last page (REST) or `pageInfo.hasNextPage` (GraphQL, with the `$endCursor` variable) | everything below |
| `--jq EXPR` | Applies a jq filter **per page** unless `--slurp` is present | `--paginate` |
| `--slurp` | Wraps all pages in one JSON array *before* `--jq` runs | `--paginate --jq` |

Rule: **counting, sorting or deduplicating across pages needs `--slurp`.**
Without it, `--jq 'length'` prints one number per page.

```bash
# One number per page — almost never what you want
gh api --paginate repos/OWNER/REPO/issues --jq 'length'

# One number for the whole result
gh api --paginate --slurp repos/OWNER/REPO/issues --jq 'map(length) | add'
```

With `--slurp`, the input to jq is an array of pages, each page an array of
items, so most filters start with `.[]` or `add`:

```bash
# Flatten pages, then work on items
gh api --paginate --slurp repos/OWNER/REPO/pulls --jq 'add | map(.number)'
```

## 2. Page size

`per_page=100` is the maximum for nearly every list endpoint. Set it; the default
of 30 makes `--paginate` do three times the requests.

```bash
gh api --paginate 'repos/OWNER/REPO/issues?per_page=100&state=all'
```

Quote the path when it contains `&`.

## 3. Open pull requests older than N days, oldest first

```bash
gh api --paginate --slurp 'repos/OWNER/REPO/pulls?per_page=100&state=open' \
  --jq 'add
        | map(select(.draft | not))
        | map({number, title, author: .user.login, created_at})
        | sort_by(.created_at)
        | .[]
        | "\(.number)\t\(.created_at[:10])\t\(.author)\t\(.title)"'
```

`gh api --jq` prints strings raw (no quotes), so the output is ready for `cut`,
`sort` and spreadsheets.

Change `select(.draft | not)` to `select(.draft)` to see drafts only.

## 4. Issues by label, counted

```bash
gh api --paginate --slurp 'repos/OWNER/REPO/issues?per_page=100&state=open' \
  --jq 'add
        | map(select(.pull_request == null))       # the issues endpoint includes PRs
        | map(.labels[].name)
        | group_by(.)
        | map({label: .[0], count: length})
        | sort_by(-.count)
        | .[] | "\(.count)\t\(.label)"'
```

The `.pull_request == null` line matters: `/issues` returns pull requests too,
with a `pull_request` key set.

## 5. Every repository in an organisation, with default branch and visibility

```bash
gh api --paginate --slurp 'orgs/ORG/repos?per_page=100&type=all' \
  --jq 'add
        | map({name, default_branch, visibility, archived, pushed_at})
        | sort_by(.name)
        | .[]
        | [.name, .default_branch, .visibility, (.archived|tostring), .pushed_at[:10]]
        | @tsv'
```

`@tsv` is the right output for spreadsheets and `cut`; `@csv` when a field can
contain tabs.

## 6. Workflow runs that failed on the default branch this week

```bash
SINCE=$(date -u -d '7 days ago' +%Y-%m-%d 2>/dev/null || date -u -v-7d +%Y-%m-%d)
gh api --paginate --slurp \
  "repos/OWNER/REPO/actions/runs?per_page=100&branch=main&status=failure&created=>=$SINCE" \
  --jq 'map(.workflow_runs) | add
        | map({id, name, head_sha: .head_sha[:7], created_at, html_url})
        | .[]
        | "\(.created_at[:16])\t\(.name)\t\(.head_sha)\t\(.html_url)"'
```

This endpoint wraps its list in `workflow_runs`, so the flatten step is
`map(.workflow_runs) | add`, not `add`. Endpoints that return an object with a
`total_count` (runs, artifacts, search) all need this.

The `date` line works on GNU and BSD `date`; delete whichever branch you do not need.

## 7. Search: repositories in an org missing a CODEOWNERS file

Search endpoints return `{total_count, incomplete_results, items}` and cap at
1,000 results.

```bash
# Repositories that have one
gh api --paginate --slurp 'search/code?q=org:ORG+filename:CODEOWNERS+path:.github&per_page=100' \
  --jq 'map(.items) | add | map(.repository.name) | unique | .[]' > with-codeowners.txt

# All non-archived repositories
gh api --paginate --slurp 'orgs/ORG/repos?per_page=100' \
  --jq 'add | map(select(.archived|not)) | map(.name) | .[]' | sort > all-repos.txt

comm -23 all-repos.txt <(sort with-codeowners.txt)
```

Code search needs a token with `contents: read` on the repositories and has its
own limit of 10 requests per minute. For more than a few hundred repositories,
run it in batches.

## 8. Rate limit before a big paginated run

```bash
gh api rate_limit --jq '.resources.core | "\(.remaining)/\(.limit) remaining, resets \(.reset | todate)"'
```

A 100-page `--paginate` run costs 100 requests. An authenticated user gets
5,000 requests per hour; a GitHub App installation gets at least 5,000, scaling
with repositories and users, and 15,000 on a GitHub Enterprise Cloud
organisation. Check before, not after.

## 9. GraphQL pagination

`--paginate` works for GraphQL when the query declares `$endCursor` and returns
`pageInfo { hasNextPage endCursor }`:

```bash
gh api graphql --paginate --slurp -f query='
  query($endCursor: String) {
    repository(owner: "OWNER", name: "REPO") {
      pullRequests(first: 100, states: MERGED, after: $endCursor, orderBy: {field: UPDATED_AT, direction: DESC}) {
        nodes { number title mergedAt author { login } }
        pageInfo { hasNextPage endCursor }
      }
    }
  }' --jq 'map(.data.repository.pullRequests.nodes) | add | .[] | "\(.mergedAt[:10])\t\(.number)\t\(.author.login)\t\(.title)"'
```

The variable must be named `endCursor` — that is what `gh` fills in.

## 10. Idempotent writes from a script

`--paginate` is read-only by nature; for writes, make the script safe to re-run:

```bash
# Add a label only if it is not already there
LABELS=$(gh api "repos/OWNER/REPO/issues/$N/labels" --jq 'map(.name) | .[]')
grep -qx 'needs-triage' <<<"$LABELS" || gh api -X POST "repos/OWNER/REPO/issues/$N/labels" -f 'labels[]=needs-triage'
```

## Common mistakes

- **`--jq` without `--slurp` on a multi-page result** — one output per page.
- **Forgetting `per_page=100`** — three times the requests.
- **Treating `/issues` as issues only** — filter `.pull_request == null`.
- **`add` on a `{total_count, items}` endpoint** — use `map(.items) | add`.
- **Unquoted `&` in the path** — the shell backgrounds the command.
- **Assuming the org-level `repos` list includes forks and archived repos by default** — it does; filter them out explicitly.
