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