Skip to content
Back to skills

Gh Projects

ASecurity

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

  • 24 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 10, 2026
toolspythonbashnodegitapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 10, 2026

npx -y skills add kinncj/MAPLE --skill gh-projects --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Gh Projects?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Gh Projects
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/kinncj-gh-projects-ffb6fd7c/badge)](https://www.skillsdirectory.com/skills/kinncj-gh-projects-ffb6fd7c)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: gh-projects
description: "Manage GitHub Projects v2 board: add issues, update field values, and query board state. Use when managing project boards."
---

# SKILL: gh-projects

## 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

| Field | Source | Example |
|---|---|---|
| `project_number` | `project.config.yaml` (`github.project_number`) | `3` — `null` = ask once; `0` = declined |
| `project_node_id` | `project.config.yaml` | `"PVT_kwDO..."` |
| `status_field_id` | `project.config.yaml` (`github.status_field_id`) | `"PVTSSF_..."` |
| `issue_node_id` | `gh-issues` skill output | `"I_kwDO..."` |
| `issue_number` | `gh-issues` skill output | `42` |
| `field_name` | project field name | `"Status"` |
| `field_value` | target option name | `"In Progress"` (capital P — GitHub's default option) |

## Read project config

Parse the `github.*` values robustly — strip inline comments (`#…`) and quotes, so
values like `project_number: null    # null = ask once` read as `null`, not the
whole comment:

```bash
cfg_get() {  # cfg_get <key> → prints value or empty
  python3 -c "
import sys
key = '$1'
for line in open('project.config.yaml'):
    s = line.strip()
    if s.startswith(key + ':'):
        v = s.split(':', 1)[1]
        v = v.split('#', 1)[0].strip().strip('\"').strip(\"'\")
        print(v)
        break
"
}

PROJECT_NUMBER=$(cfg_get project_number)   # "" if absent (old config) → treat as null
PROJECT_NODE=$(cfg_get project_node_id)
STATUS_FIELD_ID=$(cfg_get status_field_id)
```

## Board configuration gate (ask once, then persist)

Run this before adding any card. `project_number` has three states — an absent key
in an older config counts as `null`:

- **`null` or empty** — not yet decided. Ask the user **once**:
  "Add issues to a GitHub project board? (yes → bootstrap a board / no → skip)".
  - **yes** → run `maple project` (or bootstrap via GraphQL); it writes
    `project_number`, `project_node_id`, and `status_field_id` back to
    `project.config.yaml`. Continue.
  - **no** → persist the decline so we never re-ask, then skip the board:
    ```bash
    python3 - <<'PY'
    import re
    p = 'project.config.yaml'; s = open(p).read()
    s = re.sub(r'(project_number:\s*)null', r'\g<1>0', s, count=1)
    open(p, 'w').write(s)
    PY
    ```
- **`0`** — user declined. Skip all board operations silently.
- **`> 0`** — configured. Proceed with add + Status updates below.

## Add an Issue to the Project Board

```bash
# 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).

```bash
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)

For the **Status** field, use the cached `$STATUS_FIELD_ID` from config as
`$FIELD_ID` when it is set — skip the field-ID lookup. Fall back to the fetch
above only when it is empty.

```bash
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)

```bash
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

| Phase | Field | Value |
|---|---|---|
| Discover | Status | `Todo` |
| Architect | Status | `In Progress` |
| Implement | Status | `In Progress` |
| Validate | Status | `In Review` |
| Done | Status | `Done` |

## Query Board State

```bash
# 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

| Condition | Action |
|---|---|
| `project_node_id` missing from config | Run `maple project` to bootstrap. Stop until resolved. |
| Item already on board | Query before adding. `gh project item-list` and check content URL. Skip if present. |
| Field ID not found | Re-fetch fields. Field names are case-sensitive. |
| Option ID not found | List available options from field metadata. Do not guess. |
| GraphQL rate limit | Wait 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
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.