SKILL: gh-projects

SkillDev tools

Manage GitHub Projects v2 board: add issues, update field values, and query board state. Use when managing project boards.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the SKILL: gh-projects skill

What this skill tells your AI

The instructions your AI receives, as published by kinncj/heimdall in .claude/skills/gh-projects/SKILL.md and read by ahel’s review.

Purpose

Manage GitHub Projects v2 board operations: add issues as cards, update field values (status, type, ADR required), and query board state. Uses gh project CLI where available; falls back to gh api graphql for operations the CLI does not expose.

Inputs

FieldSourceExample
project_numberproject.config.yaml3
project_node_idproject.config.yaml"PVT_kwDO..."
issue_node_idgh-issues skill output"I_kwDO..."
issue_numbergh-issues skill output42
field_nameproject field name"Status"
field_valuetarget option name"In progress"

Read project config

PROJECT_NUMBER=$(python3 -c "
import sys
for line in open('project.config.yaml'):
    if 'project_number:' in line:
        print(line.split(':')[1].strip())
        break
")

PROJECT_NODE=$(python3 -c "
import sys
for line in open('project.config.yaml'):
    if 'project_node_id:' in line:
        print(line.split(':', 1)[1].strip())
        break
")

Add an Issue to the Project Board

# CLI (preferred)
gh project item-add "$PROJECT_NUMBER" \
  --owner "{owner}" \
  --url "https://github.com/{owner}/{repo}/issues/{issue_number}"

# GraphQL fallback (when CLI unavailable or for automation)
gh api graphql -f query='
  mutation($project: ID!, $content: ID!) {
    addProjectV2ItemById(input: {projectId: $project, contentId: $content}) {
      item { id }
    }
  }' \
  -f project="$PROJECT_NODE" \
  -f content="{issue_node_id}" \
  --jq '.data.addProjectV2ItemById.item.id'

Save the returned item_id — required for field updates.

Get Field and Option IDs

Field updates require the field's node ID and the option's node ID (for single-select fields).

FIELDS=$(gh api graphql -f query='
  query($project: ID!) {
    node(id: $project) {
      ... on ProjectV2 {
        fields(first: 20) {
          nodes {
            ... on ProjectV2Field { id name }
            ... on ProjectV2SingleSelectField {
              id name
              options { id name }
            }
          }
        }
      }
    }
  }' \
  -f project="$PROJECT_NODE")

# Extract field ID by name
FIELD_ID=$(echo "$FIELDS" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for f in d['data']['node']['fields']['nodes']:
    if f.get('name') == '{field_name}':
        print(f['id'])
        break
")

# Extract option ID by value (single-select fields)
OPTION_ID=$(echo "$FIELDS" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for f in d['data']['node']['fields']['nodes']:
    if f.get('name') == '{field_name}':
        for opt in f.get('options', []):
            if opt['name'] == '{field_value}':
                print(opt['id'])
                break
")

Update a Single-Select Field (Status, Type, ADR Required)

gh api graphql -f query='
  mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
    updateProjectV2ItemFieldValue(input: {
      projectId: $project
      itemId: $item
      fieldId: $field
      value: { singleSelectOptionId: $option }
    }) {
      projectV2Item { id }
    }
  }' \
  -f project="$PROJECT_NODE" \
  -f item="{item_id}" \
  -f field="$FIELD_ID" \
  -f option="$OPTION_ID"

Update a Text Field (Epic, Specialist)

gh api graphql -f query='
  mutation($project: ID!, $item: ID!, $field: ID!, $value: String!) {
    updateProjectV2ItemFieldValue(input: {
      projectId: $project
      itemId: $item
      fieldId: $field
      value: { text: $value }
    }) {
      projectV2Item { id }
    }
  }' \
  -f project="$PROJECT_NODE" \
  -f item="{item_id}" \
  -f field="$FIELD_ID" \
  -f value="{text_value}"

Standard Field Updates by Pipeline Phase

PhaseFieldValue
DiscoverStatusTodo
ArchitectStatusIn progress
ImplementStatusIn progress
ValidateStatusIn Review
DoneStatusDone

Query Board State

# List all items with status
gh project item-list "$PROJECT_NUMBER" \
  --owner "{owner}" \
  --format json \
  --jq '.items[] | {id, title: .content.title, status: .fieldValues[] | select(.field.name=="Status") | .name}'

Failure Modes

ConditionAction
project_node_id missing from configRun maple project to bootstrap. Stop until resolved.
Item already on boardQuery before adding. gh project item-list and check content URL. Skip if present.
Field ID not foundRe-fetch fields. Field names are case-sensitive.
Option ID not foundList available options from field metadata. Do not guess.
GraphQL rate limitWait 60 seconds. Retry once. Log and stop on second failure.

Logging

[gh-projects] ADD    #42  → project #{project_number}  item_id={id}
[gh-projects] UPDATE #42  field=Status  value="In progress"
[gh-projects] SKIP   #42  already on board

Signals

GitHub stars
66
Forks
4
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
gh-projects
Source
github.com/kinncj/heimdall