@snipcodeit/mgw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,404 @@
1
+ <purpose>
2
+ Shared board sync utilities for MGW pipeline commands. Three functions are exported:
3
+
4
+ - update_board_status — Called after any pipeline_stage transition to update the board
5
+ item's Status (single-select) field.
6
+ - update_board_agent_state — Called around GSD agent spawns to surface the active agent
7
+ in the board item's "AI Agent State" (text) field. Cleared after PR creation.
8
+ - sync_pr_to_board — Called after PR creation to add the PR as a board item (PR-type
9
+ item linked to the issue's board item).
10
+
11
+ All board updates are non-blocking: if the board is not configured, if the issue has no
12
+ board_item_id, or if the API call fails, the function returns silently. A board sync
13
+ failure MUST NEVER block pipeline execution.
14
+ </purpose>
15
+
16
+ ## update_board_status
17
+
18
+ Call this function after any `pipeline_stage` transition in any MGW command.
19
+
20
+ ```bash
21
+ # update_board_status — Update board Status field after a pipeline_stage transition
22
+ # Args: ISSUE_NUMBER, NEW_PIPELINE_STAGE
23
+ # Non-blocking: all failures are silent no-ops
24
+ update_board_status() {
25
+ local ISSUE_NUMBER="$1"
26
+ local NEW_STAGE="$2"
27
+
28
+ if [ -z "$ISSUE_NUMBER" ] || [ -z "$NEW_STAGE" ]; then
29
+ return 0
30
+ fi
31
+
32
+ # Read board project node ID from project.json (non-blocking — if not configured, skip)
33
+ BOARD_NODE_ID=$(python3 -c "
34
+ import json, sys
35
+ try:
36
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
37
+ print(p.get('project', {}).get('project_board', {}).get('node_id', ''))
38
+ except:
39
+ print('')
40
+ " 2>/dev/null || echo "")
41
+ if [ -z "$BOARD_NODE_ID" ]; then return 0; fi
42
+
43
+ # Get board_item_id for this issue from project.json
44
+ ITEM_ID=$(python3 -c "
45
+ import json, sys
46
+ try:
47
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
48
+ for m in p.get('milestones', []):
49
+ for i in m.get('issues', []):
50
+ if i.get('github_number') == ${ISSUE_NUMBER}:
51
+ print(i.get('board_item_id', ''))
52
+ sys.exit(0)
53
+ print('')
54
+ except:
55
+ print('')
56
+ " 2>/dev/null || echo "")
57
+ if [ -z "$ITEM_ID" ]; then return 0; fi
58
+
59
+ # Map pipeline_stage to Status field option ID
60
+ # Reads from board-schema.json first, falls back to project.json fields
61
+ FIELD_ID=$(python3 -c "
62
+ import json, sys, os
63
+ try:
64
+ schema_path = '${REPO_ROOT}/.mgw/board-schema.json'
65
+ if os.path.exists(schema_path):
66
+ s = json.load(open(schema_path))
67
+ print(s.get('fields', {}).get('status', {}).get('field_id', ''))
68
+ else:
69
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
70
+ fields = p.get('project', {}).get('project_board', {}).get('fields', {})
71
+ print(fields.get('status', {}).get('field_id', ''))
72
+ except:
73
+ print('')
74
+ " 2>/dev/null || echo "")
75
+ if [ -z "$FIELD_ID" ]; then return 0; fi
76
+
77
+ OPTION_ID=$(python3 -c "
78
+ import json, sys, os
79
+ try:
80
+ stage = '${NEW_STAGE}'
81
+ schema_path = '${REPO_ROOT}/.mgw/board-schema.json'
82
+ if os.path.exists(schema_path):
83
+ s = json.load(open(schema_path))
84
+ options = s.get('fields', {}).get('status', {}).get('options', {})
85
+ print(options.get(stage, ''))
86
+ else:
87
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
88
+ fields = p.get('project', {}).get('project_board', {}).get('fields', {})
89
+ options = fields.get('status', {}).get('options', {})
90
+ print(options.get(stage, ''))
91
+ except:
92
+ print('')
93
+ " 2>/dev/null || echo "")
94
+ if [ -z "$OPTION_ID" ]; then return 0; fi
95
+
96
+ # Update the Status field on the board item (non-blocking)
97
+ gh api graphql -f query='
98
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
99
+ updateProjectV2ItemFieldValue(input: {
100
+ projectId: $projectId
101
+ itemId: $itemId
102
+ fieldId: $fieldId
103
+ value: { singleSelectOptionId: $optionId }
104
+ }) { projectV2Item { id } }
105
+ }
106
+ ' -f projectId="$BOARD_NODE_ID" \
107
+ -f itemId="$ITEM_ID" \
108
+ -f fieldId="$FIELD_ID" \
109
+ -f optionId="$OPTION_ID" 2>/dev/null || true
110
+ }
111
+ ```
112
+
113
+ ## update_board_agent_state
114
+
115
+ Call this function before spawning each GSD agent and after PR creation to surface
116
+ real-time agent activity in the board item's "AI Agent State" text field.
117
+
118
+ ```bash
119
+ # update_board_agent_state — Update AI Agent State text field on the board item
120
+ # Args: ISSUE_NUMBER, STATE_TEXT (empty string to clear the field)
121
+ # Non-blocking: all failures are silent no-ops
122
+ update_board_agent_state() {
123
+ local ISSUE_NUMBER="$1"
124
+ local STATE_TEXT="$2"
125
+
126
+ if [ -z "$ISSUE_NUMBER" ]; then return 0; fi
127
+
128
+ # Read board project node ID from project.json (non-blocking — if not configured, skip)
129
+ BOARD_NODE_ID=$(python3 -c "
130
+ import json, sys
131
+ try:
132
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
133
+ print(p.get('project', {}).get('project_board', {}).get('node_id', ''))
134
+ except:
135
+ print('')
136
+ " 2>/dev/null || echo "")
137
+ if [ -z "$BOARD_NODE_ID" ]; then return 0; fi
138
+
139
+ # Get board_item_id for this issue from project.json
140
+ ITEM_ID=$(python3 -c "
141
+ import json, sys
142
+ try:
143
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
144
+ for m in p.get('milestones', []):
145
+ for i in m.get('issues', []):
146
+ if i.get('github_number') == ${ISSUE_NUMBER}:
147
+ print(i.get('board_item_id', ''))
148
+ sys.exit(0)
149
+ print('')
150
+ except:
151
+ print('')
152
+ " 2>/dev/null || echo "")
153
+ if [ -z "$ITEM_ID" ]; then return 0; fi
154
+
155
+ # Get the AI Agent State field ID from board-schema.json or project.json
156
+ FIELD_ID=$(python3 -c "
157
+ import json, sys, os
158
+ try:
159
+ schema_path = '${REPO_ROOT}/.mgw/board-schema.json'
160
+ if os.path.exists(schema_path):
161
+ s = json.load(open(schema_path))
162
+ print(s.get('fields', {}).get('ai_agent_state', {}).get('field_id', ''))
163
+ else:
164
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
165
+ fields = p.get('project', {}).get('project_board', {}).get('fields', {})
166
+ print(fields.get('ai_agent_state', {}).get('field_id', ''))
167
+ except:
168
+ print('')
169
+ " 2>/dev/null || echo "")
170
+ if [ -z "$FIELD_ID" ]; then return 0; fi
171
+
172
+ # Update the AI Agent State text field on the board item (non-blocking)
173
+ gh api graphql -f query='
174
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) {
175
+ updateProjectV2ItemFieldValue(input: {
176
+ projectId: $projectId
177
+ itemId: $itemId
178
+ fieldId: $fieldId
179
+ value: { text: $text }
180
+ }) { projectV2Item { id } }
181
+ }
182
+ ' -f projectId="$BOARD_NODE_ID" \
183
+ -f itemId="$ITEM_ID" \
184
+ -f fieldId="$FIELD_ID" \
185
+ -f text="$STATE_TEXT" 2>/dev/null || true
186
+ }
187
+ ```
188
+
189
+ ## Stage-to-Status Mapping
190
+
191
+ The Status field options correspond to pipeline_stage values:
192
+
193
+ | pipeline_stage | Board Status Option |
194
+ |----------------|-------------------|
195
+ | `new` | New |
196
+ | `triaged` | Triaged |
197
+ | `needs-info` | Needs Info |
198
+ | `needs-security-review` | Needs Security Review |
199
+ | `discussing` | Discussing |
200
+ | `approved` | Approved |
201
+ | `planning` | Planning |
202
+ | `executing` | Executing |
203
+ | `verifying` | Verifying |
204
+ | `pr-created` | PR Created |
205
+ | `done` | Done |
206
+ | `failed` | Failed |
207
+ | `blocked` | Blocked |
208
+
209
+ Option IDs for each stage are looked up at runtime from:
210
+ 1. `.mgw/board-schema.json` → `fields.status.options.<stage>` (preferred)
211
+ 2. `.mgw/project.json` → `project.project_board.fields.status.options.<stage>` (fallback)
212
+
213
+ ## AI Agent State Values
214
+
215
+ The AI Agent State text field is set before each GSD agent spawn and cleared after PR creation:
216
+
217
+ | Trigger | Value |
218
+ |---------|-------|
219
+ | Before gsd-planner spawn (quick route) | `"Planning"` |
220
+ | Before gsd-executor spawn (quick route) | `"Executing"` |
221
+ | Before gsd-verifier spawn (quick route) | `"Verifying"` |
222
+ | Before gsd-planner spawn (milestone, phase N) | `"Planning phase N"` |
223
+ | Before gsd-executor spawn (milestone, phase N) | `"Executing phase N"` |
224
+ | Before gsd-verifier spawn (milestone, phase N) | `"Verifying phase N"` |
225
+ | After PR created | `""` (clears the field) |
226
+
227
+ ## Data Sources
228
+
229
+ | Field | Source |
230
+ |-------|--------|
231
+ | `BOARD_NODE_ID` | `project.json` → `project.project_board.node_id` |
232
+ | `ITEM_ID` | `project.json` → `milestones[*].issues[*].board_item_id` (set by #73) |
233
+ | `FIELD_ID` (status) | `board-schema.json` or `project.json` → `fields.status.field_id` |
234
+ | `OPTION_ID` | `board-schema.json` or `project.json` → `fields.status.options.<stage>` |
235
+ | `FIELD_ID` (agent state) | `board-schema.json` or `project.json` → `fields.ai_agent_state.field_id` |
236
+
237
+ ## Non-Blocking Contract
238
+
239
+ Every failure case returns 0 (success) without printing to stderr. The caller is never
240
+ aware of board sync failures. This guarantees:
241
+
242
+ - Board not configured (no `node_id` in project.json) → silent no-op
243
+ - Issue has no `board_item_id` → silent no-op (not yet added to board)
244
+ - Status field not configured → silent no-op
245
+ - AI Agent State field not configured → silent no-op
246
+ - Stage has no mapped option ID → silent no-op
247
+ - GraphQL API error → silent no-op (`|| true` suppresses exit code)
248
+ - Network error → silent no-op
249
+
250
+ ## Touch Points
251
+
252
+ Source or inline both utilities in any MGW command that spawns GSD agents.
253
+
254
+ ### update_board_status — in issue.md (triage stage transitions)
255
+
256
+ After writing `pipeline_stage` to the state file in the `write_state` step:
257
+ ```bash
258
+ # After: pipeline_stage written to .mgw/active/<issue>.json
259
+ update_board_status $ISSUE_NUMBER "$pipeline_stage" # non-blocking
260
+ ```
261
+
262
+ Transitions in issue.md:
263
+ - `needs-info` — validity or detail gate blocked
264
+ - `needs-security-review` — security gate blocked
265
+ - `triaged` — all gates passed or user override
266
+
267
+ ### update_board_status — in run.md (pipeline stage transitions)
268
+
269
+ After each `pipeline_stage` checkpoint write to project.json and state file:
270
+ ```bash
271
+ # After: pipeline_stage checkpoint written (state.md "Update Issue Pipeline Stage" pattern)
272
+ update_board_status $ISSUE_NUMBER "$NEW_STAGE" # non-blocking
273
+ ```
274
+
275
+ Transitions in run.md:
276
+ - `planning` — GSD execution begins
277
+ - `executing` — executor agent active
278
+ - `verifying` — verifier agent active
279
+ - `pr-created` — PR created
280
+ - `done` — pipeline complete
281
+ - `blocked` — blocking comment detected in preflight_comment_check
282
+
283
+ ### update_board_agent_state — in run.md (around agent spawns)
284
+
285
+ Called immediately before spawning each GSD agent and after PR creation:
286
+ ```bash
287
+ # Before spawning gsd-planner
288
+ update_board_agent_state $ISSUE_NUMBER "Planning phase ${PHASE_NUM}"
289
+
290
+ # Before spawning gsd-executor
291
+ update_board_agent_state $ISSUE_NUMBER "Executing phase ${PHASE_NUM}"
292
+
293
+ # Before spawning gsd-verifier
294
+ update_board_agent_state $ISSUE_NUMBER "Verifying phase ${PHASE_NUM}"
295
+
296
+ # After PR created (clear the field)
297
+ update_board_agent_state $ISSUE_NUMBER ""
298
+ ```
299
+
300
+ ### sync_pr_to_board — in run.md and pr.md (after PR creation)
301
+
302
+ Called immediately after `gh pr create` succeeds in both run.md and pr.md (linked mode):
303
+ ```bash
304
+ # After PR created
305
+ sync_pr_to_board $ISSUE_NUMBER $PR_NUMBER # non-blocking board PR link
306
+ ```
307
+
308
+ ## sync_pr_to_board
309
+
310
+ Call this function after PR creation to add the PR as a board item. In GitHub Projects v2,
311
+ `addProjectV2ItemById` with a PR's node ID creates a PR-type item that GitHub Projects
312
+ tracks separately from the issue item.
313
+
314
+ ```bash
315
+ # sync_pr_to_board — Add PR as a board item, linked to the issue's board item
316
+ # Args: ISSUE_NUMBER, PR_NUMBER
317
+ # Non-blocking: all failures are silent no-ops
318
+ sync_pr_to_board() {
319
+ local ISSUE_NUMBER="$1"
320
+ local PR_NUMBER="$2"
321
+
322
+ if [ -z "$ISSUE_NUMBER" ] || [ -z "$PR_NUMBER" ]; then return 0; fi
323
+
324
+ # Read board project node ID from project.json (non-blocking — if not configured, skip)
325
+ BOARD_NODE_ID=$(python3 -c "
326
+ import json, sys
327
+ try:
328
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
329
+ print(p.get('project', {}).get('project_board', {}).get('node_id', ''))
330
+ except:
331
+ print('')
332
+ " 2>/dev/null || echo "")
333
+ if [ -z "$BOARD_NODE_ID" ]; then return 0; fi
334
+
335
+ # Get PR node ID from GitHub (non-blocking)
336
+ PR_NODE_ID=$(gh pr view "$PR_NUMBER" --json id -q .id 2>/dev/null || echo "")
337
+ if [ -z "$PR_NODE_ID" ]; then return 0; fi
338
+
339
+ # Add PR to board as a PR-type item (creates a separate board entry linked to the PR)
340
+ ITEM_ID=$(gh api graphql -f query='
341
+ mutation($projectId: ID!, $contentId: ID!) {
342
+ addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
343
+ item { id }
344
+ }
345
+ }
346
+ ' -f projectId="$BOARD_NODE_ID" -f contentId="$PR_NODE_ID" \
347
+ --jq '.data.addProjectV2ItemById.item.id' 2>/dev/null || echo "")
348
+
349
+ if [ -n "$ITEM_ID" ]; then
350
+ echo "MGW: PR #${PR_NUMBER} added to board (item: ${ITEM_ID})"
351
+ fi
352
+ }
353
+ ```
354
+
355
+ ## sync_pr_to_board — Board Reconciliation in sync.md
356
+
357
+ During `mgw:sync`, after cross-refs are loaded, check for any `implements` links
358
+ (issue → PR) that don't yet have a board item for the PR. For each such link, call
359
+ `sync_pr_to_board` to ensure the board reflects all linked PRs.
360
+
361
+ ```bash
362
+ # Board reconciliation — ensure all PR cross-refs have board items (non-blocking)
363
+ if [ -f "${REPO_ROOT}/.mgw/project.json" ] && [ -f "${REPO_ROOT}/.mgw/cross-refs.json" ]; then
364
+ BOARD_NODE_ID=$(python3 -c "
365
+ import json, sys
366
+ try:
367
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
368
+ print(p.get('project', {}).get('project_board', {}).get('node_id', ''))
369
+ except:
370
+ print('')
371
+ " 2>/dev/null || echo "")
372
+
373
+ if [ -n "$BOARD_NODE_ID" ]; then
374
+ # Find all issue→PR implements links in cross-refs
375
+ PR_LINKS=$(python3 -c "
376
+ import json
377
+ refs = json.load(open('${REPO_ROOT}/.mgw/cross-refs.json'))
378
+ for link in refs.get('links', []):
379
+ if link.get('type') == 'implements' and link['a'].startswith('issue:') and link['b'].startswith('pr:'):
380
+ issue_num = link['a'].split(':')[1]
381
+ pr_num = link['b'].split(':')[1]
382
+ print(f'{issue_num} {pr_num}')
383
+ " 2>/dev/null || echo "")
384
+
385
+ # For each issue→PR link, ensure the PR is on the board (sync_pr_to_board is idempotent)
386
+ while IFS=' ' read -r LINKED_ISSUE LINKED_PR; do
387
+ [ -z "$LINKED_PR" ] && continue
388
+ sync_pr_to_board "$LINKED_ISSUE" "$LINKED_PR" # non-blocking
389
+ done <<< "$PR_LINKS"
390
+ fi
391
+ fi
392
+ ```
393
+
394
+ ## Consumers
395
+
396
+ | Command | Function | When Called |
397
+ |---------|----------|-------------|
398
+ | issue.md | update_board_status | After writing pipeline_stage in write_state step |
399
+ | run.md | update_board_status | After each pipeline_stage checkpoint write |
400
+ | run.md | update_board_agent_state | Before each GSD agent spawn, and after PR creation |
401
+ | run.md | sync_pr_to_board | After PR creation (before cross-ref is recorded) |
402
+ | pr.md | sync_pr_to_board | After PR creation in create_pr step (linked mode only) |
403
+ | sync.md | sync_pr_to_board | Board reconciliation — for each PR link in cross-refs |
404
+ | board.md (sync) | bulk reconciliation | Iterates all .mgw/active/*.json, fetches board items via node ID query, applies updateProjectV2ItemFieldValue for Status/AI Agent State/Phase/Milestone fields. Uses same GraphQL mutations as the three utility functions above, but in a single bulk loop with diff tracking. |