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).
Verified against gh v2.102.0 (2026-09-30): every gh subcommand and flag used here, against its --help. Minimums are tagged where they matter (gh >= 2.94, >= 2.97). If gh --version is newer, read the release notes since v2.102.0 for gh project and gh issue changes before relying on flag details.
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
- Granting access -- a teammate cannot apply labels, assign, link sub-issues, or edit Status/Priority
- Configuring tool allowlists -- auto-approval patterns for read-only
gh projectcommands
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
- [ ] #123bullet 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. Seereferences/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
Doneunless the native Item closed workflow is enabled. Set Status explicitly, or enable the native workflows once (seereferences/project-setup.md). - Set board fields by name (gh >= 2.97).
gh project item-edit <num> --owner @me --url <issue-url> --field Status --value "In Progress"resolves the item, the field and the single-select option from their names.--urlis the issue URL, not the project's. The ID form (--id/--project-id/--field-id/--single-select-option-id) is for olderghand for loops that should not repeat the lookups; seereferences/cli-and-graphql.md. - 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, butgh project item-editcannot touch them and they never appear infield-list. Issue types exist only in org repos. Seereferences/types-and-milestones.md. - Repo role and project role are separate gates. Repo Write does not let a teammate set Status or Priority; project Write does not let them apply a label or assign anyone. Someone running the roadmap needs repo Triage + project Write -- and project access has no
gh projectsubcommand at all (GraphQLupdateProjectV2Collaborators). Seereferences/access-and-roles.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); olderghfalls back to the REST recipes in the references.gh>= 2.97 for name-baseditem-edit(rule 4).- Token scopes gate what works.
gh auth statusshows them.gh projectcommands and anyprojectV2GraphQL need theprojectscope (gh auth refresh -s project);read:projectalone is enough for read-only queries. Without the scope these fail with auth/permission errors even though everythingrepo-scoped works -- the classic "project features seem missing" trap. Issues, sub-issues, types, and milestones ride on the ordinaryreposcope; org-owned project access also relies onread:org. - Scopes gate your automation; roles gate your teammates. A role gap hides UI controls rather than explaining itself, so it reads as a broken board (the same call over the API does fail, with a 403 or a 404 that hides the resource). Triage is the minimum for labels/assignees/sub-issues, project Write for Status/Priority. Team grants need
admin:orgon a classic token (fine-grained: orgMembers: writefor membership, repoAdministration: writefor the team-repo grant), which a defaultghlogin does not carry (an org-settings PATCH also acceptsrepo) -- and a caller role on top: org owner or team maintainer to change membership, admin on the repo to grant it to a team, project Admin (org owners have it) to change project collaborators. Seereferences/access-and-roles.md. - Projects are addressed by number under an
--owner(@meor 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 useorganization(login: "ORG") { projectV2 }instead ofviewer { projectV2 }(seereferences/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}' --method GET
# close out the scope once nothing is left open in it
gh api repos/{owner}/{repo}/milestones/<N> --jq '{title, open_issues}' --method GET # expect open_issues: 0
gh api --method PATCH repos/{owner}/{repo}/milestones/<N> -f state=closed
Pick up / finish / prioritize (board Status + Priority)
# Fields and options by name (gh >= 2.97); --url is the ISSUE's URL
url=$(gh issue view <issue> --json url --jq .url)
# pick up
gh project item-edit <num> --owner @me --url "$url" --field Status --value "In Progress"
# 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 <num> --owner @me --url "$url" --field Status --value Done
# (re)prioritize
gh project item-edit <num> --owner @me --url "$url" --field Priority --value P1
Give a teammate access to work the board
# repo side -- labels, assignees, apply milestones, sub-issue links (Triage, no code push; creating labels or milestones is Write)
gh api --method PUT orgs/<org>/teams/<team>/memberships/<user> -f role=member
gh api --method PUT orgs/<org>/teams/<team>/repos/{owner}/{repo} -f permission=triage
# project side -- Status, Priority, every field (no gh subcommand exists for this)
gh api graphql -f query='mutation($p:ID!,$t:ID!){ updateProjectV2Collaborators(input:{projectId:$p,
collaborators:[{teamId:$t, role:WRITER}]}){ collaborators(first:20){ nodes{ __typename } } } }' \
-f p=<projectId> -f t=<teamId>
# that payload echoes back only what you passed -- verify the team grant by reading it back; a per-user grant (userId in place of teamId) has no read, and the base role is UI-only
gh api graphql -f query='query($endCursor:String){ organization(login:"<org>"){ team(slug:"<team>"){ projectsV2(first:100, after:$endCursor, minPermissionLevel: WRITE){ nodes{ number } pageInfo{ hasNextPage endCursor } } } } }' --paginate --jq '.data.organization.team.projectsV2.nodes[].number' # <num> listed = Write or higher
# verify the repo side by role_name -- .permission reports a triage collaborator as "read"
gh api repos/{owner}/{repo}/collaborators/<user>/permission --jq .role_name --method GET
Reference:
references/cli-and-graphql.md-- full command set, the ID form ofitem-edit(field-list/item-list --format jsonfor the ids),item-list --queryfiltering, andupdateProjectV2ItemPositionfor 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.references/access-and-roles.md-- the two gates in full, diagnosing a blocked teammate, reading project collaborators, theadmin:orgrefresh, and the org settings REST accepts but silently ignores.
Setup (one-time)
Reference:
references/project-setup.md-- end to end: create the Project, addStatus/Priority(and optionalStage) 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), access reads (gh api repos/{owner}/{repo}/collaborators/<user>/permission,gh api orgs/{org}/teams/{team}/repos) -- every REST read ends in--method GET, the flag the allowlist keys on - 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), milestonePOST/PATCH/DELETE, sub-issuePOST/DELETE, GraphQL mutations, access grants (team membership/repoPUT,updateProjectV2Collaborators,PATCH /orgs/{org})
Reference: See
references/allowlist.mdfor read-onlygh projectpatterns 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 togh api .../sub_issues, fetch it with--jq .id; the issue number won't work (the nativeghflags take numbers/URLs). item-edit --urltakes the issue URL -- the project is picked by<num>+--owner, whose owner can differ from the issue's repo. Names need gh >= 2.97; older gh needs the option id fromfield-list --format json(rule 4).- 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-issueedit 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-editcan't set them andfield-listwon't show them (rule 6). Types are org-only (--typeon 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). - Access is two gates -- repo Triage for labels/assignees/sub-issues, project Write for Status/Priority. A missing role hides the UI control rather than explaining itself, though the same operation over the API does return an authorization error.
.permissionmisreports a triage collaborator asread, so verify with.role_name(rule 7).