GitHub Project Management
Primary skill for organizing a repository's issues into an epic-based roadmap on a GitHub Project (v2) and running the day-to-day on it. Covers setup (project, fields, views, native workflows) and operations (create epics with sub-issues, drive Status, move work, prioritize). GitHub-only, via gh project + gh api (REST sub-issues + GraphQL).
The conventions here are non-obvious and easy to get half-right: the UI nests issues by native parent/child links and tracks progress on a separate board Status field -- neither of which a markdown checklist or a closed issue touches. Get those two wrong and the work looks done while the structure silently drifts. This skill makes the flow correct-by-default.
When to Use
- Setting up a project/roadmap -- create the Project, fields (Status, Priority), the Epic + Upcoming views, enable native workflows
- Creating an epic with issues -- "make an epic X with these issues", "group these under an epic"
- Filing / moving work -- attach an issue under an epic, move an issue between epics, add new work
- Driving the board -- pick up an issue (-> In Progress), close one (-> Done), (re)prioritize
- Classifying work -- set native issue Types (Task/Bug/Feature) instead of type labels
- Milestone scoping -- create a milestone for a deliverable, assign issues, close it out; resolve "current milestone" / "milestone N"
- Migrating an existing repo's loose issues into this structure
- Configuring tool allowlists -- auto-approval patterns for read-only
gh project commands
Critical Rules
These are the traps -- each is a place where the obvious action leaves the structure wrong.
- Epics nest by native sub-issues, NOT markdown checklists. A
- [ ] #123 bullet is cosmetic; it does not create the parent/child link the UI and roadmap read. Link natively: gh issue edit <epic> --add-sub-issue <child> / gh issue create --parent <epic> (gh >= 2.94), or the REST sub-issues API. See references/sub-issues.md.
- Moving an issue between epics = re-parent the native link. One command:
gh issue edit <child> --parent <newEpic> (replaces the old parent), then tidy body text. Editing bullets alone leaves it under the old epic -- the most common "looks moved but isn't" miss.
- The board Status is not automatic. Closing an issue does not set
Done unless the native Item closed workflow is enabled. Set Status explicitly, or enable the native workflows once (see references/project-setup.md).
- Single-selects (Status, Priority) are set by option ID, not name. Look up field IDs + option IDs first (
gh project field-list <num> --owner @me --format json), then item-edit. Names silently no-op.
- Views and workflow-enabling are one-time UI; everything else is scripted. There is no API to create/rename a view or toggle a workflow. Do those once in the UI (or copy a template); script the rest.
- Type and Milestone are issue metadata, NOT project fields. Set them on the issue (
gh issue edit <n> --type Bug --milestone "v1.0"); the board mirrors them as built-in columns for grouping/filtering, but gh project item-edit cannot touch them and they never appear in field-list. Issue types exist only in org repos. See references/types-and-milestones.md.
Prerequisites
- GitHub only -- Projects v2 has no GitLab equivalent.
gh >= 2.94 for the native issue flags used here (--type, --milestone, --parent, --add-sub-issue); older gh falls back to the REST recipes in the references.
- Token scopes gate what works.
gh auth status shows them. gh project commands and any projectV2 GraphQL need the project scope (gh auth refresh -s project); read:project alone is enough for read-only queries. Without the scope these fail with auth/permission errors even though everything repo-scoped works -- the classic "project features seem missing" trap. Issues, sub-issues, types, and milestones ride on the ordinary repo scope; org-owned project access also relies on read:org.
- Projects are addressed by number under an
--owner (@me or an org login). The repo and project may have different owners. For an org-owned project, pass the org login to --owner, and in GraphQL reads use organization(login: "ORG") { projectV2 } instead of viewer { projectV2 } (see references/cli-and-graphql.md).
The Model
| Layer |
What it is |
Mechanism |
| Epic |
A big work item -- an issue titled Epic: ..., labeled epic |
gh issue create + the epic label |
| Sub-issue |
A unit of work homed under exactly one epic |
native parent/child link (--parent / --add-sub-issue) |
| Type |
What kind of work: Task / Bug / Feature (org repos only) |
native issue type (gh issue edit --type), not labels |
| Milestone |
A dated, closeable scope -- a release or delivery slice, often cross-epic |
repo milestone (gh issue edit --milestone); mirrored on the board |
| Board state |
Where each item sits + its priority |
Project fields: Status (Todo/In Progress/Done), Priority (single-select) |
Every issue is homed under an epic (or is one). Priority lives on the Project Priority field (set via item-edit) and kind lives on the native issue Type -- neither is a label. That keeps label noise near zero: the only label in play is epic (identifies epics for filtering/grouping; if the org defines a custom Epic issue type, prefer that and drop even this label). Epics answer "what theme does this belong to"; milestones answer "when does this scope ship" -- an issue typically has both. The Epic view shows the tree; the Upcoming view filters out Done and sorts by Priority.
Operations (Playbooks)
Concrete sequences. {owner}/{repo} are filled by gh from the current repo; <num> is the project number. ID-discovery details are in references/cli-and-graphql.md.
Create an epic with issues
# Epic issue (gh issue create prints the URL; the number is its last path segment)
epic=$(gh issue create --title "Epic: <name>" --label epic --body "<goal>"); epic=${epic##*/}
# Link existing issues as NATIVE sub-issues (flag repeats; on a partial GraphQL failure re-run -- it's a
# transient sub-issue burst limit, and already-linked children are unaffected)
gh issue edit $epic --add-sub-issue <existing#> --add-sub-issue <existing#>
# New work goes straight under the epic, typed and scoped at creation
# (--type is org-only: DROP it on personal repos or the whole command fails with 'type not found')
gh issue create --title "<task>" --type Task --milestone "<title>" --parent $epic --body "<detail>"
# Put the epic on the board; its children auto-join IF the native "Auto-add sub-issues" workflow is on
gh project item-add <num> --owner @me --url "$(gh issue view $epic --json url --jq .url)"
# (if that workflow is off, item-add each child's URL too)
Add an issue to an existing epic
gh issue edit <epic> --add-sub-issue <child> # by number or URL; also re-parents if homed elsewhere
Move an issue between epics (re-parent -- rule 2)
gh issue edit <child> --parent <newEpic> # replaces the old parent in one step
# then tidy any body bullets that referenced the old epic
Classify and scope (Type + Milestone -- rule 6)
gh issue edit <n> --type Bug # org repos only; Task/Bug/Feature (+ org customs)
gh issue edit <n> --milestone "v1.0" # by TITLE; --remove-milestone / --remove-type to unset
gh issue list --milestone "v1.0" --json number,title,state # -m takes a title or the milestone NUMBER
Milestones (create, resolve "current", close out a scope)
"Current milestone" = the open milestone with the smallest due date -- past-due included, that's the most urgent one. "Milestone N" = the milestone with number N (the /milestone/N URL segment), never "the Nth open one".
# create (no gh subcommand -- REST)
gh api --method POST repos/{owner}/{repo}/milestones -f title="v1.0" -f due_on="2026-08-01T00:00:00Z" -f description="<scope>"
# current milestone (open only; undated ones sort last)
gh api repos/{owner}/{repo}/milestones --jq 'sort_by(.due_on // "9999-12-31") | first | {number, title, due_on}'
# close out the scope once nothing is left open in it
gh api repos/{owner}/{repo}/milestones/<N> --jq '{title, open_issues}' # expect open_issues: 0
gh api --method PATCH repos/{owner}/{repo}/milestones/<N> -f state=closed
Pick up / finish / prioritize (board Status + Priority)
# Discover the item id + field/option ids once (see references/cli-and-graphql.md), then:
# pick up
gh project item-edit --id <item> --project-id <proj> --field-id <statusField> --single-select-option-id <inProgress>
# finish -- close the issue (pick ONE close form; the plain one means "completed"):
gh issue close <issue> # done as planned
gh issue close <issue> --reason "not planned" # abandoned
gh issue close <issue> --duplicate-of <original> # duplicate; links it natively to the original (gh >= 2.88)
# closing normally advances the board via the native "Item closed" workflow -- nothing more to do.
# ONLY IF that workflow is off, set Status manually:
gh project item-edit --id <item> --project-id <proj> --field-id <statusField> --single-select-option-id <done>
# (re)prioritize
gh project item-edit --id <item> --project-id <proj> --field-id <priorityField> --single-select-option-id <p1>
Reference: references/cli-and-graphql.md -- full command set, getting <item>/<proj>/field+option ids (field-list/item-list --format json), and updateProjectV2ItemPosition for roadmap ordering. references/sub-issues.md -- native link flags, the REST fallback (database-id requirement), re-parenting, and the ~25/request batch limit. references/types-and-milestones.md -- org issue types, milestone CRUD, and the current-milestone / milestone-N conventions in full.
Setup (one-time)
Reference: references/project-setup.md -- end to end: create the Project, add Status/Priority (and optional Stage) fields, build the Epic and Upcoming views (UI -- no API), enable the native workflows (Item added, Auto-add sub-issues, Item closed -> Done), the optional template-copy fast-path, and the migration playbook for an existing repo.
Quick shape:
gh project create --owner @me --title "Roadmap"
gh project field-create <num> --owner @me --name "Priority" --data-type SINGLE_SELECT \
--single-select-options "P0,P1,P2,P3"
gh label create epic --color B60205 --description "Roadmap epic"
Then, once in the UI: the Epic + Upcoming views and Settings -> Workflows toggles (these have no API).
Read-Only vs Write Classification
- Read-only (safe to auto-approve):
gh project list/view/field-list/item-list, GraphQL read queries, gh label list, gh issue list/view, milestone reads (gh api repos/{owner}/{repo}/milestones), issue-type reads (gh api orgs/{org}/issue-types)
- Write (require approval):
gh project create/copy/edit/link/field-create/item-add/item-edit/item-archive/item-delete, gh label create, gh issue create/edit (incl. --type/--milestone/--parent/--add-sub-issue), milestone POST/PATCH/DELETE, sub-issue POST/DELETE, GraphQL mutations
Reference: See references/allowlist.md for read-only gh project patterns and the opt-in write set.
Key Gotchas
- Native links, not checklists -- the tree is built from sub-issue links; bullets are decoration (rule 1).
- Re-parent to move --
gh issue edit <child> --parent <newEpic>; body edits alone don't move it (rule 2).
- REST sub-issues take the database
id -- if you drop to gh api .../sub_issues, fetch it with --jq .id; the issue number won't work (the native gh flags take numbers/URLs).
- Single-selects set by option id -- discover ids with
field-list --format json before item-edit.
- Views + workflow toggles are UI-only -- no API; do them once (or copy a template) (rule 5).
- Sub-issue mutations batch ~25/request -- larger batches hit
RESOURCE_LIMITS_EXCEEDED; a multi---add-sub-issue edit can partially fail on the same limit -- re-running is safe.
- Type/Milestone are not project fields -- set on the issue, mirrored on the board;
item-edit can't set them and field-list won't show them (rule 6). Types are org-only (--type on a personal repo: type "..." not found; available types: -- fall back to labels there).
- "Current milestone" = smallest due date among open (past-due included); "milestone N" = number N, not the Nth open (see
references/types-and-milestones.md).
1---2name: git-project3description: GitHub Projects (v2) setup and management via gh CLI + GraphQL: organize a repo's issues into epics with native sub-issues, drive a board Status flow (Todo -> In Progress -> Done), set Project Priority, classify with native issue Types (Task/Bug/Feature) instead of labels, scope work with milestones (incl. resolving "current milestone"), and maintain an Epic + Upcoming roadmap. Use when setting up or managing a GitHub Project/roadmap, creating an epic with issues, adding or moving issues between epics, picking up or closing work on the board, (re)prioritizing, setting issue types, creating or closing milestones, enabling project workflows, or configuring gh project read-only allowlists. Not for PR workflows (git-pr), CI/CD status (git-ci), or commit messages (git-commit)4---56# GitHub Project Management78**Primary skill for organizing a repository's issues into an epic-based roadmap on a GitHub Project (v2) and running the day-to-day on it.** Covers setup (project, fields, views, native workflows) and operations (create epics with sub-issues, drive Status, move work, prioritize). GitHub-only, via `gh project` + `gh api` (REST sub-issues + GraphQL).910The conventions here are non-obvious and easy to get half-right: the UI nests issues by **native parent/child links** and tracks progress on a **separate board Status field** -- neither of which a markdown checklist or a closed issue touches. Get those two wrong and the work *looks* done while the structure silently drifts. This skill makes the flow correct-by-default.1112## When to Use1314- **Setting up a project/roadmap** -- create the Project, fields (Status, Priority), the Epic + Upcoming views, enable native workflows15- **Creating an epic with issues** -- "make an epic X with these issues", "group these under an epic"16- **Filing / moving work** -- attach an issue under an epic, **move an issue between epics**, add new work17- **Driving the board** -- pick up an issue (-> In Progress), close one (-> Done), (re)prioritize18- **Classifying work** -- set native issue Types (Task/Bug/Feature) instead of type labels19- **Milestone scoping** -- create a milestone for a deliverable, assign issues, close it out; resolve "current milestone" / "milestone N"20- **Migrating** an existing repo's loose issues into this structure21- **Configuring tool allowlists** -- auto-approval patterns for read-only `gh project` commands2223## Critical Rules2425These are the traps -- each is a place where the obvious action leaves the structure wrong.26271. **Epics nest by native sub-issues, NOT markdown checklists.** A `- [ ] #123` bullet is cosmetic; it does not create the parent/child link the UI and roadmap read. Link natively: `gh issue edit <epic> --add-sub-issue <child>` / `gh issue create --parent <epic>` (gh >= 2.94), or the REST sub-issues API. See `references/sub-issues.md`.282. **Moving an issue between epics = re-parent the native link.** One command: `gh issue edit <child> --parent <newEpic>` (replaces the old parent), then tidy body text. Editing bullets alone leaves it under the old epic -- the most common "looks moved but isn't" miss.293. **The board Status is not automatic.** Closing an issue does not set `Done` unless the native *Item closed* workflow is enabled. Set Status explicitly, or enable the native workflows once (see `references/project-setup.md`).304. **Single-selects (Status, Priority) are set by option ID, not name.** Look up field IDs + option IDs first (`gh project field-list <num> --owner @me --format json`), then `item-edit`. Names silently no-op.315. **Views and workflow-enabling are one-time UI; everything else is scripted.** There is no API to create/rename a view or toggle a workflow. Do those once in the UI (or copy a template); script the rest.326. **Type and Milestone are issue metadata, NOT project fields.** Set them on the issue (`gh issue edit <n> --type Bug --milestone "v1.0"`); the board mirrors them as built-in columns for grouping/filtering, but `gh project item-edit` cannot touch them and they never appear in `field-list`. Issue types exist only in **org** repos. See `references/types-and-milestones.md`.3334## Prerequisites3536- **GitHub only** -- Projects v2 has no GitLab equivalent.37- **`gh` >= 2.94** for the native issue flags used here (`--type`, `--milestone`, `--parent`, `--add-sub-issue`); older `gh` falls back to the REST recipes in the references.38- **Token scopes gate what works.** `gh auth status` shows them. `gh project` commands and any `projectV2` GraphQL need the `project` scope (`gh auth refresh -s project`); `read:project` alone is enough for read-only queries. Without the scope these fail with auth/permission errors even though everything `repo`-scoped works -- the classic "project features seem missing" trap. Issues, sub-issues, types, and milestones ride on the ordinary `repo` scope; org-owned project access also relies on `read:org`.39- Projects are addressed by **number** under an `--owner` (`@me` or an org login). The repo and project may have different owners. For an **org-owned** project, pass the org login to `--owner`, and in GraphQL reads use `organization(login: "ORG") { projectV2 }` instead of `viewer { projectV2 }` (see `references/cli-and-graphql.md`).4041## The Model4243| Layer | What it is | Mechanism |44|-------|-----------|-----------|45| **Epic** | A big work item -- an issue titled `Epic: ...`, labeled `epic` | `gh issue create` + the `epic` label |46| **Sub-issue** | A unit of work homed under exactly one epic | native parent/child link (`--parent` / `--add-sub-issue`) |47| **Type** | What kind of work: Task / Bug / Feature (org repos only) | native **issue type** (`gh issue edit --type`), not labels |48| **Milestone** | A dated, closeable scope -- a release or delivery slice, often cross-epic | repo milestone (`gh issue edit --milestone`); mirrored on the board |49| **Board state** | Where each item sits + its priority | Project fields: **Status** (Todo/In Progress/Done), **Priority** (single-select) |5051Every issue is homed under an epic (or is one). **Priority lives on the Project `Priority` field** (set via `item-edit`) and **kind lives on the native issue Type** -- neither is a label. That keeps label noise near zero: the only label in play is `epic` (identifies epics for filtering/grouping; if the org defines a custom `Epic` issue type, prefer that and drop even this label). Epics answer "what theme does this belong to"; milestones answer "when does this scope ship" -- an issue typically has both. The **Epic** view shows the tree; the **Upcoming** view filters out Done and sorts by Priority.5253## Operations (Playbooks)5455Concrete sequences. `{owner}/{repo}` are filled by gh from the current repo; `<num>` is the project number. ID-discovery details are in `references/cli-and-graphql.md`.5657### Create an epic with issues5859```bash60# Epic issue (gh issue create prints the URL; the number is its last path segment)61epic=$(gh issue create --title "Epic: <name>" --label epic --body "<goal>"); epic=${epic##*/}62# Link existing issues as NATIVE sub-issues (flag repeats; on a partial GraphQL failure re-run -- it's a63# transient sub-issue burst limit, and already-linked children are unaffected)64gh issue edit $epic --add-sub-issue <existing#> --add-sub-issue <existing#>65# New work goes straight under the epic, typed and scoped at creation66# (--type is org-only: DROP it on personal repos or the whole command fails with 'type not found')67gh issue create --title "<task>" --type Task --milestone "<title>" --parent $epic --body "<detail>"68# Put the epic on the board; its children auto-join IF the native "Auto-add sub-issues" workflow is on69gh project item-add <num> --owner @me --url "$(gh issue view $epic --json url --jq .url)"70# (if that workflow is off, item-add each child's URL too)71```7273### Add an issue to an existing epic7475```bash76gh issue edit <epic> --add-sub-issue <child> # by number or URL; also re-parents if homed elsewhere77```7879### Move an issue between epics (re-parent -- rule 2)8081```bash82gh issue edit <child> --parent <newEpic> # replaces the old parent in one step83# then tidy any body bullets that referenced the old epic84```8586### Classify and scope (Type + Milestone -- rule 6)8788```bash89gh issue edit <n> --type Bug # org repos only; Task/Bug/Feature (+ org customs)90gh issue edit <n> --milestone "v1.0" # by TITLE; --remove-milestone / --remove-type to unset91gh issue list --milestone "v1.0" --json number,title,state # -m takes a title or the milestone NUMBER92```9394### Milestones (create, resolve "current", close out a scope)9596"**Current milestone**" = the open milestone with the **smallest due date** -- past-due included, that's the most urgent one. "**Milestone N**" = the milestone with **number N** (the `/milestone/N` URL segment), never "the Nth open one".9798```bash99# create (no gh subcommand -- REST)100gh api --method POST repos/{owner}/{repo}/milestones -f title="v1.0" -f due_on="2026-08-01T00:00:00Z" -f description="<scope>"101# current milestone (open only; undated ones sort last)102gh api repos/{owner}/{repo}/milestones --jq 'sort_by(.due_on // "9999-12-31") | first | {number, title, due_on}'103# close out the scope once nothing is left open in it104gh api repos/{owner}/{repo}/milestones/<N> --jq '{title, open_issues}' # expect open_issues: 0105gh api --method PATCH repos/{owner}/{repo}/milestones/<N> -f state=closed106```107108### Pick up / finish / prioritize (board Status + Priority)109110```bash111# Discover the item id + field/option ids once (see references/cli-and-graphql.md), then:112# pick up113gh project item-edit --id <item> --project-id <proj> --field-id <statusField> --single-select-option-id <inProgress>114# finish -- close the issue (pick ONE close form; the plain one means "completed"):115gh issue close <issue> # done as planned116gh issue close <issue> --reason "not planned" # abandoned117gh issue close <issue> --duplicate-of <original> # duplicate; links it natively to the original (gh >= 2.88)118# closing normally advances the board via the native "Item closed" workflow -- nothing more to do.119# ONLY IF that workflow is off, set Status manually:120gh project item-edit --id <item> --project-id <proj> --field-id <statusField> --single-select-option-id <done>121# (re)prioritize122gh project item-edit --id <item> --project-id <proj> --field-id <priorityField> --single-select-option-id <p1>123```124125> **Reference**: `references/cli-and-graphql.md` -- full command set, getting `<item>`/`<proj>`/field+option ids (`field-list`/`item-list --format json`), and `updateProjectV2ItemPosition` for roadmap ordering. `references/sub-issues.md` -- native link flags, the REST fallback (database-id requirement), re-parenting, and the ~25/request batch limit. `references/types-and-milestones.md` -- org issue types, milestone CRUD, and the current-milestone / milestone-N conventions in full.126127## Setup (one-time)128129> **Reference**: `references/project-setup.md` -- end to end: create the Project, add `Status`/`Priority` (and optional `Stage`) fields, build the **Epic** and **Upcoming** views (UI -- no API), enable the native workflows (*Item added*, *Auto-add sub-issues*, *Item closed -> Done*), the optional template-copy fast-path, and the **migration** playbook for an existing repo.130131Quick shape:132```bash133gh project create --owner @me --title "Roadmap"134gh project field-create <num> --owner @me --name "Priority" --data-type SINGLE_SELECT \135 --single-select-options "P0,P1,P2,P3"136gh label create epic --color B60205 --description "Roadmap epic"137```138Then, once in the UI: the Epic + Upcoming views and Settings -> Workflows toggles (these have no API).139140## Read-Only vs Write Classification141142- **Read-only** (safe to auto-approve): `gh project list/view/field-list/item-list`, GraphQL read queries, `gh label list`, `gh issue list/view`, milestone reads (`gh api repos/{owner}/{repo}/milestones`), issue-type reads (`gh api orgs/{org}/issue-types`)143- **Write** (require approval): `gh project create/copy/edit/link/field-create/item-add/item-edit/item-archive/item-delete`, `gh label create`, `gh issue create/edit` (incl. `--type/--milestone/--parent/--add-sub-issue`), milestone `POST`/`PATCH`/`DELETE`, sub-issue `POST`/`DELETE`, GraphQL mutations144145> **Reference**: See `references/allowlist.md` for read-only `gh project` patterns and the opt-in write set.146147## Key Gotchas1481491. **Native links, not checklists** -- the tree is built from sub-issue links; bullets are decoration (rule 1).1502. **Re-parent to move** -- `gh issue edit <child> --parent <newEpic>`; body edits alone don't move it (rule 2).1513. **REST sub-issues take the database `id`** -- if you drop to `gh api .../sub_issues`, fetch it with `--jq .id`; the issue *number* won't work (the native `gh` flags take numbers/URLs).1524. **Single-selects set by option id** -- discover ids with `field-list --format json` before `item-edit`.1535. **Views + workflow toggles are UI-only** -- no API; do them once (or copy a template) (rule 5).1546. **Sub-issue mutations batch ~25/request** -- larger batches hit `RESOURCE_LIMITS_EXCEEDED`; a multi-`--add-sub-issue` edit can partially fail on the same limit -- re-running is safe.1557. **Type/Milestone are not project fields** -- set on the issue, mirrored on the board; `item-edit` can't set them and `field-list` won't show them (rule 6). Types are org-only (`--type` on a personal repo: `type "..." not found; available types:` -- fall back to labels there).1568. **"Current milestone" = smallest due date among open** (past-due included); **"milestone N" = number N**, not the Nth open (see `references/types-and-milestones.md`).