Skill

github-project-management

Name
github-project-management
Description
Expert at building and managing GitHub Projects (v2), issues, and assignments end to end on both GitHub.com (Enterprise Cloud) and GitHub Enterprise Server (self-hosted). Teaches an AI agent the correct combination of the gh CLI, GitHub REST API, and GitHub GraphQL API to create a project, populate it with issues, assign users, set labels and milestones, configure custom fields (Status, Priority, Iteration), and automate bulk project operations. Covers enterprise-specific setup including custom hostnames, /api/v3 REST base paths, and gh CLI host switching. Use when the user asks you to set up a GitHub project, break work down into issues, assign tasks, track progress, or otherwise manage GitHub project boards programmatically on any GitHub deployment.
License
Apache-2-0
Compatibility
Requires the gh CLI installed and authenticated (gh auth login), or a personal access token with the correct scopes set as GITHUB_TOKEN (or GH_ENTERPRISE_TOKEN for Enterprise Server). Network access to api.github.com for GitHub.com, or to your Enterprise Server hostname's /api/v3 and /api/graphql endpoints. Projects v2 features require either the gh CLI or a GraphQL-capable HTTP client. Enterprise Server must be running version 3.8 or later for Projects v2 support.
Allowed Tools
Read, Grep, Glob, Web Fetch, Bash
Has Section
Title
Two APIs, one coherent workflow
Body
**Before you start** — three hard requirements you need to confirm in the first minute, before writing any code: 1. The `gh` CLI is installed (`gh --version`) and authenticated against the right host (`gh auth status`). For GHES you must have run `gh auth login --hostname YOUR_HOST` once. 2. If you are on **GitHub Enterprise Server**, the server is on **version 3.8 or newer**. Projects v2 requires this — older GHES exposes only the deprecated Projects v1, and none of the mutations in this skill will work. Check with `gh api /meta --hostname YOUR_HOST --jq .installed_version` if you are unsure. 3. Your token carries the **project** scope (classic PAT) or the **Projects: Read and write** permission (fine-grained PAT), plus whatever repository access you will need. A token missing the project scope fails with "Resource not accessible" on `createProjectV2`. GitHub exposes two APIs and you will use both in almost any non-trivial project workflow: - **REST API** — covers repositories, issues, labels, milestones, assignees, comments, and PRs. On GitHub.com the base is `https://api.github.com`. On GitHub Enterprise Server it is `https://HOSTNAME/api/v3`. Every object has both a numeric `id` and a GraphQL **Node ID** (returned as `node_id`). REST is the fastest path for creating, updating, and querying individual issues. - **GraphQL API** — **required** for everything related to Projects v2 (the modern project boards). On GitHub.com the endpoint is `https://api.github.com/graphql`. On GitHub Enterprise Server it is `https://HOSTNAME/api/graphql` (no `/v3`). The classic REST `/projects` endpoints are the deprecated Projects v1 and you should not use them. GraphQL is also more efficient for batched reads across many entities. The `gh` CLI wraps both, handles authentication, and routes to the right host for any GitHub deployment. Prefer `gh` for anything it supports; drop to `gh api` for raw REST; drop to `gh api graphql` for arbitrary GraphQL. **Read the next section before any Enterprise Server work** — host-switching is a first-class concern.
Title
GitHub.com vs GitHub Enterprise Server
Body
GitHub ships in three forms you will encounter: | Deployment | REST base | GraphQL endpoint | gh hostname | |------------------------------------|-----------------------------------|-----------------------------------|-------------------| | **GitHub.com** (public cloud) | `https://api.github.com` | `https://api.github.com/graphql` | `github.com` | | **GitHub Enterprise Cloud** | `https://api.github.com` | `https://api.github.com/graphql` | `github.com` | | **GitHub Enterprise Server** (GHES, self-hosted) | `https://HOST/api/v3` | `https://HOST/api/graphql` | `HOST` (e.g. `github.company.com`) | Enterprise Cloud uses the same `api.github.com` as public GitHub.com — the only differences are which orgs you can reach and which scopes your token has. You do not need to change anything beyond auth. **Enterprise Server is the one that needs special handling.** It runs on a customer-controlled hostname and exposes `/api/v3/` for REST (note the `/v3`) and `/api/graphql` for GraphQL (no `/v3`). Older GHES versions may also lack specific endpoints — Projects v2 requires **GHES 3.8 or newer**. Always confirm with `gh api /meta --hostname HOST` (or `curl -sI https://HOST/api/v3`) that the instance is reachable before you do anything else. **Configuring the gh CLI for GHES** — log in once per host: ```bash gh auth login --hostname github.company.com # pick HTTPS, then either browser or PAT gh auth status # shows every host you are authenticated against ``` Once logged in, `gh` stores that host's token separately. You then pick the target host on every call, either globally via the `GH_HOST` environment variable or per-command via `--hostname`: ```bash # Option A: set once per shell export GH_HOST=github.company.com gh repo view my-org/web gh project list --owner my-org # Option B: pass --hostname per command (more explicit # in automation) gh repo view my-org/web --hostname github.company.com gh project list --owner my-org --hostname github.company.com ``` **Raw API calls on Enterprise Server** — the `gh api` wrapper takes the same `--hostname` flag and routes through the right base URL automatically. Paths stay identical; `gh` prepends `/api/v3` for REST and hits `/api/graphql` directly for GraphQL: ```bash # REST (gh api prepends /api/v3 on GHES automatically) gh api /repos/my-org/web/issues --hostname github.company.com # GraphQL (gh api hits /api/graphql on GHES automatically) gh api graphql \ -f query='{ viewer { login } }' \ --hostname github.company.com ``` **Tokens for GHES** — do not use `GITHUB_TOKEN` for Enterprise Server. `gh` and GitHub Actions use `GH_ENTERPRISE_TOKEN` specifically for non-github.com hosts: ```bash export GH_ENTERPRISE_TOKEN=ghp_xxxxxxxx export GH_HOST=github.company.com gh project list --owner my-org ``` If you are calling the API directly with `curl` instead of `gh`, build the full URL from your hostname: ```bash GH_HOST=github.company.com GH_ENTERPRISE_TOKEN=ghp_xxxxxxxx # REST curl -sSf \ -H "Authorization: Bearer $GH_ENTERPRISE_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "https://$GH_HOST/api/v3/repos/my-org/web/issues" # GraphQL curl -sSf \ -H "Authorization: Bearer $GH_ENTERPRISE_TOKEN" \ -H "Content-Type: application/json" \ -d '{"query":"{ viewer { login } }"}' \ "https://$GH_HOST/api/graphql" ``` **TLS** — many GHES instances sit behind an internal CA. If your HTTP client rejects the certificate, either install the CA chain into the system trust store or point to it explicitly (`curl --cacert /path/to/ca.pem`, `NODE_EXTRA_CA_CERTS=/path/to/ca.pem` for Node). Do not use `-k` / skip verification in production; do the CA plumbing. **Enterprise-specific gotchas:** - **`/api/v3` for REST, `/api/graphql` for GraphQL.** No `/v3` on the GraphQL path. Getting these swapped produces `404`s that look like missing endpoints. - **Projects v2 needs GHES 3.8+.** Earlier versions have only classic Projects v1, which is deprecated and not covered by this skill. - **Single Sign-On (SSO) tokens.** On GitHub.com Enterprise Cloud with SAML SSO, tokens must also be explicitly authorized for the org (`gh auth refresh`, then "authorize" in the UI). A token that works for your personal repos will silently fail against SSO-enforcing orgs with no useful error. - **`rate_limit` endpoint is per-host.** On GHES it is `/api/v3/rate_limit`; admin-configured limits may be very different from github.com. - **Packages, GHCR, and LFS use different hostnames** (`containers.HOST`, `npm.HOST`, etc.) on some GHES deployments. Check `gh api /meta --hostname HOST` for the `packages`/`uploads` URLs before scripting against them. The rest of this skill uses `gh api` / `gh project` commands without the `--hostname` flag for readability. When operating on Enterprise Server, assume every such command needs either `GH_HOST` in the environment or a `--hostname HOST` flag appended.
Title
Authentication and required scopes
Body
The cleanest path is the `gh` CLI: ```bash gh auth login # interactive - pick GitHub.com, HTTPS, browser gh auth status # verify gh auth refresh -s project # add the project scope if missing gh auth refresh -s read:org # for reading org-owned projects ``` If you are using a personal access token directly (in CI or a server), you need: - **Classic PAT** (`ghp_...`): scopes `repo`, `project`, and `read:org`. For user-owned resources only, `read:user` is usually enough; for org-owned projects you need `read:org` and sometimes `write:org`. - **Fine-grained PAT** (`github_pat_...`): repository access for the repos you will touch, plus **Issues: Read and write**, **Pull requests: Read and write**, **Projects: Read and write**, and on the organization side **Members: Read**. Export it for `gh api` calls: ```bash export GITHUB_TOKEN=github_pat_XXX gh api /user # quick sanity check ``` Common failure: creating a project and getting `Resource not accessible by personal access token`. That is almost always the `project` scope (classic PAT) or the Projects permission (fine-grained PAT) being missing.
Title
The gh CLI as primary path
Body
`gh` already has dedicated subcommands for the common operations. Use these first before dropping to raw API calls: ```bash # Issues gh issue create --repo owner/repo \ --title "Implement login flow" \ --body "Details..." \ --assignee alice,bob \ --label "type:feature,priority:high" \ --milestone "Sprint 12" gh issue edit 42 --repo owner/repo --add-assignee alice gh issue edit 42 --repo owner/repo --add-label "blocked" gh issue close 42 --repo owner/repo gh issue list --repo owner/repo --json number,title,assignees # Projects v2 (must pass --owner explicitly for org projects) gh project list --owner my-org gh project create --owner my-org --title "Q2 Roadmap" gh project view 7 --owner my-org # Add an existing issue to a project gh project item-add 7 \ --owner my-org \ --url https://github.com/my-org/web/issues/42 # Create a draft issue directly in a project (no repo yet) gh project item-create 7 \ --owner my-org \ --title "Research: OIDC provider comparison" \ --body "Compare Auth0, Clerk, Ory Kratos..." # Edit a custom field on a project item gh project item-edit \ --id PVTI_xxx # project item id (NOT issue number) --field-id PVTSSF_xxx # the field id --project-id PVT_xxx # the project id --single-select-option-id OPT_xxx # List all items in a project gh project item-list 7 --owner my-org --format json ``` Every `gh project ...` subcommand accepts either `--owner` (user or org login) or can be run from a repo directory where `gh` will infer it. The project **number** is the small integer you see in the project URL (`.../projects/7`). Most project operations also require the project's **Node ID** (`PVT_xxx`) for field edits — you get that from `gh project view` with `--format json`.
Title
Finding Node IDs (the part that confuses everyone)
Body
GraphQL operates on Node IDs, not on numeric IDs or URLs. The Projects v2 API in particular requires several distinct Node IDs that you have to look up up front. Keep this glossary clear: | Node | ID prefix | How to find it | |--------------------------|------------------|----------------| | User | `U_` | `gh api graphql -f query='{ user(login:"alice") { id } }'` | | Organization | `O_` | `gh api graphql -f query='{ organization(login:"my-org") { id } }'` | | Repository | `R_` | `gh api graphql -f query='{ repository(owner:"o",name:"r") { id } }'` | | Issue | `I_` | REST `/repos/{o}/{r}/issues/{n}` returns `node_id` | | Pull Request | `PR_` | REST `/repos/{o}/{r}/pulls/{n}` returns `node_id` | | Project v2 | `PVT_` | `gh project view N --owner o --format json` | | Project v2 Item | `PVTI_` | `gh project item-list N --owner o --format json` | | Project v2 Field | `PVTF_` / `PVTSSF_` | `gh project field-list N --owner o --format json` | | Single select option | (no prefix) | `field-list --format json` - inside the field's `options` array | The practical pattern for any "update a project item's field" flow: 1. Find the project id (`PVT_...`) via `gh project view`. 2. Find the field id (`PVTSSF_...` for single-select, `PVTF_...` for text/number/date) via `gh project field-list`. 3. Find the option id (for single-select fields only) from the same field-list output. 4. Create or add the item, capturing its `PVTI_...` id. 5. Call `gh project item-edit` or the GraphQL `updateProjectV2ItemFieldValue` mutation with all four. **Never try to guess a Node ID from a numeric ID or URL** — there is no stable mapping. Always look them up from the API.
Title
Creating a project from scratch (GraphQL)
Body
`gh project create` is the fastest path, but if you need to do it from a script or want more control, the GraphQL path is three mutations: ```bash # 1. Resolve the owner Node ID OWNER_ID=$(gh api graphql -f query=' query { organization(login: "my-org") { id } } ' --jq '.data.organization.id') # 2. Create the project PROJECT_ID=$(gh api graphql -f query=" mutation { createProjectV2(input: { ownerId: \"$OWNER_ID\" title: \"Q2 Roadmap\" }) { projectV2 { id number url } } } " --jq '.data.createProjectV2.projectV2.id') echo "Created project: $PROJECT_ID" # 3. (Optional) Add a Priority single-select field gh api graphql -f query=" mutation { createProjectV2Field(input: { projectId: \"$PROJECT_ID\" dataType: SINGLE_SELECT name: \"Priority\" singleSelectOptions: [ { name: \"P0\", color: RED, description: \"Drop everything\" }, { name: \"P1\", color: ORANGE, description: \"This sprint\" }, { name: \"P2\", color: YELLOW, description: \"Next sprint\" }, { name: \"P3\", color: GRAY, description: \"Backlog\" } ] }) { projectV2Field { ... on ProjectV2SingleSelectField { id } } } } " ``` Field `dataType` options are `TEXT`, `NUMBER`, `DATE`, `SINGLE_SELECT`, and `ITERATION`. Every project starts with a built-in `Status` single-select field that already has `Todo`, `In Progress`, and `Done`.
Title
Creating and editing issues
Body
Prefer the `gh` CLI for interactive and scripted issue work. Drop to `gh api` only when you need something the CLI doesn't expose or when you're batching in a way the CLI wrappers make awkward. **Preferred: gh CLI** ```bash # Create gh issue create --repo my-org/web \ --title "Implement login flow" \ --body "## Acceptance criteria..." \ --assignee alice,bob \ --label "type:feature,priority:high" \ --milestone "Sprint 12" # Edit (add without removing existing) gh issue edit 42 --repo my-org/web \ --add-assignee alice \ --add-label "needs-review" # Close gh issue close 42 --repo my-org/web ``` **Advanced: raw REST via `gh api`** Use this form for bulk operations, CI/CD pipelines, or when you need fields the CLI wrappers don't cover (custom payloads, timelines, events). **Read the field syntax cheat sheet in the next section before writing any array values** — getting `-f` vs `-F` vs `-F :=` wrong is the single most common failure mode. ```bash # Create via REST gh api -X POST /repos/my-org/web/issues \ -f title="Implement login flow" \ -f body="## Acceptance criteria..." \ -F assignees:='["alice","bob"]' \ # note the colon before = ! -F labels:='["type:feature","priority:high"]' \ -F milestone=3 # Capture the node_id for later addProjectV2ItemById calls ISSUE_NODE_ID=$(gh api -X POST /repos/my-org/web/issues \ -f title="Another task" -f body="..." \ --jq '.node_id') # Edit via PATCH (replaces the entire assignees array) gh api -X PATCH /repos/my-org/web/issues/42 \ -F assignees:='["alice"]' \ -F labels:='["type:bug"]' \ -f state=closed ``` **Assignee operations are three different endpoints**, and the difference matters when you're updating existing issues: - `POST /repos/{o}/{r}/issues/{n}/assignees` — **adds** assignees to the existing set. Non-destructive. Use this when you want to add without removing. - `PATCH /repos/{o}/{r}/issues/{n}` with `assignees:='[...]'` — **replaces** the entire assignees array. Anyone not in the new list gets removed. - `DELETE /repos/{o}/{r}/issues/{n}/assignees` with `assignees:='[...]'` — **removes** the named assignees without touching the rest. The same pattern applies to labels (`POST` for add, `PUT` for replace, `DELETE` for remove).
Title
gh api field syntax cheat sheet
Body
Every flag in `gh api` either sends a string or sends a typed value, and they are NOT interchangeable. This one table will save you more debugging than any other page in this skill. | Value kind | Flag | Example | |--------------------------|-------------|-----------------------------------------------------| | String | `-f` | `-f title="Bug fix"` | | Number | `-F` | `-F milestone=3` | | Boolean | `-F` | `-F draft=true` | | **Array of strings** | **`-F :=`** | **`-F assignees:='["alice","bob"]'`** | | **JSON object literal** | **`-f :=`** | **`-f config:='{"enabled":true,"level":42}'`** | | `null` | `-F` | `-F milestone=null` | The `:=` suffix is what tells `gh api` to parse the value as a JSON literal instead of treating it as a string. It is the difference between: ```bash # WRONG — sends the literal string '["alice"]' -F assignees='["alice"]' # gh error: "For 'properties/assignees', # '["alice"]' is not an array." # RIGHT — sends a real JSON array -F assignees:='["alice"]' ``` And again for object values: ```bash # WRONG — sends the literal string '{"key":"value"}' -f metadata='{"key":"value"}' # RIGHT — sends a real JSON object -f metadata:='{"key":"value"}' ``` Rules of thumb: - If the value is a **primitive string**, use `-f field="value"`. - If the value is a **number, boolean, or null**, use `-F field=value`. - If the value is a **JSON array or object**, use `-F field:='json'` or `-f field:='json'`. The `:=` is mandatory, regardless of which flag you prefix it with. When in doubt, try the `gh <resource> edit` CLI wrapper first — it encodes all this for you.
Title
Adding issues to a project
Body
Issues exist independently of projects; adding them is a separate step. ```bash # By URL (simplest) gh project item-add 7 \ --owner my-org \ --url https://github.com/my-org/web/issues/42 # By Node ID via GraphQL (useful in scripts) gh api graphql -f query=" mutation { addProjectV2ItemById(input: { projectId: \"$PROJECT_ID\" contentId: \"$ISSUE_NODE_ID\" }) { item { id } } } " --jq '.data.addProjectV2ItemById.item.id' ``` The returned `item.id` is a `PVTI_...` Node ID — this is the project item id you use for all subsequent field updates. It is NOT the issue id. A single issue can belong to multiple projects, and each project has its own project-item id. To create a draft issue directly in a project (no repository yet, useful for quick capture), use `addProjectV2DraftIssue`: ```bash gh api graphql -f query=" mutation { addProjectV2DraftIssue(input: { projectId: \"$PROJECT_ID\" title: \"Research OIDC providers\" body: \"Compare Auth0, Clerk, Ory Kratos...\" }) { projectItem { id } } } " ```
Title
Setting custom fields on a project item
Body
The `updateProjectV2ItemFieldValue` mutation takes `projectId`, `itemId`, `fieldId`, and a typed `value`. The `value` shape depends on the field's dataType: ```graphql # Text field value: { text: "Some notes" } # Number field value: { number: 42 } # Date field (YYYY-MM-DD) value: { date: "2026-06-30" } # Single-select field (use an OPTION id, not the label) value: { singleSelectOptionId: "a1b2c3" } # Iteration field value: { iterationId: "iter_xxx" } ``` A concrete script that sets `Status = In Progress` on an item: ```bash # Get project, field, and option ids (once) PROJECT_ID=$(gh project view 7 --owner my-org --format json | jq -r '.id') FIELDS_JSON=$(gh project field-list 7 --owner my-org --format json) STATUS_FIELD_ID=$(echo "$FIELDS_JSON" | jq -r '.fields[] | select(.name=="Status") | .id') IN_PROGRESS_OPTION_ID=$(echo "$FIELDS_JSON" | \ jq -r '.fields[] | select(.name=="Status") | .options[] | select(.name=="In Progress") | .id') # Move the project item gh api graphql -f query=" mutation { updateProjectV2ItemFieldValue(input: { projectId: \"$PROJECT_ID\" itemId: \"$ITEM_ID\" fieldId: \"$STATUS_FIELD_ID\" value: { singleSelectOptionId: \"$IN_PROGRESS_OPTION_ID\" } }) { projectV2Item { id } } } " ``` Or with the CLI shortcut for single-select: ```bash gh project item-edit \ --id "$ITEM_ID" \ --project-id "$PROJECT_ID" \ --field-id "$STATUS_FIELD_ID" \ --single-select-option-id "$IN_PROGRESS_OPTION_ID" ```
Title
Assignees, labels, and milestones
Body
Assignees, labels, and milestones live on the ISSUE, not on the project item. Keep these on the issue and they flow automatically to every project board the issue is a member of. **Preferred: gh CLI** ```bash # Add without removing existing gh issue edit 42 --repo my-org/web --add-assignee alice gh issue edit 42 --repo my-org/web --add-label needs-review gh issue edit 42 --repo my-org/web --milestone "Sprint 12" # Replace all at once gh issue edit 42 --repo my-org/web --add-assignee alice,bob \ --add-label bug,backend # Remove specific ones gh issue edit 42 --repo my-org/web --remove-assignee bob gh issue edit 42 --repo my-org/web --remove-label stale ``` **Advanced: raw REST** Three distinct verbs, three different semantics. Array values MUST use `-F field:='json'` — see the field syntax cheat sheet. ```bash # Assignees: POST=add, PATCH=replace, DELETE=remove # Add without touching existing gh api -X POST /repos/my-org/web/issues/42/assignees \ -F assignees:='["carol"]' # Replace the entire set gh api -X PATCH /repos/my-org/web/issues/42 \ -F assignees:='["alice","bob"]' # Remove specific ones gh api -X DELETE /repos/my-org/web/issues/42/assignees \ -F assignees:='["bob"]' # Labels: PUT=replace, POST=add, DELETE=remove by name gh api -X PUT /repos/my-org/web/issues/42/labels \ -F labels:='["type:bug","severity:high"]' gh api -X POST /repos/my-org/web/issues/42/labels \ -F labels:='["needs-review"]' gh api -X DELETE /repos/my-org/web/issues/42/labels/stale # Milestones (by numeric id, -F because it's a number) gh api -X PATCH /repos/my-org/web/issues/42 -F milestone=3 ``` Creating the supporting resources once: ```bash gh api -X POST /repos/my-org/web/labels \ -f name="type:feature" -f color="1f77b4" \ -f description="New capability" gh api -X POST /repos/my-org/web/milestones \ -f title="Sprint 12" -f due_on="2026-05-01T00:00:00Z" ```
Title
End-to-end example, scripted
Body
A full "create project, create three issues, assign them, drop them in the project, move one to In Progress" flow: ```bash #!/usr/bin/env bash set -euo pipefail ORG=my-org REPO=web # 1. Create the project OWNER_ID=$(gh api graphql -f query="{ organization(login: \"$ORG\") { id } }" \ --jq '.data.organization.id') PROJECT_ID=$(gh api graphql -f query=" mutation { createProjectV2(input: { ownerId: \"$OWNER_ID\", title: \"Sprint 12\" }) { projectV2 { id number } } }" --jq '.data.createProjectV2.projectV2.id') # 2. Create three issues create_issue() { local title="$1"; local body="$2"; local assignees_json="$3" gh api -X POST "/repos/$ORG/$REPO/issues" \ -f title="$title" -f body="$body" -F assignees:="$assignees_json" \ --jq '.node_id' } ISSUE1=$(create_issue "Login form" "..." '["alice"]') ISSUE2=$(create_issue "OAuth callback" "..." '["bob"]') ISSUE3=$(create_issue "Session store" "..." '["alice","bob"]') # 3. Add all three issues to the project add_to_project() { local content_id="$1" gh api graphql -f query=" mutation { addProjectV2ItemById(input: { projectId: \"$PROJECT_ID\", contentId: \"$content_id\" }) { item { id } } }" --jq '.data.addProjectV2ItemById.item.id' } ITEM1=$(add_to_project "$ISSUE1") ITEM2=$(add_to_project "$ISSUE2") ITEM3=$(add_to_project "$ISSUE3") # 4. Move the first item to In Progress FIELDS=$(gh project field-list 1 --owner "$ORG" --format json) STATUS_FIELD=$(echo "$FIELDS" | jq -r '.fields[] | select(.name=="Status") | .id') IN_PROG_OPT=$(echo "$FIELDS" | \ jq -r '.fields[] | select(.name=="Status") | .options[] | select(.name=="In Progress") | .id') gh api graphql -f query=" mutation { updateProjectV2ItemFieldValue(input: { projectId: \"$PROJECT_ID\" itemId: \"$ITEM1\" fieldId: \"$STATUS_FIELD\" value: { singleSelectOptionId: \"$IN_PROG_OPT\" } }) { projectV2Item { id } } }" echo "Done. Project id: $PROJECT_ID" ``` This is the canonical shape. Adapt the REST / GraphQL mix to your needs — the key idea is "REST for issue lifecycle, GraphQL for project lifecycle and field updates."
Title
Querying state (reading what is there)
Body
The most useful read query for an agent inspecting a project: get every item with its linked content and status. ```bash gh api graphql -f query=" query(\$org: String!, \$number: Int!) { organization(login: \$org) { projectV2(number: \$number) { id title items(first: 100) { nodes { id content { ... on Issue { number title state repository { nameWithOwner } assignees(first: 5) { nodes { login } } } ... on PullRequest { number title state repository { nameWithOwner } } ... on DraftIssue { title body } } fieldValues(first: 20) { nodes { ... on ProjectV2ItemFieldSingleSelectValue { field { ... on ProjectV2FieldCommon { name } } name optionId } ... on ProjectV2ItemFieldTextValue { field { ... on ProjectV2FieldCommon { name } } text } ... on ProjectV2ItemFieldDateValue { field { ... on ProjectV2FieldCommon { name } } date } ... on ProjectV2ItemFieldNumberValue { field { ... on ProjectV2FieldCommon { name } } number } } } } } } } }" -f org="my-org" -F number=7 | jq . ``` Key GraphQL gotcha: field values come back as a union, so you must `... on ProjectV2ItemFieldSingleSelectValue` and friends for each concrete type. The field's own name is behind another fragment `... on ProjectV2FieldCommon` because `ProjectV2FieldConfiguration` is a union too.
Title
Bulk operations and rate limits
Body
REST: 5000 requests/hour for authenticated users (15000 for GitHub Apps). GraphQL: point-based — each query costs 1+ points depending on the number of nodes requested. See `rateLimit { remaining, resetAt, cost }` fields. ```graphql query { rateLimit { limit cost remaining resetAt } ...rest_of_query } ``` When bulk-creating issues (say 50+), batch with parallelism but back off on `403`/`429`. A safe pattern: ```bash for row in $(cat tasks.csv); do title=$(echo "$row" | cut -d, -f1) body=$( echo "$row" | cut -d, -f2) gh api -X POST "/repos/$ORG/$REPO/issues" -f title="$title" -f body="$body" >/dev/null & # throttle: wait every 5 concurrent calls (( $(jobs -rp | wc -l) >= 5 )) && wait -n done wait ``` Or use GraphQL to do several `createIssue` mutations in one request (note GraphQL issue creation also exists as `createIssue` mutation but requires the repository Node ID and less-commonly-used fields).
Title
Pagination and large projects
Body
Every GraphQL connection on GitHub's API is paginated, and the defaults are small. A naive `items { nodes { ... } }` query returns only the FIRST N items, where N is however many fit in GitHub's default page size (often as low as 1 or 10 depending on the field). If you see fewer items than you expect, the first thing to check is your page size. **Always pass `first:`** on `ProjectV2.items`, `ProjectV2.fields`, and any other connection: ```graphql query($org: String!, $number: Int!) { organization(login: $org) { projectV2(number: $number) { items(first: 100) { # pick a page size totalCount # always request this nodes { id content { ... on Issue { number title } } } pageInfo { # needed for cursor pagination hasNextPage endCursor } } } } } ``` Request `totalCount` on every connection query and compare it to `nodes.length`. If they differ, you're paginating and need to either bump `first:` or implement cursor iteration. **Cursor iteration for >100 items** — GraphQL's hard ceiling for `first:` is 100 on most connections. For larger projects, walk the cursor: ```bash CURSOR=null while : ; do RESULT=$(gh api graphql -f query=" query(\$org: String!, \$number: Int!, \$after: String) { organization(login: \$org) { projectV2(number: \$number) { items(first: 100, after: \$after) { totalCount nodes { id content { ... on Issue { number title } } } pageInfo { hasNextPage endCursor } } } } }" -f org=my-org -F number=7 -F after="$CURSOR") echo "$RESULT" | jq '.data.organization.projectV2.items.nodes[]' HAS_NEXT=$(echo "$RESULT" | jq -r '.data.organization.projectV2.items.pageInfo.hasNextPage') [ "$HAS_NEXT" = "true" ] || break CURSOR=$(echo "$RESULT" | jq -r '.data.organization.projectV2.items.pageInfo.endCursor') done ``` This pattern applies to `issues`, `comments`, `labels`, `assignees`, `fields`, and every other connection in the GitHub GraphQL schema. When a query silently returns "not enough" results, suspect pagination first. **Project board views may lag issue writes.** After you edit an issue via REST (adding assignees, for example), a GraphQL project query might still show the old state for a few seconds due to cache propagation on GitHub's side. If you need a sanity check, query the issue directly (`/repos/{o}/{r}/issues/{n}`) — the REST issue endpoint is always authoritative. In tests and scripts, either add a short retry loop or accept that the board view is eventually consistent, not immediately consistent.
Title
Troubleshooting
Body
**"For 'properties/assignees', '[...]' is not an array"** You used `-F assignees='["alice"]'` instead of `-F assignees:='["alice"]'`. The `:=` is mandatory for JSON arrays and objects in `gh api`; without it, the value is sent as a literal string. See the field syntax cheat sheet. Alternatively, use `gh issue edit --add-assignee alice`. **"Project query shows empty assignees but the issue API shows them."** The project board view is eventually consistent with issue writes. Wait a few seconds and re-query, or hit `/repos/{o}/{r}/issues/{n}` directly to get the authoritative state. **"I only see 1 (or 10, or 20) items in my project."** You're hitting GraphQL pagination defaults. Check `items.totalCount` and compare it to the number of returned nodes. Bump `first:` or implement cursor iteration (see the Pagination section). **"Resource not accessible by personal access token."** Your token is missing the `project` scope (classic PAT) or the **Projects: Read and write** permission (fine-grained PAT). On github.com Enterprise Cloud with SAML SSO enforcement, you also need to authorize the token for the org — go to the token page and click "Configure SSO". **"Silently operates on github.com when I meant my GHES instance."** You forgot `GH_HOST` / `--hostname`. Set `export GH_HOST=github.company.com` at the top of any Enterprise Server script so the default can never bite you. For tokens, remember GHES wants `GH_ENTERPRISE_TOKEN`, not `GITHUB_TOKEN`. **"createProjectV2 / addProjectV2ItemById returns a 404 on GHES."** Your Enterprise Server is older than 3.8. Projects v2 requires GHES 3.8 or newer. Upgrade the server or fall back to the deprecated Projects v1 API (not covered by this skill — migrate as soon as you can). **"The project Node ID from `gh project view` is prefixed with `PVT_` but my script expects `P_` or `PR_`."** Those are different things. `PVT_` is Projects v2, `P_` was the legacy Projects v1, `PR_` is a Pull Request. Refer to the Node ID glossary — the prefix identifies the object class. **"I set a single-select field but nothing changed."** You passed the option NAME instead of the option ID. The `value: { singleSelectOptionId: ... }` mutation requires the opaque option id, which you get from `gh project field-list --format json`. Look up the id once, cache it for the life of your script. **"addProjectV2ItemById returns null with no error."** The `contentId` you passed is not a recognized issue or pull request Node ID. Common causes: you passed the numeric issue number, you passed a `PVTI_` project item id (a different object), or the token can't see the target repository.
Title
Pitfalls and gotchas
Body
- **Projects v1 is deprecated.** Never use `POST /projects` or any `/projects/columns/*` endpoint. Those are the old projects. Only use `projectV2` GraphQL and `gh project` CLI. - **`project` scope is not `repo` scope.** Creating a project but reading its repository items needs both. - **Numeric issue number is NOT the issue Node ID.** REST returns `number: 42` and `node_id: "I_xxx"`. GraphQL requires the Node ID. - **Project item id is NOT the issue id.** An issue added to a project gets a fresh `PVTI_` Node ID per project. If the same issue is on two projects, it has two different item ids. - **`-f` vs `-F` in `gh api`.** `-f` sends strings; `-F` sends JSON-typed fields (arrays, numbers, booleans). Assignees, labels, and anything boolean MUST use `-F`. - **Option id vs option name.** When setting a single-select field, the `value: { singleSelectOptionId: ... }` takes the **option id** (opaque string), not its display name. Look it up via `gh project field-list`. - **Draft issues do not have a repository.** `content` on a draft project item is of union type `DraftIssue`, which has no `number`, `state`, or `assignees`. Handle the union explicitly. - **Org-level projects require `read:org`** for the user or token. Missing this scope returns a `null` project without a clear error. - **Issue closed state.** REST `state` is `open`/`closed`. GraphQL `state` is `OPEN`/`CLOSED`. Do not mix cases. - **Assignees are users, not emails.** Use login handles (`alice`, not `alice@example.com`). Non-existent logins silently validate and then do nothing. - **You cannot add a non-member as assignee** on a private repository. The API will silently drop the assignment. - **Enterprise Server needs `--hostname` everywhere.** If a command silently operates on github.com when you meant your GHES instance, you forgot `GH_HOST` or `--hostname`. Set `GH_HOST` once at the top of any script that targets a GHES host so you cannot forget. - **Use `GH_ENTERPRISE_TOKEN`, not `GITHUB_TOKEN`, for GHES.** `gh` treats `GITHUB_TOKEN` as github.com-only. Exporting the wrong one leads to auth-works-for-public- calls-but-fails-on-private-repos confusion.

Referenced by

Uses Skill