diff --git a/.claude/commands/mgw/board.md b/.claude/commands/mgw/board.md new file mode 100644 index 0000000..4131869 --- /dev/null +++ b/.claude/commands/mgw/board.md @@ -0,0 +1,1245 @@ +--- +name: mgw:board +description: Create, show, and configure the GitHub Projects v2 board for this repo +argument-hint: "" +allowed-tools: + - Bash + - Read + - Write + - Edit +--- + + +Manage the GitHub Projects v2 board for the current MGW project. Four subcommands: + +- `create` — Idempotent: creates the board and custom fields if not yet in project.json. + If board already exists in project.json, exits cleanly with the board URL. +- `show` — Displays current board state: board URL, field IDs, and a summary of items + grouped by pipeline_stage. Also shows configured views. +- `configure` — Updates board field options (add new pipeline stages, GSD routes, etc.) + based on the current board-schema definitions. +- `views` — Creates GitHub Projects v2 layout views (Board/Kanban, Table, Roadmap). + Subcommands: `views kanban`, `views table`, `views roadmap`. Creates the view and + outputs instructions for manual group-by configuration in the GitHub UI. + +All board API calls use GitHub GraphQL v4. Board metadata is stored in project.json +under `project.project_board.fields`. Board item sync (adding issues as board items) +is handled by issue #73 — this command only creates the board structure. + +Command reads `.mgw/project.json` for context. Never hardcodes IDs. Follows delegation +boundary: board API calls in MGW, never application code reads. + + + +@~/.claude/commands/mgw/workflows/state.md +@~/.claude/commands/mgw/workflows/github.md + + + +Subcommand: $ARGUMENTS + +Repo detected via: gh repo view --json nameWithOwner -q .nameWithOwner +State: .mgw/project.json +Board schema: .mgw/board-schema.json (if exists) or embedded defaults from docs/BOARD-SCHEMA.md + + + + + +**Parse $ARGUMENTS and validate environment:** + +```bash +SUBCOMMAND=$(echo "$ARGUMENTS" | awk '{print $1}') + +if [ -z "$SUBCOMMAND" ]; then + echo "Usage: /mgw:board " + echo "" + echo " create Create board and custom fields (idempotent)" + echo " show Display board state and item counts" + echo " configure Update board field options" + echo " views Create layout views (kanban, table, roadmap)" + exit 1 +fi + +case "$SUBCOMMAND" in + create|show|configure|views) ;; + *) + echo "Unknown subcommand: ${SUBCOMMAND}" + echo "Valid: create, show, configure, views" + exit 1 + ;; +esac +``` + +**Validate environment:** + +```bash +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +if [ -z "$REPO_ROOT" ]; then + echo "Not a git repository. Run from a repo root." + exit 1 +fi + +MGW_DIR="${REPO_ROOT}/.mgw" +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) +if [ -z "$REPO" ]; then + echo "No GitHub remote found. MGW requires a GitHub repo." + exit 1 +fi + +if [ ! -f "${MGW_DIR}/project.json" ]; then + echo "No project initialized. Run /mgw:project first." + exit 1 +fi + +OWNER=$(echo "$REPO" | cut -d'/' -f1) +REPO_NAME=$(echo "$REPO" | cut -d'/' -f2) +``` + + + +**Load project.json and extract board state:** + +```bash +PROJECT_JSON=$(cat "${MGW_DIR}/project.json") + +PROJECT_NAME=$(echo "$PROJECT_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['project']['name'])") + +# Check for existing board in project.json +BOARD_NUMBER=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +print(board.get('number', '')) +" 2>/dev/null) + +BOARD_URL=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +print(board.get('url', '')) +" 2>/dev/null) + +BOARD_NODE_ID=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +print(board.get('node_id', '')) +" 2>/dev/null) + +FIELDS_JSON=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +print(json.dumps(board.get('fields', {}))) +" 2>/dev/null || echo "{}") + +# Board exists if it has a node_id stored +BOARD_CONFIGURED=$([ -n "$BOARD_NODE_ID" ] && echo "true" || echo "false") +``` + + + +**Execute 'create' subcommand:** + +Only run if `$SUBCOMMAND = "create"`. + +**Idempotency check:** + +```bash +if [ "$SUBCOMMAND" = "create" ]; then + if [ "$BOARD_CONFIGURED" = "true" ]; then + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD ALREADY CONFIGURED" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${BOARD_NUMBER} — ${BOARD_URL}" + echo "Node ID: ${BOARD_NODE_ID}" + echo "" + echo "Custom fields:" + echo "$FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +for name, data in fields.items(): + print(f\" {name}: {data.get('field_id', 'unknown')} ({data.get('type','?')})\") +" 2>/dev/null + echo "" + echo "To update field options: /mgw:board configure" + echo "To see board items: /mgw:board show" + exit 0 + fi +``` + +**Get owner and repo node IDs (required for GraphQL mutations):** + +```bash + OWNER_ID=$(gh api graphql -f query=' + query($login: String!) { + user(login: $login) { id } + } + ' -f login="$OWNER" --jq '.data.user.id' 2>/dev/null) + + # Fall back to org if user lookup fails + if [ -z "$OWNER_ID" ]; then + OWNER_ID=$(gh api graphql -f query=' + query($login: String!) { + organization(login: $login) { id } + } + ' -f login="$OWNER" --jq '.data.organization.id' 2>/dev/null) + fi + + if [ -z "$OWNER_ID" ]; then + echo "ERROR: Cannot resolve owner ID for '${OWNER}'. Check your GitHub token permissions." + exit 1 + fi + + REPO_NODE_ID=$(gh api graphql -f query=' + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { id } + } + ' -f owner="$OWNER" -f name="$REPO_NAME" --jq '.data.repository.id' 2>/dev/null) +``` + +**Create the project board:** + +```bash + BOARD_TITLE="${PROJECT_NAME} — MGW Pipeline Board" + echo "Creating GitHub Projects v2 board: '${BOARD_TITLE}'..." + + CREATE_RESULT=$(gh api graphql -f query=' + mutation($ownerId: ID!, $title: String!) { + createProjectV2(input: { + ownerId: $ownerId + title: $title + }) { + projectV2 { + id + number + url + } + } + } + ' -f ownerId="$OWNER_ID" -f title="$BOARD_TITLE" 2>&1) + + NEW_PROJECT_ID=$(echo "$CREATE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2']['projectV2']['id']) +" 2>/dev/null) + + NEW_PROJECT_NUMBER=$(echo "$CREATE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2']['projectV2']['number']) +" 2>/dev/null) + + NEW_PROJECT_URL=$(echo "$CREATE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2']['projectV2']['url']) +" 2>/dev/null) + + if [ -z "$NEW_PROJECT_ID" ]; then + echo "ERROR: Failed to create project board." + echo "GraphQL response: ${CREATE_RESULT}" + exit 1 + fi + + echo " Created board: #${NEW_PROJECT_NUMBER} — ${NEW_PROJECT_URL}" + echo " Board node ID: ${NEW_PROJECT_ID}" +``` + +**Create custom fields (Status, AI Agent State, Milestone, Phase, GSD Route):** + +Field definitions follow docs/BOARD-SCHEMA.md from issue #71. + +```bash + echo "" + echo "Creating custom fields..." + + # Field 1: Status (SINGLE_SELECT — maps to pipeline_stage) + STATUS_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId + dataType: SINGLE_SELECT + name: "Status" + singleSelectOptions: [ + { name: "New", color: GRAY, description: "Issue created, not yet triaged" } + { name: "Triaged", color: BLUE, description: "Triage complete, ready for execution" } + { name: "Needs Info", color: YELLOW, description: "Blocked at triage gate" } + { name: "Needs Security Review", color: RED, description: "High security risk flagged" } + { name: "Discussing", color: PURPLE, description: "Awaiting stakeholder scope approval" } + { name: "Approved", color: GREEN, description: "Cleared for execution" } + { name: "Planning", color: BLUE, description: "GSD planner agent active" } + { name: "Executing", color: ORANGE, description: "GSD executor agent active" } + { name: "Verifying", color: BLUE, description: "GSD verifier agent active" } + { name: "PR Created", color: GREEN, description: "PR open, awaiting review" } + { name: "Done", color: GREEN, description: "PR merged, issue closed" } + { name: "Failed", color: RED, description: "Unrecoverable pipeline error" } + { name: "Blocked", color: RED, description: "Blocking comment detected" } + ] + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + ' -f projectId="$NEW_PROJECT_ID" 2>&1) + + STATUS_FIELD_ID=$(echo "$STATUS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2Field']['projectV2Field']['id']) +" 2>/dev/null) + + # Build option ID map from result + STATUS_OPTIONS=$(echo "$STATUS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +options = d['data']['createProjectV2Field']['projectV2Field']['options'] +# Map lowercase pipeline_stage keys to option IDs +stage_map = { + 'new': 'New', 'triaged': 'Triaged', 'needs-info': 'Needs Info', + 'needs-security-review': 'Needs Security Review', 'discussing': 'Discussing', + 'approved': 'Approved', 'planning': 'Planning', 'executing': 'Executing', + 'verifying': 'Verifying', 'pr-created': 'PR Created', 'done': 'Done', + 'failed': 'Failed', 'blocked': 'Blocked' +} +name_to_id = {o['name']: o['id'] for o in options} +result = {stage: name_to_id.get(display, '') for stage, display in stage_map.items()} +print(json.dumps(result)) +" 2>/dev/null || echo "{}") + + if [ -n "$STATUS_FIELD_ID" ]; then + echo " Status field created: ${STATUS_FIELD_ID}" + else + echo " WARNING: Status field creation failed: ${STATUS_RESULT}" + fi + + # Field 2: AI Agent State (TEXT) + AI_STATE_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId + dataType: TEXT + name: "AI Agent State" + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + } + } + } + ' -f projectId="$NEW_PROJECT_ID" 2>&1) + + AI_STATE_FIELD_ID=$(echo "$AI_STATE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2Field']['projectV2Field']['id']) +" 2>/dev/null) + + if [ -n "$AI_STATE_FIELD_ID" ]; then + echo " AI Agent State field created: ${AI_STATE_FIELD_ID}" + else + echo " WARNING: AI Agent State field creation failed" + fi + + # Field 3: Milestone (TEXT) + MILESTONE_FIELD_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId + dataType: TEXT + name: "Milestone" + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + } + } + } + ' -f projectId="$NEW_PROJECT_ID" 2>&1) + + MILESTONE_FIELD_ID=$(echo "$MILESTONE_FIELD_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2Field']['projectV2Field']['id']) +" 2>/dev/null) + + if [ -n "$MILESTONE_FIELD_ID" ]; then + echo " Milestone field created: ${MILESTONE_FIELD_ID}" + else + echo " WARNING: Milestone field creation failed" + fi + + # Field 4: Phase (TEXT) + PHASE_FIELD_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId + dataType: TEXT + name: "Phase" + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + } + } + } + ' -f projectId="$NEW_PROJECT_ID" 2>&1) + + PHASE_FIELD_ID=$(echo "$PHASE_FIELD_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2Field']['projectV2Field']['id']) +" 2>/dev/null) + + if [ -n "$PHASE_FIELD_ID" ]; then + echo " Phase field created: ${PHASE_FIELD_ID}" + else + echo " WARNING: Phase field creation failed" + fi + + # Field 5: GSD Route (SINGLE_SELECT) + GSD_ROUTE_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId + dataType: SINGLE_SELECT + name: "GSD Route" + singleSelectOptions: [ + { name: "quick", color: BLUE, description: "Small/atomic task, direct execution" } + { name: "quick --full", color: BLUE, description: "Small task with plan-checker and verifier" } + { name: "plan-phase", color: PURPLE, description: "Medium task with phase planning" } + { name: "new-milestone", color: ORANGE, description: "Large task with full milestone lifecycle" } + ] + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + ' -f projectId="$NEW_PROJECT_ID" 2>&1) + + GSD_ROUTE_FIELD_ID=$(echo "$GSD_ROUTE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2Field']['projectV2Field']['id']) +" 2>/dev/null) + + GSD_ROUTE_OPTIONS=$(echo "$GSD_ROUTE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +options = d['data']['createProjectV2Field']['projectV2Field']['options'] +route_map = { + 'gsd:quick': 'quick', 'gsd:quick --full': 'quick --full', + 'gsd:plan-phase': 'plan-phase', 'gsd:new-milestone': 'new-milestone' +} +name_to_id = {o['name']: o['id'] for o in options} +result = {route: name_to_id.get(display, '') for route, display in route_map.items()} +print(json.dumps(result)) +" 2>/dev/null || echo "{}") + + if [ -n "$GSD_ROUTE_FIELD_ID" ]; then + echo " GSD Route field created: ${GSD_ROUTE_FIELD_ID}" + else + echo " WARNING: GSD Route field creation failed" + fi +``` + +**Update project.json with board metadata:** + +```bash + echo "" + echo "Updating project.json with board metadata..." + + python3 << PYEOF +import json + +with open('${MGW_DIR}/project.json') as f: + project = json.load(f) + +# Build field schema +status_options = json.loads('''${STATUS_OPTIONS}''') if '${STATUS_OPTIONS}' != '{}' else {} +gsd_route_options = json.loads('''${GSD_ROUTE_OPTIONS}''') if '${GSD_ROUTE_OPTIONS}' != '{}' else {} + +fields = {} + +if '${STATUS_FIELD_ID}': + fields['status'] = { + 'field_id': '${STATUS_FIELD_ID}', + 'field_name': 'Status', + 'type': 'SINGLE_SELECT', + 'options': status_options + } + +if '${AI_STATE_FIELD_ID}': + fields['ai_agent_state'] = { + 'field_id': '${AI_STATE_FIELD_ID}', + 'field_name': 'AI Agent State', + 'type': 'TEXT' + } + +if '${MILESTONE_FIELD_ID}': + fields['milestone'] = { + 'field_id': '${MILESTONE_FIELD_ID}', + 'field_name': 'Milestone', + 'type': 'TEXT' + } + +if '${PHASE_FIELD_ID}': + fields['phase'] = { + 'field_id': '${PHASE_FIELD_ID}', + 'field_name': 'Phase', + 'type': 'TEXT' + } + +if '${GSD_ROUTE_FIELD_ID}': + fields['gsd_route'] = { + 'field_id': '${GSD_ROUTE_FIELD_ID}', + 'field_name': 'GSD Route', + 'type': 'SINGLE_SELECT', + 'options': gsd_route_options + } + +# Update project_board section +project['project']['project_board'] = { + 'number': int('${NEW_PROJECT_NUMBER}') if '${NEW_PROJECT_NUMBER}'.isdigit() else None, + 'url': '${NEW_PROJECT_URL}', + 'node_id': '${NEW_PROJECT_ID}', + 'fields': fields +} + +with open('${MGW_DIR}/project.json', 'w') as f: + json.dump(project, f, indent=2) + +print('project.json updated') +PYEOF + + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD CREATED" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${NEW_PROJECT_NUMBER} — ${NEW_PROJECT_URL}" + echo "Node ID: ${NEW_PROJECT_ID}" + echo "" + echo "Custom fields created:" + echo " status ${STATUS_FIELD_ID:-FAILED} (SINGLE_SELECT, 13 options)" + echo " ai_agent_state ${AI_STATE_FIELD_ID:-FAILED} (TEXT)" + echo " milestone ${MILESTONE_FIELD_ID:-FAILED} (TEXT)" + echo " phase ${PHASE_FIELD_ID:-FAILED} (TEXT)" + echo " gsd_route ${GSD_ROUTE_FIELD_ID:-FAILED} (SINGLE_SELECT, 4 options)" + echo "" + echo "Field IDs stored in .mgw/project.json" + echo "" + echo "Next:" + echo " /mgw:board show Display board state" + echo " /mgw:run 73 Sync issues onto board items (#73)" + +fi # end create subcommand +``` + + + +**Execute 'show' subcommand:** + +Only run if `$SUBCOMMAND = "show"`. + +```bash +if [ "$SUBCOMMAND" = "show" ]; then + if [ "$BOARD_CONFIGURED" = "false" ]; then + echo "No board configured. Run /mgw:board create first." + exit 1 + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD STATE: ${PROJECT_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${BOARD_NUMBER} — ${BOARD_URL}" + echo "Node ID: ${BOARD_NODE_ID}" + echo "" + echo "Custom Fields:" + echo "$FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +for name, data in fields.items(): + fid = data.get('field_id', 'unknown') + ftype = data.get('type', 'unknown') + fname = data.get('field_name', name) + if ftype == 'SINGLE_SELECT': + opts = len(data.get('options', {})) + print(f' {fname:<20} {fid} ({ftype}, {opts} options)') + else: + print(f' {fname:<20} {fid} ({ftype})') +" 2>/dev/null + echo "" +``` + +**Fetch board items from GitHub to show current state:** + +```bash + echo "Fetching board items from GitHub..." + + ITEMS_RESULT=$(gh api graphql -f query=' + query($owner: String!, $number: Int!) { + user(login: $owner) { + projectV2(number: $number) { + title + items(first: 50) { + totalCount + nodes { + id + content { + ... on Issue { + number + title + state + } + ... on PullRequest { + number + title + state + } + } + fieldValues(first: 10) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + ... on ProjectV2ItemFieldTextValue { + text + field { ... on ProjectV2Field { name } } + } + } + } + } + } + } + } + } + ' -f owner="$OWNER" -F number="$BOARD_NUMBER" 2>/dev/null) + + # Fall back to org query if user query fails + if echo "$ITEMS_RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); _ = d['data']['user']['projectV2']" 2>/dev/null; then + ITEM_NODES=$(echo "$ITEMS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(json.dumps(d['data']['user']['projectV2']['items']['nodes'])) +" 2>/dev/null || echo "[]") + TOTAL_ITEMS=$(echo "$ITEMS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['user']['projectV2']['items']['totalCount']) +" 2>/dev/null || echo "0") + else + # Try organization lookup + ITEMS_RESULT=$(gh api graphql -f query=' + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + title + items(first: 50) { + totalCount + nodes { + id + content { + ... on Issue { number title state } + ... on PullRequest { number title state } + } + fieldValues(first: 10) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + ... on ProjectV2ItemFieldTextValue { + text + field { ... on ProjectV2Field { name } } + } + } + } + } + } + } + } + } + ' -f owner="$OWNER" -F number="$BOARD_NUMBER" 2>/dev/null) + + ITEM_NODES=$(echo "$ITEMS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +org_data = d.get('data', {}).get('organization') or d.get('data', {}).get('user', {}) +proj = org_data.get('projectV2', {}) +print(json.dumps(proj.get('items', {}).get('nodes', []))) +" 2>/dev/null || echo "[]") + TOTAL_ITEMS=$(echo "$ITEMS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +org_data = d.get('data', {}).get('organization') or d.get('data', {}).get('user', {}) +proj = org_data.get('projectV2', {}) +print(proj.get('items', {}).get('totalCount', 0)) +" 2>/dev/null || echo "0") + fi + + echo "Board Items (${TOTAL_ITEMS} total):" + echo "" + + echo "$ITEM_NODES" | python3 -c " +import json,sys +nodes = json.load(sys.stdin) + +if not nodes: + print(' No items on board yet.') + print(' Run /mgw:run 73 to sync issues as board items (#73).') + sys.exit(0) + +# Group by Status field +by_status = {} +for node in nodes: + content = node.get('content', {}) + num = content.get('number', '?') + title = content.get('title', 'Unknown')[:45] + status = 'No Status' + for fv in node.get('fieldValues', {}).get('nodes', []): + field = fv.get('field', {}) + if field.get('name') == 'Status': + status = fv.get('name', 'No Status') + break + by_status.setdefault(status, []).append((num, title)) + +order = ['Executing', 'Planning', 'Verifying', 'PR Created', 'Triaged', 'Approved', + 'Discussing', 'New', 'Needs Info', 'Needs Security Review', 'Blocked', 'Failed', 'Done', 'No Status'] + +for status in order: + items = by_status.pop(status, []) + if items: + print(f' {status} ({len(items)}):') + for num, title in items: + print(f' #{num} {title}') + +for status, items in by_status.items(): + print(f' {status} ({len(items)}):') + for num, title in items: + print(f' #{num} {title}') +" 2>/dev/null + + echo "" + + # Show configured views if any + VIEWS_JSON=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +views = board.get('views', {}) +print(json.dumps(views)) +" 2>/dev/null || echo "{}") + + if [ "$VIEWS_JSON" != "{}" ] && [ -n "$VIEWS_JSON" ]; then + echo "Configured Views:" + echo "$VIEWS_JSON" | python3 -c " +import json,sys +views = json.load(sys.stdin) +for key, v in views.items(): + print(f' {v[\"name\"]:<40} {v[\"layout\"]:<16} (ID: {v[\"view_id\"]})') +" 2>/dev/null + echo "" + else + echo "Views: none configured" + echo " Run /mgw:board views kanban to create the kanban view" + echo "" + fi + + echo "Open board: ${BOARD_URL}" + +fi # end show subcommand +``` + + + +**Execute 'configure' subcommand:** + +Only run if `$SUBCOMMAND = "configure"`. + +Reads current field options from GitHub and compares to the canonical schema in +docs/BOARD-SCHEMA.md / .mgw/board-schema.json. Adds any missing options. + +```bash +if [ "$SUBCOMMAND" = "configure" ]; then + if [ "$BOARD_CONFIGURED" = "false" ]; then + echo "No board configured. Run /mgw:board create first." + exit 1 + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD CONFIGURE: ${PROJECT_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${BOARD_NUMBER} — ${BOARD_URL}" + echo "" +``` + +**Fetch current field state from GitHub:** + +```bash + FIELDS_STATE=$(gh api graphql -f query=' + query($owner: String!, $number: Int!) { + user(login: $owner) { + projectV2(number: $number) { + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name color description } + } + ... on ProjectV2Field { + id + name + dataType + } + } + } + } + } + } + ' -f owner="$OWNER" -F number="$BOARD_NUMBER" 2>/dev/null) + + # Try org if user fails + if ! echo "$FIELDS_STATE" | python3 -c "import json,sys; d=json.load(sys.stdin); _ = d['data']['user']['projectV2']" 2>/dev/null; then + FIELDS_STATE=$(gh api graphql -f query=' + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name color description } + } + ... on ProjectV2Field { + id + name + dataType + } + } + } + } + } + } + ' -f owner="$OWNER" -F number="$BOARD_NUMBER" 2>/dev/null) + fi + + echo "Current fields on board:" + echo "$FIELDS_STATE" | python3 -c " +import json,sys +d = json.load(sys.stdin) +data = d.get('data', {}) +proj = (data.get('user') or data.get('organization', {})).get('projectV2', {}) +nodes = proj.get('fields', {}).get('nodes', []) +for node in nodes: + name = node.get('name', 'unknown') + nid = node.get('id', 'unknown') + opts = node.get('options') + if opts is not None: + print(f' {name} (SINGLE_SELECT, {len(opts)} options): {nid}') + for opt in opts: + print(f' - {opt[\"name\"]} ({opt[\"color\"]}) [{opt[\"id\"]}]') + else: + dtype = node.get('dataType', 'TEXT') + print(f' {name} ({dtype}): {nid}') +" 2>/dev/null || echo " (could not fetch field details)" + + echo "" +``` + +**Compare with canonical schema and identify missing options:** + +```bash + # Canonical Status options from BOARD-SCHEMA.md + CANONICAL_STATUS_OPTIONS='["New","Triaged","Needs Info","Needs Security Review","Discussing","Approved","Planning","Executing","Verifying","PR Created","Done","Failed","Blocked"]' + + # Get current Status option names + CURRENT_STATUS_OPTIONS=$(echo "$FIELDS_STATE" | python3 -c " +import json,sys +d = json.load(sys.stdin) +data = d.get('data', {}) +proj = (data.get('user') or data.get('organization', {})).get('projectV2', {}) +nodes = proj.get('fields', {}).get('nodes', []) +for node in nodes: + if node.get('name') == 'Status' and 'options' in node: + print(json.dumps([o['name'] for o in node['options']])) + sys.exit(0) +print('[]') +" 2>/dev/null || echo "[]") + + MISSING_STATUS=$(python3 -c " +import json +canonical = json.loads('${CANONICAL_STATUS_OPTIONS}') +current = json.loads('''${CURRENT_STATUS_OPTIONS}''') +missing = [o for o in canonical if o not in current] +if missing: + print('Missing Status options: ' + ', '.join(missing)) +else: + print('Status field: all options present') +" 2>/dev/null) + + echo "Schema comparison:" + echo " ${MISSING_STATUS}" + + # Canonical GSD Route options + CANONICAL_GSD_OPTIONS='["quick","quick --full","plan-phase","new-milestone"]' + + CURRENT_GSD_OPTIONS=$(echo "$FIELDS_STATE" | python3 -c " +import json,sys +d = json.load(sys.stdin) +data = d.get('data', {}) +proj = (data.get('user') or data.get('organization', {})).get('projectV2', {}) +nodes = proj.get('fields', {}).get('nodes', []) +for node in nodes: + if node.get('name') == 'GSD Route' and 'options' in node: + print(json.dumps([o['name'] for o in node['options']])) + sys.exit(0) +print('[]') +" 2>/dev/null || echo "[]") + + MISSING_GSD=$(python3 -c " +import json +canonical = json.loads('${CANONICAL_GSD_OPTIONS}') +current = json.loads('''${CURRENT_GSD_OPTIONS}''') +missing = [o for o in canonical if o not in current] +if missing: + print('Missing GSD Route options: ' + ', '.join(missing)) +else: + print('GSD Route field: all options present') +" 2>/dev/null) + + echo " ${MISSING_GSD}" + echo "" + + # Check for missing text fields + CURRENT_FIELD_NAMES=$(echo "$FIELDS_STATE" | python3 -c " +import json,sys +d = json.load(sys.stdin) +data = d.get('data', {}) +proj = (data.get('user') or data.get('organization', {})).get('projectV2', {}) +nodes = proj.get('fields', {}).get('nodes', []) +print(json.dumps([n.get('name') for n in nodes])) +" 2>/dev/null || echo "[]") + + REQUIRED_TEXT_FIELDS='["AI Agent State","Milestone","Phase"]' + MISSING_TEXT=$(python3 -c " +import json +required = json.loads('${REQUIRED_TEXT_FIELDS}') +current = json.loads('''${CURRENT_FIELD_NAMES}''') +missing = [f for f in required if f not in current] +if missing: + print('Missing text fields: ' + ', '.join(missing)) +else: + print('Text fields: all present') +" 2>/dev/null) + + echo " ${MISSING_TEXT}" + echo "" + + # Report: no automated field addition (GitHub Projects v2 API does not support + # updating existing single-select field options — must delete and recreate) + echo "Note: GitHub Projects v2 GraphQL does not support adding options to an" + echo "existing single-select field. To add new pipeline stages:" + echo " 1. Delete the existing Status field on the board UI" + echo " 2. Run /mgw:board create (idempotency check will be skipped for fields)" + echo " Or: manually add options via GitHub Projects UI at ${BOARD_URL}" + echo "" + echo "For missing text fields, run /mgw:board create (it will create missing fields)." + +fi # end configure subcommand +``` + + + +**Execute 'views' subcommand:** + +Only run if `$SUBCOMMAND = "views"`. + +Creates GitHub Projects v2 layout views. Subcommand argument is the view type: +`kanban`, `table`, or `roadmap`. GitHub's API supports creating views but does NOT +support programmatic configuration of board grouping — that must be set in the UI. + +```bash +if [ "$SUBCOMMAND" = "views" ]; then + if [ "$BOARD_CONFIGURED" = "false" ]; then + echo "No board configured. Run /mgw:board create first." + exit 1 + fi + + VIEW_TYPE=$(echo "$ARGUMENTS" | awk '{print $2}') + + if [ -z "$VIEW_TYPE" ]; then + echo "Usage: /mgw:board views " + echo "" + echo " kanban Create Board layout view (swimlanes by Status)" + echo " table Create Table layout view (flat list with all fields)" + echo " roadmap Create Roadmap layout view (timeline grouped by Milestone)" + exit 1 + fi + + case "$VIEW_TYPE" in + kanban|table|roadmap) ;; + *) + echo "Unknown view type: ${VIEW_TYPE}" + echo "Valid: kanban, table, roadmap" + exit 1 + ;; + esac +``` + +**Map view type to layout and name:** + +```bash + case "$VIEW_TYPE" in + kanban) + VIEW_NAME="Kanban — Pipeline Stages" + VIEW_LAYOUT="BOARD_LAYOUT" + VIEW_KEY="kanban" + ;; + table) + VIEW_NAME="Triage Table — Team Planning" + VIEW_LAYOUT="TABLE_LAYOUT" + VIEW_KEY="table" + ;; + roadmap) + VIEW_NAME="Roadmap — Milestone Timeline" + VIEW_LAYOUT="ROADMAP_LAYOUT" + VIEW_KEY="roadmap" + ;; + esac + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD VIEWS: ${VIEW_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${BOARD_NUMBER} — ${BOARD_URL}" + echo "Creating ${VIEW_LAYOUT} view: '${VIEW_NAME}'..." + echo "" +``` + +**Create the view via GraphQL:** + +```bash + CREATE_VIEW_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!, $name: String!, $layout: ProjectV2ViewLayout!) { + createProjectV2View(input: { + projectId: $projectId + name: $name + layout: $layout + }) { + projectV2View { + id + name + layout + } + } + } + ' -f projectId="$BOARD_NODE_ID" \ + -f name="$VIEW_NAME" \ + -f layout="$VIEW_LAYOUT" 2>&1) + + VIEW_ID=$(echo "$CREATE_VIEW_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2View']['projectV2View']['id']) +" 2>/dev/null) + + VIEW_LAYOUT_RETURNED=$(echo "$CREATE_VIEW_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2View']['projectV2View']['layout']) +" 2>/dev/null) + + if [ -z "$VIEW_ID" ]; then + echo "ERROR: Failed to create view." + echo "GraphQL response: ${CREATE_VIEW_RESULT}" + exit 1 + fi + + echo "View created:" + echo " Name: ${VIEW_NAME}" + echo " Layout: ${VIEW_LAYOUT_RETURNED}" + echo " ID: ${VIEW_ID}" + echo "" +``` + +**Store view ID in project.json:** + +```bash + python3 << PYEOF +import json + +with open('${MGW_DIR}/project.json') as f: + project = json.load(f) + +# Ensure views dict exists under project_board +board = project.setdefault('project', {}).setdefault('project_board', {}) +views = board.setdefault('views', {}) + +views['${VIEW_KEY}'] = { + 'view_id': '${VIEW_ID}', + 'name': '${VIEW_NAME}', + 'layout': '${VIEW_LAYOUT}' +} + +with open('${MGW_DIR}/project.json', 'w') as f: + json.dump(project, f, indent=2) + +print('project.json updated with view ID') +PYEOF +``` + +**Output instructions and next steps:** + +```bash + echo "View ID stored in .mgw/project.json under project.project_board.views.${VIEW_KEY}" + echo "" + + case "$VIEW_TYPE" in + kanban) + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " NEXT STEP: Configure Group By in GitHub UI" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "GitHub's API does not support setting board grouping programmatically." + echo "To create swimlanes by pipeline stage:" + echo "" + echo " 1. Open the board: ${BOARD_URL}" + echo " 2. Click '${VIEW_NAME}' in the view tabs" + echo " 3. Click the view settings (down-arrow next to view name)" + echo " 4. Select 'Group by' -> 'Status'" + echo "" + echo "Each pipeline stage will become a swimlane column:" + echo " New / Triaged / Planning / Executing / Verifying / PR Created / Done" + echo " + Needs Info / Needs Security Review / Discussing / Approved / Failed / Blocked" + echo "" + echo "See docs/BOARD-SCHEMA.md for full view configuration reference." + ;; + table) + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " NEXT STEP: Configure Columns in GitHub UI" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Triage Table view created for team planning visibility." + echo "GitHub's API does not support setting table columns or sort order" + echo "programmatically — configure in the GitHub UI:" + echo "" + echo " 1. Open the board: ${BOARD_URL}" + echo " 2. Click '${VIEW_NAME}' in the view tabs" + echo " 3. Click the view settings (down-arrow next to view name)" + echo " 4. Add these columns in order:" + echo " Status (sort ascending — pipeline order)" + echo " Milestone" + echo " Phase" + echo " GSD Route" + echo " AI Agent State" + echo " 5. Set 'Sort by' -> 'Status' ascending" + echo "" + echo "This column order surfaces triage planning context:" + echo " Status first shows pipeline position at a glance." + echo " Milestone + Phase + GSD Route give scope and routing context." + echo " AI Agent State shows live execution activity." + echo "" + echo "See docs/BOARD-SCHEMA.md for full column and sort configuration reference." + ;; + roadmap) + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " NEXT STEP: Configure Roadmap in GitHub UI" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Roadmap view created for milestone-based timeline visualization." + echo "GitHub's API does not support setting roadmap grouping or date fields" + echo "programmatically — configure in the GitHub UI:" + echo "" + echo " 1. Open the board: ${BOARD_URL}" + echo " 2. Click '${VIEW_NAME}' in the view tabs" + echo " 3. Click the view settings (down-arrow next to view name)" + echo " 4. Set 'Group by' -> 'Milestone'" + echo " Items will be grouped by the Milestone field value." + echo "" + echo "Timeline date field limitation:" + echo " GitHub Roadmap requires date fields (start date + end date) to render" + echo " items on the timeline. MGW uses iteration-based tracking without" + echo " explicit date fields — items will appear in the roadmap grouped by" + echo " Milestone but without timeline bars unless date fields are added." + echo "" + echo " To enable timeline bars, set milestone due dates via:" + echo " gh api repos/{owner}/{repo}/milestones/{number} --method PATCH \\" + echo " -f due_on='YYYY-MM-DDT00:00:00Z'" + echo " GitHub Projects v2 can read milestone due dates as a date source." + echo "" + echo "See docs/BOARD-SCHEMA.md for full roadmap configuration reference." + ;; + esac + +fi # end views subcommand +``` + + + + + +- [ ] parse_and_validate: subcommand parsed, git repo and GitHub remote confirmed, project.json exists +- [ ] load_project: project.json loaded, board state extracted (number, url, node_id, fields) +- [ ] create: idempotency check — exits cleanly if board already configured (board_node_id present) +- [ ] create: owner node ID resolved via GraphQL (user or org fallback) +- [ ] create: createProjectV2 mutation succeeds — board number, URL, node_id captured +- [ ] create: all 5 custom fields created (Status, AI Agent State, Milestone, Phase, GSD Route) +- [ ] create: Status field has 13 single-select options matching pipeline_stage values +- [ ] create: GSD Route field has 4 single-select options +- [ ] create: field IDs and option IDs stored in project.json under project.project_board.fields +- [ ] create: success report shows board URL, node ID, and field IDs +- [ ] show: board not configured → clear error message +- [ ] show: board URL and node ID displayed +- [ ] show: custom fields listed with IDs and types +- [ ] show: board items fetched from GitHub and grouped by Status field value +- [ ] show: handles empty board (no items) with helpful next-step message +- [ ] show: user/org GraphQL fallback handles both account types +- [ ] configure: board not configured → clear error message +- [ ] configure: fetches current field state from GitHub +- [ ] configure: compares against canonical schema, reports missing options +- [ ] configure: lists all missing Status options, GSD Route options, and text fields +- [ ] configure: explains GitHub Projects v2 limitation on adding options to existing fields +- [ ] show: displays configured views (name, layout, ID) if any views exist +- [ ] show: prompts to run views kanban if no views are configured +- [ ] views: board not configured → clear error message +- [ ] views: no view type argument → usage message listing kanban, table, roadmap +- [ ] views: unknown view type → clear error message +- [ ] views: createProjectV2View mutation succeeds — view ID captured +- [ ] views: view ID stored in project.json under project.project_board.views +- [ ] views kanban: outputs step-by-step instructions for setting Group by Status in GitHub UI +- [ ] views kanban: lists all 13 pipeline stage columns user will see after configuring +- [ ] views table: view name is "Triage Table — Team Planning" +- [ ] views table: outputs step-by-step instructions for adding triage planning columns in GitHub UI +- [ ] views table: column order is Status, Milestone, Phase, GSD Route, AI Agent State +- [ ] views table: outputs instructions for sorting by Status ascending +- [ ] views roadmap: view name is "Roadmap — Milestone Timeline" +- [ ] views roadmap: outputs step-by-step instructions for setting Group by Milestone in GitHub UI +- [ ] views roadmap: explains date field limitation — MGW uses iteration-based tracking without explicit dates +- [ ] views roadmap: documents milestone due date workaround via gh api PATCH +- [ ] views: references docs/BOARD-SCHEMA.md for full view configuration documentation + diff --git a/.claude/commands/mgw/issue.md b/.claude/commands/mgw/issue.md index 5a9c3ac..91fab81 100644 --- a/.claude/commands/mgw/issue.md +++ b/.claude/commands/mgw/issue.md @@ -409,6 +409,74 @@ Also add branch cross-ref: BRANCH=$(git branch --show-current) ``` Add to linked_branches if not main/master. + +After writing the state file, sync the board Status field (non-blocking): +```bash +# Board sync — update board Status field to reflect new pipeline_stage +# Source the shared utility from board-sync.md, then call it +# Reads REPO_ROOT from environment (set in validate_and_load / init_state) +update_board_status() { + local ISSUE_NUMBER="$1" + local NEW_STAGE="$2" + if [ -z "$ISSUE_NUMBER" ] || [ -z "$NEW_STAGE" ]; then return 0; fi + BOARD_NODE_ID=$(python3 -c " +import json,sys,os +try: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project',{}).get('project_board',{}).get('node_id','')) +except: print('') +" 2>/dev/null || echo "") + if [ -z "$BOARD_NODE_ID" ]; then return 0; fi + ITEM_ID=$(python3 -c " +import json,sys +try: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + for m in p.get('milestones',[]): + for i in m.get('issues',[]): + if i.get('github_number')==${ISSUE_NUMBER}: + print(i.get('board_item_id','')); sys.exit(0) + print('') +except: print('') +" 2>/dev/null || echo "") + if [ -z "$ITEM_ID" ]; then return 0; fi + FIELD_ID=$(python3 -c " +import json,sys,os +try: + s='${REPO_ROOT}/.mgw/board-schema.json' + if os.path.exists(s): + print(json.load(open(s)).get('fields',{}).get('status',{}).get('field_id','')) + else: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project',{}).get('project_board',{}).get('fields',{}).get('status',{}).get('field_id','')) +except: print('') +" 2>/dev/null || echo "") + if [ -z "$FIELD_ID" ]; then return 0; fi + OPTION_ID=$(python3 -c " +import json,sys,os +try: + stage='${NEW_STAGE}' + s='${REPO_ROOT}/.mgw/board-schema.json' + if os.path.exists(s): + print(json.load(open(s)).get('fields',{}).get('status',{}).get('options',{}).get(stage,'')) + else: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project',{}).get('project_board',{}).get('fields',{}).get('status',{}).get('options',{}).get(stage,'')) +except: print('') +" 2>/dev/null || echo "") + if [ -z "$OPTION_ID" ]; then return 0; fi + gh api graphql -f query=' + mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$optionId:String!){ + updateProjectV2ItemFieldValue(input:{projectId:$projectId,itemId:$itemId,fieldId:$fieldId,value:{singleSelectOptionId:$optionId}}){projectV2Item{id}} + } + ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \ + -f fieldId="$FIELD_ID" -f optionId="$OPTION_ID" 2>/dev/null || true +} + +# Call after state file is written — non-blocking, never fails the pipeline +update_board_status $ISSUE_NUMBER "$pipeline_stage" +``` + +See @~/.claude/commands/mgw/workflows/board-sync.md for the full utility and data source reference. @@ -465,5 +533,6 @@ Consider closing or commenting on the issue with your reasoning. - [ ] Passed issues get mgw:triaged label - [ ] User confirms, overrides, or rejects - [ ] State file written to .mgw/active/ (if accepted) with comment tracking fields and gate_result +- [ ] Board Status field updated via update_board_status (non-blocking — failure does not block) - [ ] Next steps offered diff --git a/.claude/commands/mgw/pr.md b/.claude/commands/mgw/pr.md index 92d6195..1482693 100644 --- a/.claude/commands/mgw/pr.md +++ b/.claude/commands/mgw/pr.md @@ -29,6 +29,7 @@ Works in two modes: @~/.claude/commands/mgw/workflows/github.md @~/.claude/commands/mgw/workflows/gsd.md @~/.claude/commands/mgw/workflows/validation.md +@~/.claude/commands/mgw/workflows/board-sync.md @@ -247,6 +248,11 @@ Update state file: - Set pipeline_stage to "pr-created" Add cross-ref: issue → PR link in cross-refs.json. + +Sync PR to board (non-blocking): +```bash +sync_pr_to_board $ISSUE_NUMBER $PR_NUMBER # non-blocking — add PR as board item +``` @@ -274,4 +280,5 @@ Testing procedures posted as PR comment. - [ ] Testing procedures posted as separate PR comment - [ ] State file updated with PR number (linked mode) - [ ] Cross-ref added (linked mode) +- [ ] PR added to board as board item after creation (non-blocking, linked mode only) diff --git a/.claude/commands/mgw/run.md b/.claude/commands/mgw/run.md index 7a6a780..7dcde4f 100644 --- a/.claude/commands/mgw/run.md +++ b/.claude/commands/mgw/run.md @@ -38,6 +38,7 @@ Checkpoints requiring user input: @~/.claude/commands/mgw/workflows/github.md @~/.claude/commands/mgw/workflows/gsd.md @~/.claude/commands/mgw/workflows/validation.md +@~/.claude/commands/mgw/workflows/board-sync.md @@ -57,6 +58,110 @@ REPO_ROOT=$(git rev-parse --show-toplevel) DEFAULT=$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name) ``` +Define the board sync utilities (non-blocking — see board-sync.md for full reference): +```bash +update_board_status() { + local ISSUE_NUMBER="$1" + local NEW_STAGE="$2" + if [ -z "$ISSUE_NUMBER" ] || [ -z "$NEW_STAGE" ]; then return 0; fi + BOARD_NODE_ID=$(python3 -c " +import json,sys,os +try: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project',{}).get('project_board',{}).get('node_id','')) +except: print('') +" 2>/dev/null || echo "") + if [ -z "$BOARD_NODE_ID" ]; then return 0; fi + ITEM_ID=$(python3 -c " +import json,sys +try: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + for m in p.get('milestones',[]): + for i in m.get('issues',[]): + if i.get('github_number')==${ISSUE_NUMBER}: + print(i.get('board_item_id','')); sys.exit(0) + print('') +except: print('') +" 2>/dev/null || echo "") + if [ -z "$ITEM_ID" ]; then return 0; fi + FIELD_ID=$(python3 -c " +import json,sys,os +try: + s='${REPO_ROOT}/.mgw/board-schema.json' + if os.path.exists(s): + print(json.load(open(s)).get('fields',{}).get('status',{}).get('field_id','')) + else: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project',{}).get('project_board',{}).get('fields',{}).get('status',{}).get('field_id','')) +except: print('') +" 2>/dev/null || echo "") + if [ -z "$FIELD_ID" ]; then return 0; fi + OPTION_ID=$(python3 -c " +import json,sys,os +try: + stage='${NEW_STAGE}' + s='${REPO_ROOT}/.mgw/board-schema.json' + if os.path.exists(s): + print(json.load(open(s)).get('fields',{}).get('status',{}).get('options',{}).get(stage,'')) + else: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project',{}).get('project_board',{}).get('fields',{}).get('status',{}).get('options',{}).get(stage,'')) +except: print('') +" 2>/dev/null || echo "") + if [ -z "$OPTION_ID" ]; then return 0; fi + gh api graphql -f query=' + mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$optionId:String!){ + updateProjectV2ItemFieldValue(input:{projectId:$projectId,itemId:$itemId,fieldId:$fieldId,value:{singleSelectOptionId:$optionId}}){projectV2Item{id}} + } + ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \ + -f fieldId="$FIELD_ID" -f optionId="$OPTION_ID" 2>/dev/null || true +} + +update_board_agent_state() { + local ISSUE_NUMBER="$1" + local STATE_TEXT="$2" + if [ -z "$ISSUE_NUMBER" ]; then return 0; fi + BOARD_NODE_ID=$(python3 -c " +import json,sys,os +try: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project',{}).get('project_board',{}).get('node_id','')) +except: print('') +" 2>/dev/null || echo "") + if [ -z "$BOARD_NODE_ID" ]; then return 0; fi + ITEM_ID=$(python3 -c " +import json,sys +try: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + for m in p.get('milestones',[]): + for i in m.get('issues',[]): + if i.get('github_number')==${ISSUE_NUMBER}: + print(i.get('board_item_id','')); sys.exit(0) + print('') +except: print('') +" 2>/dev/null || echo "") + if [ -z "$ITEM_ID" ]; then return 0; fi + FIELD_ID=$(python3 -c " +import json,sys,os +try: + s='${REPO_ROOT}/.mgw/board-schema.json' + if os.path.exists(s): + print(json.load(open(s)).get('fields',{}).get('ai_agent_state',{}).get('field_id','')) + else: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project',{}).get('project_board',{}).get('fields',{}).get('ai_agent_state',{}).get('field_id','')) +except: print('') +" 2>/dev/null || echo "") + if [ -z "$FIELD_ID" ]; then return 0; fi + gh api graphql -f query=' + mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$text:String!){ + updateProjectV2ItemFieldValue(input:{projectId:$projectId,itemId:$itemId,fieldId:$fieldId,value:{text:$text}}){projectV2Item{id}} + } + ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \ + -f fieldId="$FIELD_ID" -f text="$STATE_TEXT" 2>/dev/null || true +} +``` + Parse $ARGUMENTS for issue number. If missing: ``` AskUserQuestion( @@ -248,7 +353,7 @@ Return ONLY valid JSON: |---------------|--------| | **informational** | Log: "MGW: ${NEW_COUNT} new comment(s) reviewed — informational, continuing." Update `triage.last_comment_count` in state file. Continue pipeline. | | **material** | Log: "MGW: Material comment(s) detected — scope may have changed." Update state: add new_requirements to triage context. Update `triage.last_comment_count`. Re-read issue body for updated requirements. Continue with enriched context (pass new_requirements to planner). Check for security keywords in material comments (see below). | -| **blocking** | Log: "MGW: Blocking comment detected — pipeline paused." Update state: `pipeline_stage = "blocked"`. Apply mgw:blocked label. Post comment on issue: `> **MGW** . \`pipeline-blocked\` . Blocked by stakeholder comment. Reason: ${blocking_reason}`. Stop pipeline execution. | +| **blocking** | Log: "MGW: Blocking comment detected — pipeline paused." Update state: `pipeline_stage = "blocked"`. Apply mgw:blocked label. Call `update_board_status $ISSUE_NUMBER "blocked"` (non-blocking). Post comment on issue: `> **MGW** . \`pipeline-blocked\` . Blocked by stakeholder comment. Reason: ${blocking_reason}`. Stop pipeline execution. | **Security keyword check for material comments:** ```bash @@ -339,6 +444,9 @@ Log comment in state file (at `${REPO_ROOT}/.mgw/active/`). Only run this step if gsd_route is "gsd:quick" or "gsd:quick --full". Update pipeline_stage to "executing" in state file (at `${REPO_ROOT}/.mgw/active/`). +```bash +update_board_status $ISSUE_NUMBER "executing" # non-blocking board sync +``` Determine flags: - "gsd:quick" → $QUICK_FLAGS = "" @@ -373,6 +481,9 @@ mkdir -p "$QUICK_DIR" ``` 3. **Spawn planner (task agent):** +```bash +update_board_agent_state $ISSUE_NUMBER "Planning" # non-blocking agent state +``` ``` Task( prompt=" @@ -475,6 +586,9 @@ If issues found and iteration < 2: spawn planner revision, then re-check. If iteration >= 2: offer force proceed or abort. 7. **Spawn executor (task agent):** +```bash +update_board_agent_state $ISSUE_NUMBER "Executing" # non-blocking agent state +``` ``` Task( prompt=" @@ -506,6 +620,9 @@ VERIFY_RESULT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs verify-summary "$ Parse JSON result. Use `passed` field for go/no-go. Checks summary existence, files created, and commits. 9. **(If --full) Spawn verifier:** +```bash +update_board_agent_state $ISSUE_NUMBER "Verifying" # non-blocking agent state +``` ``` Task( prompt=" @@ -539,6 +656,9 @@ node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs(quick-${next_num}): ``` Update state (at `${REPO_ROOT}/.mgw/active/`): gsd_artifacts.path = $QUICK_DIR, pipeline_stage = "verifying". +```bash +update_board_status $ISSUE_NUMBER "verifying" # non-blocking board sync +``` @@ -576,6 +696,7 @@ Set pipeline_stage to "discussing" and apply "mgw:discussing" label: ```bash gh issue edit ${ISSUE_NUMBER} --remove-label "mgw:in-progress" 2>/dev/null gh issue edit ${ISSUE_NUMBER} --add-label "mgw:discussing" 2>/dev/null +update_board_status $ISSUE_NUMBER "discussing" # non-blocking board sync ``` Present to user: @@ -623,6 +744,9 @@ If proceed: apply "mgw:approved" label and continue. ``` Update pipeline_stage to "planning" (at `${REPO_ROOT}/.mgw/active/`). + ```bash + update_board_status $ISSUE_NUMBER "planning" # non-blocking board sync + ``` 2. **If resuming with pipeline_stage = "planning" and ROADMAP.md exists:** Discover phases from ROADMAP and run the full per-phase GSD lifecycle: @@ -661,6 +785,9 @@ If proceed: apply "mgw:approved" label and continue. ``` **b. Spawn planner agent (gsd:plan-phase):** + ```bash + update_board_agent_state $ISSUE_NUMBER "Planning phase ${PHASE_NUMBER}" # non-blocking agent state + ``` ``` Task( prompt=" @@ -707,6 +834,7 @@ If proceed: apply "mgw:approved" label and continue. ```bash EXEC_INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init execute-phase "${PHASE_NUMBER}") # Parse EXEC_INIT JSON for: executor_model, verifier_model, phase_dir, plans, incomplete_plans, plan_count + update_board_agent_state $ISSUE_NUMBER "Executing phase ${PHASE_NUMBER}" # non-blocking agent state ``` ``` Task( @@ -739,6 +867,9 @@ If proceed: apply "mgw:approved" label and continue. ``` **e. Spawn verifier agent (gsd:verify-phase):** + ```bash + update_board_agent_state $ISSUE_NUMBER "Verifying phase ${PHASE_NUMBER}" # non-blocking agent state + ``` ``` Task( prompt=" @@ -785,6 +916,9 @@ COMMENTEOF ``` After ALL phases complete → update pipeline_stage to "verifying" (at `${REPO_ROOT}/.mgw/active/`). + ```bash + update_board_status $ISSUE_NUMBER "verifying" # non-blocking board sync + ``` @@ -822,6 +956,9 @@ gh issue comment ${ISSUE_NUMBER} --body "$EXEC_BODY" 2>/dev/null || true ``` Update pipeline_stage to "pr-pending" (at `${REPO_ROOT}/.mgw/active/`). +```bash +update_board_status $ISSUE_NUMBER "pr-created" # non-blocking board sync (pr-pending maps to pr-created on board) +``` @@ -992,6 +1129,12 @@ Update state (at `${REPO_ROOT}/.mgw/active/`): - linked_pr = PR number - pipeline_stage = "pr-created" +```bash +update_board_status $ISSUE_NUMBER "pr-created" # non-blocking board sync +update_board_agent_state $ISSUE_NUMBER "" # clear agent state after PR creation (non-blocking) +sync_pr_to_board $ISSUE_NUMBER $PR_NUMBER # non-blocking — add PR as board item +``` + Add cross-ref (at `${REPO_ROOT}/.mgw/cross-refs.json`): issue → PR. @@ -1052,6 +1195,9 @@ gh issue comment ${ISSUE_NUMBER} --body "$PR_READY_BODY" 2>/dev/null || true ``` Update pipeline_stage to "done" (at `${REPO_ROOT}/.mgw/active/`). +```bash +update_board_status $ISSUE_NUMBER "done" # non-blocking board sync +``` Report to user: ``` @@ -1092,5 +1238,10 @@ Next: - [ ] Worktree cleaned up, user returned to main workspace - [ ] mgw:in-progress label removed at completion - [ ] State file updated through all pipeline stages +- [ ] Board Status field synced at each pipeline_stage transition (non-blocking) +- [ ] AI Agent State field set before each GSD agent spawn (non-blocking) +- [ ] AI Agent State field cleared after PR creation (non-blocking) +- [ ] PR added to board as board item after creation (non-blocking) +- [ ] Board sync failures never block pipeline execution - [ ] User prompted to run /mgw:sync after merge diff --git a/.claude/commands/mgw/sync.md b/.claude/commands/mgw/sync.md index 5919117..df86c66 100644 --- a/.claude/commands/mgw/sync.md +++ b/.claude/commands/mgw/sync.md @@ -24,6 +24,7 @@ Run periodically or when starting a new session to get a clean view. @~/.claude/commands/mgw/workflows/github.md @~/.claude/commands/mgw/workflows/gsd.md @~/.claude/commands/mgw/workflows/validation.md +@~/.claude/commands/mgw/workflows/board-sync.md @@ -88,6 +89,53 @@ fi This is read-only and additive — health status is included in the sync summary but does not block any reconciliation actions. + +**Board reconciliation — ensure PR cross-refs are reflected on the board (non-blocking):** + +If the project board is configured, check cross-refs for any issue→PR `implements` links +and ensure each linked PR exists as a board item. Uses `sync_pr_to_board` from +board-sync.md which is idempotent — adding a PR that's already on the board is a no-op. + +```bash +# Non-blocking throughout — board sync failures never block reconciliation +if [ -f "${REPO_ROOT}/.mgw/project.json" ] && [ -f "${REPO_ROOT}/.mgw/cross-refs.json" ]; then + BOARD_NODE_ID=$(python3 -c " +import json, sys +try: + p = json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project', {}).get('project_board', {}).get('node_id', '')) +except: + print('') +" 2>/dev/null || echo "") + + if [ -n "$BOARD_NODE_ID" ]; then + # Find all issue→PR implements links in cross-refs + PR_LINKS=$(python3 -c " +import json +refs = json.load(open('${REPO_ROOT}/.mgw/cross-refs.json')) +for link in refs.get('links', []): + if link.get('type') == 'implements' and link['a'].startswith('issue:') and link['b'].startswith('pr:'): + issue_num = link['a'].split(':')[1] + pr_num = link['b'].split(':')[1] + print(f'{issue_num} {pr_num}') +" 2>/dev/null || echo "") + + # For each issue→PR link, ensure the PR is on the board + PR_SYNCED=0 + while IFS=' ' read -r LINKED_ISSUE LINKED_PR; do + [ -z "$LINKED_PR" ] && continue + sync_pr_to_board "$LINKED_ISSUE" "$LINKED_PR" # non-blocking + PR_SYNCED=$((PR_SYNCED + 1)) + done <<< "$PR_LINKS" + + if [ "$PR_SYNCED" -gt 0 ]; then + echo "MGW: Board reconciliation — checked ${PR_SYNCED} PR cross-ref(s)" + fi + fi +fi +``` + + **Take action per classification:** @@ -168,5 +216,6 @@ ${comment_drift_details ? 'Unreviewed comments:\n' + comment_drift_details : ''} - [ ] Lingering worktrees cleaned up for completed items - [ ] Branch deletion offered for completed items - [ ] Stale/orphaned/drift items flagged (including comment drift) +- [ ] Board reconciliation run — all PR cross-refs checked against board (non-blocking) - [ ] Summary presented diff --git a/.claude/commands/mgw/workflows/board-sync.md b/.claude/commands/mgw/workflows/board-sync.md new file mode 100644 index 0000000..a35e692 --- /dev/null +++ b/.claude/commands/mgw/workflows/board-sync.md @@ -0,0 +1,403 @@ + +Shared board sync utilities for MGW pipeline commands. Three functions are exported: + +- update_board_status — Called after any pipeline_stage transition to update the board + item's Status (single-select) field. +- update_board_agent_state — Called around GSD agent spawns to surface the active agent + in the board item's "AI Agent State" (text) field. Cleared after PR creation. +- sync_pr_to_board — Called after PR creation to add the PR as a board item (PR-type + item linked to the issue's board item). + +All board updates are non-blocking: if the board is not configured, if the issue has no +board_item_id, or if the API call fails, the function returns silently. A board sync +failure MUST NEVER block pipeline execution. + + +## update_board_status + +Call this function after any `pipeline_stage` transition in any MGW command. + +```bash +# update_board_status — Update board Status field after a pipeline_stage transition +# Args: ISSUE_NUMBER, NEW_PIPELINE_STAGE +# Non-blocking: all failures are silent no-ops +update_board_status() { + local ISSUE_NUMBER="$1" + local NEW_STAGE="$2" + + if [ -z "$ISSUE_NUMBER" ] || [ -z "$NEW_STAGE" ]; then + return 0 + fi + + # Read board project node ID from project.json (non-blocking — if not configured, skip) + BOARD_NODE_ID=$(python3 -c " +import json, sys +try: + p = json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project', {}).get('project_board', {}).get('node_id', '')) +except: + print('') +" 2>/dev/null || echo "") + if [ -z "$BOARD_NODE_ID" ]; then return 0; fi + + # Get board_item_id for this issue from project.json + ITEM_ID=$(python3 -c " +import json, sys +try: + p = json.load(open('${REPO_ROOT}/.mgw/project.json')) + for m in p.get('milestones', []): + for i in m.get('issues', []): + if i.get('github_number') == ${ISSUE_NUMBER}: + print(i.get('board_item_id', '')) + sys.exit(0) + print('') +except: + print('') +" 2>/dev/null || echo "") + if [ -z "$ITEM_ID" ]; then return 0; fi + + # Map pipeline_stage to Status field option ID + # Reads from board-schema.json first, falls back to project.json fields + FIELD_ID=$(python3 -c " +import json, sys, os +try: + schema_path = '${REPO_ROOT}/.mgw/board-schema.json' + if os.path.exists(schema_path): + s = json.load(open(schema_path)) + print(s.get('fields', {}).get('status', {}).get('field_id', '')) + else: + p = json.load(open('${REPO_ROOT}/.mgw/project.json')) + fields = p.get('project', {}).get('project_board', {}).get('fields', {}) + print(fields.get('status', {}).get('field_id', '')) +except: + print('') +" 2>/dev/null || echo "") + if [ -z "$FIELD_ID" ]; then return 0; fi + + OPTION_ID=$(python3 -c " +import json, sys, os +try: + stage = '${NEW_STAGE}' + schema_path = '${REPO_ROOT}/.mgw/board-schema.json' + if os.path.exists(schema_path): + s = json.load(open(schema_path)) + options = s.get('fields', {}).get('status', {}).get('options', {}) + print(options.get(stage, '')) + else: + p = json.load(open('${REPO_ROOT}/.mgw/project.json')) + fields = p.get('project', {}).get('project_board', {}).get('fields', {}) + options = fields.get('status', {}).get('options', {}) + print(options.get(stage, '')) +except: + print('') +" 2>/dev/null || echo "") + if [ -z "$OPTION_ID" ]; then return 0; fi + + # Update the Status field on the board item (non-blocking) + gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } + ' -f projectId="$BOARD_NODE_ID" \ + -f itemId="$ITEM_ID" \ + -f fieldId="$FIELD_ID" \ + -f optionId="$OPTION_ID" 2>/dev/null || true +} +``` + +## update_board_agent_state + +Call this function before spawning each GSD agent and after PR creation to surface +real-time agent activity in the board item's "AI Agent State" text field. + +```bash +# update_board_agent_state — Update AI Agent State text field on the board item +# Args: ISSUE_NUMBER, STATE_TEXT (empty string to clear the field) +# Non-blocking: all failures are silent no-ops +update_board_agent_state() { + local ISSUE_NUMBER="$1" + local STATE_TEXT="$2" + + if [ -z "$ISSUE_NUMBER" ]; then return 0; fi + + # Read board project node ID from project.json (non-blocking — if not configured, skip) + BOARD_NODE_ID=$(python3 -c " +import json, sys +try: + p = json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project', {}).get('project_board', {}).get('node_id', '')) +except: + print('') +" 2>/dev/null || echo "") + if [ -z "$BOARD_NODE_ID" ]; then return 0; fi + + # Get board_item_id for this issue from project.json + ITEM_ID=$(python3 -c " +import json, sys +try: + p = json.load(open('${REPO_ROOT}/.mgw/project.json')) + for m in p.get('milestones', []): + for i in m.get('issues', []): + if i.get('github_number') == ${ISSUE_NUMBER}: + print(i.get('board_item_id', '')) + sys.exit(0) + print('') +except: + print('') +" 2>/dev/null || echo "") + if [ -z "$ITEM_ID" ]; then return 0; fi + + # Get the AI Agent State field ID from board-schema.json or project.json + FIELD_ID=$(python3 -c " +import json, sys, os +try: + schema_path = '${REPO_ROOT}/.mgw/board-schema.json' + if os.path.exists(schema_path): + s = json.load(open(schema_path)) + print(s.get('fields', {}).get('ai_agent_state', {}).get('field_id', '')) + else: + p = json.load(open('${REPO_ROOT}/.mgw/project.json')) + fields = p.get('project', {}).get('project_board', {}).get('fields', {}) + print(fields.get('ai_agent_state', {}).get('field_id', '')) +except: + print('') +" 2>/dev/null || echo "") + if [ -z "$FIELD_ID" ]; then return 0; fi + + # Update the AI Agent State text field on the board item (non-blocking) + gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { text: $text } + }) { projectV2Item { id } } + } + ' -f projectId="$BOARD_NODE_ID" \ + -f itemId="$ITEM_ID" \ + -f fieldId="$FIELD_ID" \ + -f text="$STATE_TEXT" 2>/dev/null || true +} +``` + +## Stage-to-Status Mapping + +The Status field options correspond to pipeline_stage values: + +| pipeline_stage | Board Status Option | +|----------------|-------------------| +| `new` | New | +| `triaged` | Triaged | +| `needs-info` | Needs Info | +| `needs-security-review` | Needs Security Review | +| `discussing` | Discussing | +| `approved` | Approved | +| `planning` | Planning | +| `executing` | Executing | +| `verifying` | Verifying | +| `pr-created` | PR Created | +| `done` | Done | +| `failed` | Failed | +| `blocked` | Blocked | + +Option IDs for each stage are looked up at runtime from: +1. `.mgw/board-schema.json` → `fields.status.options.` (preferred) +2. `.mgw/project.json` → `project.project_board.fields.status.options.` (fallback) + +## AI Agent State Values + +The AI Agent State text field is set before each GSD agent spawn and cleared after PR creation: + +| Trigger | Value | +|---------|-------| +| Before gsd-planner spawn (quick route) | `"Planning"` | +| Before gsd-executor spawn (quick route) | `"Executing"` | +| Before gsd-verifier spawn (quick route) | `"Verifying"` | +| Before gsd-planner spawn (milestone, phase N) | `"Planning phase N"` | +| Before gsd-executor spawn (milestone, phase N) | `"Executing phase N"` | +| Before gsd-verifier spawn (milestone, phase N) | `"Verifying phase N"` | +| After PR created | `""` (clears the field) | + +## Data Sources + +| Field | Source | +|-------|--------| +| `BOARD_NODE_ID` | `project.json` → `project.project_board.node_id` | +| `ITEM_ID` | `project.json` → `milestones[*].issues[*].board_item_id` (set by #73) | +| `FIELD_ID` (status) | `board-schema.json` or `project.json` → `fields.status.field_id` | +| `OPTION_ID` | `board-schema.json` or `project.json` → `fields.status.options.` | +| `FIELD_ID` (agent state) | `board-schema.json` or `project.json` → `fields.ai_agent_state.field_id` | + +## Non-Blocking Contract + +Every failure case returns 0 (success) without printing to stderr. The caller is never +aware of board sync failures. This guarantees: + +- Board not configured (no `node_id` in project.json) → silent no-op +- Issue has no `board_item_id` → silent no-op (not yet added to board) +- Status field not configured → silent no-op +- AI Agent State field not configured → silent no-op +- Stage has no mapped option ID → silent no-op +- GraphQL API error → silent no-op (`|| true` suppresses exit code) +- Network error → silent no-op + +## Touch Points + +Source or inline both utilities in any MGW command that spawns GSD agents. + +### update_board_status — in issue.md (triage stage transitions) + +After writing `pipeline_stage` to the state file in the `write_state` step: +```bash +# After: pipeline_stage written to .mgw/active/.json +update_board_status $ISSUE_NUMBER "$pipeline_stage" # non-blocking +``` + +Transitions in issue.md: +- `needs-info` — validity or detail gate blocked +- `needs-security-review` — security gate blocked +- `triaged` — all gates passed or user override + +### update_board_status — in run.md (pipeline stage transitions) + +After each `pipeline_stage` checkpoint write to project.json and state file: +```bash +# After: pipeline_stage checkpoint written (state.md "Update Issue Pipeline Stage" pattern) +update_board_status $ISSUE_NUMBER "$NEW_STAGE" # non-blocking +``` + +Transitions in run.md: +- `planning` — GSD execution begins +- `executing` — executor agent active +- `verifying` — verifier agent active +- `pr-created` — PR created +- `done` — pipeline complete +- `blocked` — blocking comment detected in preflight_comment_check + +### update_board_agent_state — in run.md (around agent spawns) + +Called immediately before spawning each GSD agent and after PR creation: +```bash +# Before spawning gsd-planner +update_board_agent_state $ISSUE_NUMBER "Planning phase ${PHASE_NUM}" + +# Before spawning gsd-executor +update_board_agent_state $ISSUE_NUMBER "Executing phase ${PHASE_NUM}" + +# Before spawning gsd-verifier +update_board_agent_state $ISSUE_NUMBER "Verifying phase ${PHASE_NUM}" + +# After PR created (clear the field) +update_board_agent_state $ISSUE_NUMBER "" +``` + +### sync_pr_to_board — in run.md and pr.md (after PR creation) + +Called immediately after `gh pr create` succeeds in both run.md and pr.md (linked mode): +```bash +# After PR created +sync_pr_to_board $ISSUE_NUMBER $PR_NUMBER # non-blocking board PR link +``` + +## sync_pr_to_board + +Call this function after PR creation to add the PR as a board item. In GitHub Projects v2, +`addProjectV2ItemById` with a PR's node ID creates a PR-type item that GitHub Projects +tracks separately from the issue item. + +```bash +# sync_pr_to_board — Add PR as a board item, linked to the issue's board item +# Args: ISSUE_NUMBER, PR_NUMBER +# Non-blocking: all failures are silent no-ops +sync_pr_to_board() { + local ISSUE_NUMBER="$1" + local PR_NUMBER="$2" + + if [ -z "$ISSUE_NUMBER" ] || [ -z "$PR_NUMBER" ]; then return 0; fi + + # Read board project node ID from project.json (non-blocking — if not configured, skip) + BOARD_NODE_ID=$(python3 -c " +import json, sys +try: + p = json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project', {}).get('project_board', {}).get('node_id', '')) +except: + print('') +" 2>/dev/null || echo "") + if [ -z "$BOARD_NODE_ID" ]; then return 0; fi + + # Get PR node ID from GitHub (non-blocking) + PR_NODE_ID=$(gh pr view "$PR_NUMBER" --json id -q .id 2>/dev/null || echo "") + if [ -z "$PR_NODE_ID" ]; then return 0; fi + + # Add PR to board as a PR-type item (creates a separate board entry linked to the PR) + ITEM_ID=$(gh api graphql -f query=' + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { id } + } + } + ' -f projectId="$BOARD_NODE_ID" -f contentId="$PR_NODE_ID" \ + --jq '.data.addProjectV2ItemById.item.id' 2>/dev/null || echo "") + + if [ -n "$ITEM_ID" ]; then + echo "MGW: PR #${PR_NUMBER} added to board (item: ${ITEM_ID})" + fi +} +``` + +## sync_pr_to_board — Board Reconciliation in sync.md + +During `mgw:sync`, after cross-refs are loaded, check for any `implements` links +(issue → PR) that don't yet have a board item for the PR. For each such link, call +`sync_pr_to_board` to ensure the board reflects all linked PRs. + +```bash +# Board reconciliation — ensure all PR cross-refs have board items (non-blocking) +if [ -f "${REPO_ROOT}/.mgw/project.json" ] && [ -f "${REPO_ROOT}/.mgw/cross-refs.json" ]; then + BOARD_NODE_ID=$(python3 -c " +import json, sys +try: + p = json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project', {}).get('project_board', {}).get('node_id', '')) +except: + print('') +" 2>/dev/null || echo "") + + if [ -n "$BOARD_NODE_ID" ]; then + # Find all issue→PR implements links in cross-refs + PR_LINKS=$(python3 -c " +import json +refs = json.load(open('${REPO_ROOT}/.mgw/cross-refs.json')) +for link in refs.get('links', []): + if link.get('type') == 'implements' and link['a'].startswith('issue:') and link['b'].startswith('pr:'): + issue_num = link['a'].split(':')[1] + pr_num = link['b'].split(':')[1] + print(f'{issue_num} {pr_num}') +" 2>/dev/null || echo "") + + # For each issue→PR link, ensure the PR is on the board (sync_pr_to_board is idempotent) + while IFS=' ' read -r LINKED_ISSUE LINKED_PR; do + [ -z "$LINKED_PR" ] && continue + sync_pr_to_board "$LINKED_ISSUE" "$LINKED_PR" # non-blocking + done <<< "$PR_LINKS" + fi +fi +``` + +## Consumers + +| Command | Function | When Called | +|---------|----------|-------------| +| issue.md | update_board_status | After writing pipeline_stage in write_state step | +| run.md | update_board_status | After each pipeline_stage checkpoint write | +| run.md | update_board_agent_state | Before each GSD agent spawn, and after PR creation | +| run.md | sync_pr_to_board | After PR creation (before cross-ref is recorded) | +| pr.md | sync_pr_to_board | After PR creation in create_pr step (linked mode only) | +| sync.md | sync_pr_to_board | Board reconciliation — for each PR link in cross-refs | diff --git a/.claude/commands/mgw/workflows/state.md b/.claude/commands/mgw/workflows/state.md index 112d3cd..9b0bc88 100644 --- a/.claude/commands/mgw/workflows/state.md +++ b/.claude/commands/mgw/workflows/state.md @@ -361,3 +361,4 @@ Only advance if ALL issues in current milestone completed successfully. | Slug generation | issue.md, run.md | | Project state | milestone.md, next.md, ask.md | | Gate result schema | issue.md (populate), run.md (validate) | +| Board status sync | board-sync.md (utility), issue.md (triage transitions), run.md (pipeline transitions) | diff --git a/commands/milestone.md b/commands/milestone.md index 6d4fe8e..1331db4 100644 --- a/commands/milestone.md +++ b/commands/milestone.md @@ -250,6 +250,72 @@ fi ``` + +**Post milestone-start announcement to GitHub Discussions (or first-issue comment fallback):** + +Runs once before the execute loop. Skipped if --dry-run is set. Failure is non-blocking — a warning is logged and execution continues. + +```bash +if [ "$DRY_RUN" = true ]; then + echo "MGW: Skipping milestone-start announcement (dry-run mode)" +else + # Gather board URL from project.json if present (non-blocking) + BOARD_URL=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +m = p['milestones'][${MILESTONE_NUM} - 1] +print(m.get('board_url', '')) +" 2>/dev/null || echo "") + + # Build issues JSON array with assignee and gsd_route per issue + ISSUES_PAYLOAD=$(echo "$ISSUES_JSON" | python3 -c " +import json,sys +issues = json.load(sys.stdin) +result = [] +for i in issues: + result.append({ + 'number': i.get('github_number', 0), + 'title': i.get('title', '')[:60], + 'assignee': i.get('assignee') or None, + 'gsdRoute': i.get('gsd_route', 'plan-phase') + }) +print(json.dumps(result)) +" 2>/dev/null || echo "[]") + + # Get first issue number for fallback comment (non-blocking) + FIRST_ISSUE_NUM=$(echo "$ISSUES_JSON" | python3 -c " +import json,sys +issues = json.load(sys.stdin) +print(issues[0]['github_number'] if issues else '') +" 2>/dev/null || echo "") + + REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || echo "") + + REPO="$REPO" \ + MILESTONE_NAME="$MILESTONE_NAME" \ + BOARD_URL="$BOARD_URL" \ + ISSUES_PAYLOAD="$ISSUES_PAYLOAD" \ + FIRST_ISSUE_NUM="$FIRST_ISSUE_NUM" \ + node -e " +const { postMilestoneStartAnnouncement } = require('./lib/index.cjs'); +const result = postMilestoneStartAnnouncement({ + repo: process.env.REPO, + milestoneName: process.env.MILESTONE_NAME, + boardUrl: process.env.BOARD_URL || undefined, + issues: JSON.parse(process.env.ISSUES_PAYLOAD || '[]'), + firstIssueNumber: process.env.FIRST_ISSUE_NUM ? parseInt(process.env.FIRST_ISSUE_NUM) : undefined +}); +if (result.posted) { + const detail = result.url ? ': ' + result.url : ''; + console.log('MGW: Milestone-start announcement posted via ' + result.method + detail); +} else { + console.log('MGW: Milestone-start announcement skipped (Discussions unavailable, no fallback)'); +} +" 2>/dev/null || echo "MGW: Announcement step failed (non-blocking) — continuing" +fi +``` + + **If --dry-run flag: display execution plan and exit:** diff --git a/commands/project.md b/commands/project.md index 35755ca..e25bd06 100644 --- a/commands/project.md +++ b/commands/project.md @@ -574,6 +574,228 @@ fi Store `PROJECT_NUMBER` and `PROJECT_URL` for inclusion in project.json and the summary report. + +**Sync newly created issues onto the board as items with field values (non-blocking):** + +This step runs after `create_project_board` (both init and extend modes). It adds each +newly created issue as a board item and sets Milestone, Phase, and GSD Route field values. +Board item IDs are collected here and stored in project.json (as `board_item_id` per issue). + +If no board is configured (PROJECT_NUMBER is empty) or the board has no custom fields +configured (node_id or fields missing from project.json), skip silently. + +Non-blocking: any GraphQL error is logged as a WARNING and does not halt the pipeline. + +```bash +# Load board field metadata from project.json +BOARD_NODE_ID=$(python3 -c " +import json +try: + p = json.load(open('${MGW_DIR}/project.json')) + print(p.get('project', {}).get('project_board', {}).get('node_id', '')) +except: + print('') +" 2>/dev/null || echo "") + +BOARD_FIELDS_JSON=$(python3 -c " +import json +try: + p = json.load(open('${MGW_DIR}/project.json')) + fields = p.get('project', {}).get('project_board', {}).get('fields', {}) + print(json.dumps(fields)) +except: + print('{}') +" 2>/dev/null || echo "{}") + +# Resolve field IDs from stored metadata +MILESTONE_FIELD_ID=$(echo "$BOARD_FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +print(fields.get('milestone', {}).get('field_id', '')) +" 2>/dev/null || echo "") + +PHASE_FIELD_ID=$(echo "$BOARD_FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +print(fields.get('phase', {}).get('field_id', '')) +" 2>/dev/null || echo "") + +GSD_ROUTE_FIELD_ID=$(echo "$BOARD_FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +print(fields.get('gsd_route', {}).get('field_id', '')) +" 2>/dev/null || echo "") + +GSD_ROUTE_OPTIONS=$(echo "$BOARD_FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +print(json.dumps(fields.get('gsd_route', {}).get('options', {}))) +" 2>/dev/null || echo "{}") + +# Determine if sync is possible +BOARD_SYNC_ENABLED=false +if [ -n "$PROJECT_NUMBER" ] && [ -n "$BOARD_NODE_ID" ]; then + BOARD_SYNC_ENABLED=true + echo "" + echo "Syncing ${TOTAL_ISSUES_CREATED} issues onto board #${PROJECT_NUMBER}..." +elif [ -n "$PROJECT_NUMBER" ] && [ -z "$BOARD_NODE_ID" ]; then + echo "" + echo "NOTE: Board #${PROJECT_NUMBER} exists but custom fields not configured." + echo " Run /mgw:board create to set up fields, then board sync will be available." +fi + +# ISSUE_RECORD format: "milestone_index:issue_number:title:phase_num:phase_name:gsd_route:depends_on" +# ITEM_ID_MAP accumulates: "issue_number:item_id" for project.json storage +ITEM_ID_MAP=() +BOARD_SYNC_WARNINGS=() + +if [ "$BOARD_SYNC_ENABLED" = "true" ]; then + for RECORD in "${ISSUE_RECORDS[@]}"; do + ISSUE_NUM=$(echo "$RECORD" | cut -d':' -f2) + ISSUE_PHASE_NUM=$(echo "$RECORD" | cut -d':' -f4) + ISSUE_PHASE_NAME=$(echo "$RECORD" | cut -d':' -f5) + ISSUE_GSD_ROUTE=$(echo "$RECORD" | cut -d':' -f6) + ISSUE_MILESTONE_IDX=$(echo "$RECORD" | cut -d':' -f1) + + # Get milestone name for this issue + ISSUE_MILESTONE_NAME=$(python3 -c " +import json +try: + d = json.load(open('/tmp/mgw-template.json')) + print(d['milestones'][${ISSUE_MILESTONE_IDX}]['name']) +except: + print('') +" 2>/dev/null || echo "") + + # Resolve GitHub issue node ID (needed for addProjectV2ItemById) + ISSUE_NODE_ID=$(gh api graphql -f query=' + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { id } + } + } + ' -f owner="$OWNER" -f repo="$REPO_NAME" -F number="${ISSUE_NUM}" \ + --jq '.data.repository.issue.id' 2>/dev/null || echo "") + + if [ -z "$ISSUE_NODE_ID" ]; then + BOARD_SYNC_WARNINGS+=("WARNING: Could not resolve node ID for issue #${ISSUE_NUM} — skipping board sync for this issue") + continue + fi + + # Add issue to board + ADD_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { + projectId: $projectId + contentId: $contentId + }) { + item { id } + } + } + ' -f projectId="$BOARD_NODE_ID" -f contentId="$ISSUE_NODE_ID" 2>/dev/null) + + ITEM_ID=$(echo "$ADD_RESULT" | python3 -c " +import json,sys +try: + d = json.load(sys.stdin) + print(d['data']['addProjectV2ItemById']['item']['id']) +except: + print('') +" 2>/dev/null || echo "") + + if [ -z "$ITEM_ID" ]; then + BOARD_SYNC_WARNINGS+=("WARNING: Failed to add issue #${ISSUE_NUM} to board") + continue + fi + + echo " Added #${ISSUE_NUM} to board (item: ${ITEM_ID})" + ITEM_ID_MAP+=("${ISSUE_NUM}:${ITEM_ID}") + + # Set Milestone field (TEXT) + if [ -n "$MILESTONE_FIELD_ID" ] && [ -n "$ISSUE_MILESTONE_NAME" ]; then + gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { text: $value } + }) { projectV2Item { id } } + } + ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \ + -f fieldId="$MILESTONE_FIELD_ID" -f value="$ISSUE_MILESTONE_NAME" \ + 2>/dev/null || BOARD_SYNC_WARNINGS+=("WARNING: Failed to set Milestone field on board item for #${ISSUE_NUM}") + fi + + # Set Phase field (TEXT) — "Phase N: Phase Name" + if [ -n "$PHASE_FIELD_ID" ]; then + PHASE_DISPLAY="Phase ${ISSUE_PHASE_NUM}: ${ISSUE_PHASE_NAME}" + gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { text: $value } + }) { projectV2Item { id } } + } + ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \ + -f fieldId="$PHASE_FIELD_ID" -f value="$PHASE_DISPLAY" \ + 2>/dev/null || BOARD_SYNC_WARNINGS+=("WARNING: Failed to set Phase field on board item for #${ISSUE_NUM}") + fi + + # Set GSD Route field (SINGLE_SELECT) — look up option ID from stored map + if [ -n "$GSD_ROUTE_FIELD_ID" ]; then + # Map template gsd_route to board option key (e.g. "plan-phase" → "gsd:plan-phase") + # GSD_ROUTE_OPTIONS stores keys like "gsd:quick", "gsd:plan-phase", etc. + ROUTE_OPTION_ID=$(echo "$GSD_ROUTE_OPTIONS" | python3 -c " +import json,sys +opts = json.load(sys.stdin) +# Try exact match on gsd: prefix first, then plain match +route = '${ISSUE_GSD_ROUTE}' +for key, val in opts.items(): + if key == 'gsd:' + route or key == route: + print(val) + sys.exit(0) +# Fallback: plain match on the route name without prefix +for key, val in opts.items(): + if key.endswith(':' + route) or key == route: + print(val) + sys.exit(0) +print('') +" 2>/dev/null || echo "") + + if [ -n "$ROUTE_OPTION_ID" ]; then + gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } + ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \ + -f fieldId="$GSD_ROUTE_FIELD_ID" -f optionId="$ROUTE_OPTION_ID" \ + 2>/dev/null || BOARD_SYNC_WARNINGS+=("WARNING: Failed to set GSD Route field on board item for #${ISSUE_NUM}") + fi + fi + done + + if [ ${#BOARD_SYNC_WARNINGS[@]} -gt 0 ]; then + echo "" + echo "Board sync warnings:" + for W in "${BOARD_SYNC_WARNINGS[@]}"; do + echo " $W" + done + fi + + BOARD_SYNC_COUNT=$((${#ITEM_ID_MAP[@]})) + echo " Board sync complete: ${BOARD_SYNC_COUNT}/${TOTAL_ISSUES_CREATED} issues synced" +fi +``` + + **Write .mgw/project.json with project state** @@ -607,6 +829,8 @@ for m_idx, milestone in enumerate(template_data['milestones']): # Find github number from SLUG_TO_NUMBER slug = slugify(issue['title'])[:40] gh_num = SLUG_TO_NUMBER_MAP.get(slug) + # Look up board_item_id from ITEM_ID_MAP if available + item_id = ITEM_ID_MAP_DICT.get(gh_num, None) issues_out.append({ "github_number": gh_num, "title": issue['title'], @@ -615,7 +839,8 @@ for m_idx, milestone in enumerate(template_data['milestones']): "gsd_route": phase.get('gsd_route', 'plan-phase'), "labels": issue.get('labels', []), "depends_on_slugs": issue.get('depends_on', []), - "pipeline_stage": "new" + "pipeline_stage": "new", + "board_item_id": item_id }) milestones_out.append({ @@ -658,7 +883,18 @@ import json, sys # Read template data from the validated generated file template_data = json.load(open('/tmp/mgw-template.json')) -# ... (construct from available bash variables) + +# Build ITEM_ID_MAP_DICT from bash ITEM_ID_MAP array ("issue_num:item_id" entries) +# This dict maps github_number (int) -> board_item_id (str) +ITEM_ID_MAP_DICT = {} +for entry in [x for x in '''${ITEM_ID_MAP[*]}'''.split() if ':' in x]: + parts = entry.split(':', 1) + try: + ITEM_ID_MAP_DICT[int(parts[0])] = parts[1] + except (ValueError, IndexError): + pass + +# ... (construct from available bash variables — see pseudocode above) PYEOF ``` @@ -666,6 +902,11 @@ The simplest implementation: build the JSON structure incrementally during the issue/milestone creation steps (maintaining bash arrays), then assemble them into a python3 dictionary and write with `json.dumps(indent=2)` at this step. +The `ITEM_ID_MAP` bash array (populated in `sync_milestone_to_board`) contains entries +in `"issue_number:board_item_id"` format. Decode it into `ITEM_ID_MAP_DICT` (as shown +above) and use it when building each issue record so `board_item_id` is stored. +If board sync was skipped (ITEM_ID_MAP is empty), `board_item_id` is null for all issues. + Note: use `GENERATED_TYPE` (read from `/tmp/mgw-template.json`) for the `template` field in project.json, not a hardcoded template name. @@ -770,7 +1011,12 @@ Warnings: - [ ] Slug-to-number mapping built during Pass 1b - [ ] Dependency labels applied (Pass 2) — blocked-by:#N on dependent issues - [ ] cross-refs.json updated with dependency entries -- [ ] .mgw/project.json written with full project state +- [ ] Board sync: if board configured (PROJECT_NUMBER + BOARD_NODE_ID in project.json), each new issue added as board item +- [ ] Board sync: Milestone, Phase, and GSD Route fields set on each board item where field IDs are available +- [ ] Board sync: board_item_id stored per issue in project.json (null if board sync skipped or failed) +- [ ] Board sync: non-blocking — GraphQL errors logged as warnings, pipeline continues +- [ ] Board sync: skipped silently if board not configured or custom fields not set up +- [ ] .mgw/project.json written with full project state (including board_item_id per issue) - [ ] Post-init summary displayed - [ ] Command does NOT trigger execution (PROJ-05) - [ ] Extend mode: all milestones complete detected, new milestones appended, existing data preserved diff --git a/docs/BOARD-SCHEMA.md b/docs/BOARD-SCHEMA.md new file mode 100644 index 0000000..5c8880c --- /dev/null +++ b/docs/BOARD-SCHEMA.md @@ -0,0 +1,226 @@ +# MGW Board Schema + +This document describes the GitHub Projects v2 board structure used by MGW, including +custom fields, option values, and layout views. + +--- + +## Overview + +The MGW pipeline board is a GitHub Projects v2 project created by `/mgw:board create`. +It tracks all issues managed by the MGW pipeline, with custom fields reflecting the +pipeline state stored in `.mgw/active/` and `.mgw/project.json`. + +--- + +## Custom Fields + +| Field Name | Type | Description | +|------------|------|-------------| +| Status | SINGLE_SELECT | Maps to `pipeline_stage` in issue state | +| AI Agent State | TEXT | Current GSD agent activity or last action | +| Milestone | TEXT | Milestone title (e.g. "v2 — GitHub Projects Board Management") | +| Phase | TEXT | Phase number and name (e.g. "15 — Multi-Layout Views") | +| GSD Route | SINGLE_SELECT | GSD execution route for the issue | + +--- + +## Status Field Options + +The Status field maps directly to MGW `pipeline_stage` values. The 13 options are: + +| Option Name | Color | pipeline_stage | Description | +|-------------|-------|---------------|-------------| +| New | GRAY | `new` | Issue created, not yet triaged | +| Triaged | BLUE | `triaged` | Triage complete, ready for execution | +| Needs Info | YELLOW | `needs-info` | Blocked at triage gate — needs more detail | +| Needs Security Review | RED | `needs-security-review` | High security risk flagged | +| Discussing | PURPLE | `discussing` | Awaiting stakeholder scope approval | +| Approved | GREEN | `approved` | Discussion complete, cleared for execution | +| Planning | BLUE | `planning` | GSD planner agent active | +| Executing | ORANGE | `executing` | GSD executor agent active | +| Verifying | BLUE | `verifying` | GSD verifier agent active | +| PR Created | GREEN | `pr-created` | PR open, awaiting review | +| Done | GREEN | `done` | PR merged, issue closed | +| Failed | RED | `failed` | Unrecoverable pipeline error | +| Blocked | RED | `blocked` | Blocking comment detected | + +--- + +## GSD Route Field Options + +| Option Name | Route | Description | +|-------------|-------|-------------| +| quick | `gsd:quick` | Small/atomic task, direct execution | +| quick --full | `gsd:quick --full` | Small task with plan-checker and verifier | +| plan-phase | `gsd:plan-phase` | Medium task with phase planning | +| new-milestone | `gsd:new-milestone` | Large task with full milestone lifecycle | + +--- + +## Views + +GitHub Projects v2 supports multiple layout views: Board (kanban), Table, and Roadmap. +MGW creates and configures these views using `/mgw:board views`. + +### Intended Views + +| View Name | Layout | Group By | Sort By | Purpose | +|-----------|--------|----------|---------|---------| +| Kanban — Pipeline Stages | BOARD_LAYOUT | Status | — | Swimlane view per pipeline stage | +| Triage Table — Team Planning | TABLE_LAYOUT | — | Status (asc) | Triage planning surface sorted by pipeline status | +| Roadmap — Milestone Timeline | ROADMAP_LAYOUT | Milestone | — | Timeline visualization grouped by milestone | + +### View Configuration Notes + +**Kanban — Pipeline Stages (Board Layout)** + +- Created by `/mgw:board views kanban` +- Group By must be set to "Status" in the GitHub Projects UI after creation +- Each pipeline stage becomes a swimlane column +- GitHub's API does not support programmatic configuration of board grouping — + use the view's settings menu in the GitHub UI after the view is created + +**Triage Table — Team Planning (Table Layout)** + +- Created by `/mgw:board views table` +- Primary planning surface for team triage and routing visibility +- Column order for triage planning (configure in GitHub Projects UI): + + | Order | Column | Purpose | + |-------|--------|---------| + | 1 | Status | Pipeline position — sort ascending for pipeline order | + | 2 | Milestone | Which milestone the issue belongs to | + | 3 | Phase | Phase number and name within the milestone | + | 4 | GSD Route | Execution route (quick, plan-phase, new-milestone) | + | 5 | AI Agent State | Live agent activity or last action | + +- Sort By: **Status ascending** — surfaces active work (Executing, Planning, Verifying) + at top, done work (Done, PR Created) at bottom +- GitHub's API does not support setting column order or sort programmatically — + configure via the view settings menu in the GitHub UI after creation + +**Roadmap — Milestone Timeline (Roadmap Layout)** + +- Created by `/mgw:board views roadmap` +- View name: "Roadmap — Milestone Timeline" +- Purpose: leadership and planning overview of the full project lifecycle by milestone + +**Roadmap grouping (configure in GitHub Projects UI after creation):** + +1. Open the board and click the "Roadmap — Milestone Timeline" view tab +2. Click the view settings (down-arrow next to the view name) +3. Set "Group by" -> "Milestone" + +Items will be grouped by the Milestone field value, showing all issues belonging to +each milestone as a row group in the roadmap. + +**Timeline date field limitation:** + +GitHub Roadmap layout requires date fields (start date + target date) assigned to +each item to render timeline bars. MGW uses iteration-based milestone tracking and +does not define explicit start/end date fields on board items by default. + +As a result: +- Items appear in the roadmap grouped by Milestone (grouping works correctly) +- Timeline bars are not displayed without date fields configured + +**Workaround — milestone due dates as timeline anchor:** + +GitHub Projects v2 can read GitHub milestone due dates as a date source. To enable +timeline bar rendering, set due dates on milestones via the API: + +```bash +gh api repos/{owner}/{repo}/milestones/{number} \ + --method PATCH \ + -f due_on='YYYY-MM-DDT00:00:00Z' +``` + +Once milestone due dates are set, configure the roadmap's date fields in the GitHub UI: +- Date field: "Target Date" -> milestone due date + +This gives the roadmap a target anchor per milestone without requiring issue-level +date fields in the MGW schema. + +--- + +## API Reference + +### Creating Views + +```graphql +mutation { + createProjectV2View(input: { + projectId: $projectId + name: "Roadmap — Milestone Timeline" + layout: ROADMAP_LAYOUT + }) { + projectV2View { id name layout } + } +} +``` + +Valid layout values: `BOARD_LAYOUT`, `TABLE_LAYOUT`, `ROADMAP_LAYOUT` + +### Limitation: Board Grouping and Date Fields + +GitHub's Projects v2 GraphQL API does not expose mutations for: +- Setting the "Group by" field on any view layout +- Configuring date fields for roadmap timeline bars + +Both must be configured manually in the GitHub UI: + +1. Open the board at the project URL +2. Click the view name to open view settings +3. For kanban: set "Group by: Status" +4. For roadmap: set "Group by: Milestone" +5. For roadmap: configure date fields under "Date fields" settings + +This limitation is documented in the GitHub Projects v2 API changelog. +The `/mgw:board views` command creates the view and outputs these instructions. + +--- + +## Storage + +Board metadata is stored in `.mgw/project.json` under `project.project_board`: + +```json +{ + "project": { + "project_board": { + "number": 1, + "url": "https://github.com/orgs/owner/projects/1", + "node_id": "PVT_...", + "fields": { + "status": { + "field_id": "PVTSSF_...", + "field_name": "Status", + "type": "SINGLE_SELECT", + "options": { + "new": "option-id-1", + "triaged": "option-id-2" + } + } + }, + "views": { + "kanban": { + "view_id": "PVTV_...", + "name": "Kanban — Pipeline Stages", + "layout": "BOARD_LAYOUT" + }, + "table": { + "view_id": "PVTV_...", + "name": "Triage Table — Team Planning", + "layout": "TABLE_LAYOUT" + }, + "roadmap": { + "view_id": "PVTV_...", + "name": "Roadmap — Milestone Timeline", + "layout": "ROADMAP_LAYOUT" + } + } + } + } +} +``` diff --git a/lib/github.cjs b/lib/github.cjs index 6be7309..82e0b43 100644 --- a/lib/github.cjs +++ b/lib/github.cjs @@ -152,6 +152,112 @@ function addItemToProject(owner, projectNumber, issueUrl) { ); } +/** + * Post a milestone-start announcement to GitHub Discussions (Announcements category). + * Falls back to a comment on the first milestone issue if Discussions are not enabled. + * Never throws — all errors are caught and returned as { posted: false }. + * + * @param {object} opts + * @param {string} opts.repo - "owner/repo" + * @param {string} opts.milestoneName - Human-readable milestone name + * @param {string|number} [opts.milestoneNumber] - GitHub milestone number (optional, for context) + * @param {string} [opts.boardUrl] - Optional project board URL + * @param {Array<{number: number, title: string, assignee: string|null, gsdRoute: string}>} opts.issues + * @param {number} [opts.firstIssueNumber] - Fallback: issue number to comment on if Discussions fail + * @returns {{ posted: boolean, method: 'discussion'|'comment'|'none', url: string|null }} + */ +function postMilestoneStartAnnouncement(opts) { + const { + repo, + milestoneName, + boardUrl, + issues, + firstIssueNumber + } = opts; + + const timestamp = new Date().toISOString(); + const issueList = Array.isArray(issues) ? issues : []; + + // Build issue table rows + const issueRows = issueList.map(i => { + const assignee = i.assignee ? `@${i.assignee}` : '—'; + return `| #${i.number} | ${i.title} | ${assignee} | \`${i.gsdRoute}\` |`; + }).join('\n'); + + const boardLine = boardUrl + ? `**Board:** ${boardUrl}` + : '**Board:** _(not configured)_'; + + const body = [ + `> **MGW** · \`milestone-started\` · ${timestamp}`, + '', + `## Milestone Execution Started: ${milestoneName}`, + '', + boardLine, + '', + '### Issues in This Milestone', + '', + '| # | Title | Assignee | Route |', + '|---|-------|----------|-------|', + issueRows, + '', + `**${issueList.length} issue(s)** queued for autonomous execution. PRs will be posted on each issue as they complete.`, + '', + '---', + '*Auto-posted by MGW milestone orchestration*' + ].join('\n'); + + const title = `[MGW] Milestone Started: ${milestoneName}`; + + // 1. Try GitHub Discussions (Announcements category) via GraphQL + if (repo) { + try { + const [owner, repoName] = repo.split('/'); + + const repoMetaRaw = run( + `gh api graphql -f query='query { repository(owner: "${owner}", name: "${repoName}") { id discussionCategories(first: 20) { nodes { id name } } } }' --jq '.data.repository'` + ); + const repoMeta = JSON.parse(repoMetaRaw); + + const categories = (repoMeta.discussionCategories && repoMeta.discussionCategories.nodes) || []; + const announcements = categories.find(c => c.name === 'Announcements'); + + if (announcements) { + const repoId = repoMeta.id; + const categoryId = announcements.id; + + const bodyEscaped = JSON.stringify(body); + const titleEscaped = JSON.stringify(title); + + const resultRaw = run( + `gh api graphql -f query='mutation { createDiscussion(input: { repositoryId: ${JSON.stringify(repoId)}, categoryId: ${JSON.stringify(categoryId)}, title: ${titleEscaped}, body: ${bodyEscaped} }) { discussion { url } } }' --jq '.data.createDiscussion.discussion'` + ); + + const result = JSON.parse(resultRaw); + if (result && result.url) { + return { posted: true, method: 'discussion', url: result.url }; + } + } + } catch (_) { + // Discussions not available or GraphQL failed — fall through to comment + } + } + + // 2. Fallback: comment on first issue + if (firstIssueNumber && repo) { + try { + run( + `gh issue comment ${firstIssueNumber} --repo ${JSON.stringify(repo)} --body ${JSON.stringify(body)}` + ); + return { posted: true, method: 'comment', url: null }; + } catch (_) { + // ignore + } + } + + return { posted: false, method: 'none', url: null }; +} + module.exports = { getRepo, getIssue, @@ -161,5 +267,6 @@ module.exports = { closeMilestone, createRelease, createProject, - addItemToProject + addItemToProject, + postMilestoneStartAnnouncement };