@snipcodeit/mgw 0.1.1 → 0.1.2
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.
- package/README.md +20 -9
- package/commands/board.md +75 -0
- package/commands/milestone.md +138 -15
- package/commands/project.md +55 -1651
- package/commands/run.md +285 -11
- package/commands/sync.md +332 -1
- package/dist/bin/mgw.cjs +2 -2
- package/dist/{claude-Vp9qvImH.cjs → claude-Dk1oVsaG.cjs} +156 -0
- package/dist/lib/index.cjs +237 -12
- package/package.json +1 -1
package/commands/sync.md
CHANGED
|
@@ -16,6 +16,11 @@ if the GitHub issue is still open, if linked PRs were merged/closed, and if trac
|
|
|
16
16
|
branches still exist. Moves completed items to .mgw/completed/, cleans up branches
|
|
17
17
|
and lingering worktrees, flags inconsistencies.
|
|
18
18
|
|
|
19
|
+
Also pulls board state to reconstruct missing local state files — enabling multi-machine
|
|
20
|
+
workflows. On a fresh machine with no .mgw/active/ files, sync reads the GitHub Projects
|
|
21
|
+
v2 board's Status field and rebuilds local state for every in-progress issue, so work
|
|
22
|
+
can continue without re-triaging from scratch.
|
|
23
|
+
|
|
19
24
|
Run periodically or when starting a new session to get a clean view.
|
|
20
25
|
</objective>
|
|
21
26
|
|
|
@@ -28,6 +33,321 @@ Run periodically or when starting a new session to get a clean view.
|
|
|
28
33
|
|
|
29
34
|
<process>
|
|
30
35
|
|
|
36
|
+
<step name="pull_board_state">
|
|
37
|
+
**Pull board state to reconstruct missing local .mgw/active/ files.**
|
|
38
|
+
|
|
39
|
+
This step runs first — before scan_active — so that any issues the board knows about
|
|
40
|
+
but this machine doesn't will be present by the time check_each runs. This is the
|
|
41
|
+
multi-machine sync mechanism: board is the distributed source of truth, .mgw/active/
|
|
42
|
+
is the local cache that sync rebuilds from it.
|
|
43
|
+
|
|
44
|
+
Two sub-operations, both non-blocking:
|
|
45
|
+
1. **Board discovery** — if project_board.node_id is missing, find and register it.
|
|
46
|
+
2. **Board pull** — fetch all board items, reconstruct .mgw/active/ for any issue not
|
|
47
|
+
present locally, detect stage drift for issues that exist on both sides.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
51
|
+
MGW_DIR="${REPO_ROOT}/.mgw"
|
|
52
|
+
OWNER=$(gh repo view --json owner -q .owner.login 2>/dev/null)
|
|
53
|
+
ACTIVE_DIR="${MGW_DIR}/active"
|
|
54
|
+
mkdir -p "$ACTIVE_DIR"
|
|
55
|
+
|
|
56
|
+
BOARD_NODE_ID=$(python3 -c "
|
|
57
|
+
import json
|
|
58
|
+
try:
|
|
59
|
+
p = json.load(open('${MGW_DIR}/project.json'))
|
|
60
|
+
print(p.get('project', {}).get('project_board', {}).get('node_id', ''))
|
|
61
|
+
except: print('')
|
|
62
|
+
" 2>/dev/null || echo "")
|
|
63
|
+
|
|
64
|
+
BOARD_DISCOVERED=""
|
|
65
|
+
BOARD_PULL_CREATED=0
|
|
66
|
+
BOARD_PULL_DRIFT="[]"
|
|
67
|
+
BOARD_PULL_ERRORS="[]"
|
|
68
|
+
|
|
69
|
+
# Sub-operation 1: board discovery
|
|
70
|
+
if [ -z "$BOARD_NODE_ID" ] && [ -f "${MGW_DIR}/project.json" ]; then
|
|
71
|
+
PROJECT_NAME=$(python3 -c "
|
|
72
|
+
import json
|
|
73
|
+
try:
|
|
74
|
+
p = json.load(open('${MGW_DIR}/project.json'))
|
|
75
|
+
print(p.get('project', {}).get('name', ''))
|
|
76
|
+
except: print('')
|
|
77
|
+
" 2>/dev/null || echo "")
|
|
78
|
+
|
|
79
|
+
if [ -n "$PROJECT_NAME" ]; then
|
|
80
|
+
DISCOVERED=$(node -e "
|
|
81
|
+
const { findExistingBoard, getProjectFields } = require('./lib/github.cjs');
|
|
82
|
+
const board = findExistingBoard('${OWNER}', '${PROJECT_NAME}');
|
|
83
|
+
if (!board) { process.stdout.write(''); process.exit(0); }
|
|
84
|
+
const fields = getProjectFields('${OWNER}', board.number) || {};
|
|
85
|
+
console.log(JSON.stringify({ ...board, fields }));
|
|
86
|
+
" 2>/dev/null || echo "")
|
|
87
|
+
|
|
88
|
+
if [ -n "$DISCOVERED" ]; then
|
|
89
|
+
python3 -c "
|
|
90
|
+
import json
|
|
91
|
+
with open('${MGW_DIR}/project.json') as f:
|
|
92
|
+
project = json.load(f)
|
|
93
|
+
d = json.loads('${DISCOVERED}')
|
|
94
|
+
project['project']['project_board'] = {
|
|
95
|
+
'number': d['number'], 'url': d['url'],
|
|
96
|
+
'node_id': d['nodeId'], 'fields': d.get('fields', {})
|
|
97
|
+
}
|
|
98
|
+
with open('${MGW_DIR}/project.json', 'w') as f:
|
|
99
|
+
json.dump(project, f, indent=2)
|
|
100
|
+
" 2>/dev/null
|
|
101
|
+
BOARD_NODE_ID=$(echo "$DISCOVERED" | python3 -c "import json,sys; print(json.load(sys.stdin)['nodeId'])" 2>/dev/null)
|
|
102
|
+
DISC_NUMBER=$(echo "$DISCOVERED" | python3 -c "import json,sys; print(json.load(sys.stdin)['number'])" 2>/dev/null)
|
|
103
|
+
DISC_URL=$(echo "$DISCOVERED" | python3 -c "import json,sys; print(json.load(sys.stdin)['url'])" 2>/dev/null)
|
|
104
|
+
BOARD_DISCOVERED="#${DISC_NUMBER} — ${DISC_URL}"
|
|
105
|
+
fi
|
|
106
|
+
fi
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
# Sub-operation 2: board pull
|
|
110
|
+
if [ -n "$BOARD_NODE_ID" ]; then
|
|
111
|
+
echo "Pulling board state from GitHub..."
|
|
112
|
+
|
|
113
|
+
BOARD_ITEMS=$(gh api graphql -f query='
|
|
114
|
+
query($projectId: ID!) {
|
|
115
|
+
node(id: $projectId) {
|
|
116
|
+
... on ProjectV2 {
|
|
117
|
+
items(first: 100) {
|
|
118
|
+
nodes {
|
|
119
|
+
id
|
|
120
|
+
content {
|
|
121
|
+
... on Issue { number title url }
|
|
122
|
+
}
|
|
123
|
+
fieldValues(first: 10) {
|
|
124
|
+
nodes {
|
|
125
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
126
|
+
name
|
|
127
|
+
field { ... on ProjectV2SingleSelectField { name } }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
' -f projectId="$BOARD_NODE_ID" \
|
|
137
|
+
--jq '.data.node.items.nodes' 2>/dev/null || echo "[]")
|
|
138
|
+
|
|
139
|
+
# One Python call handles: map board items, detect missing local files,
|
|
140
|
+
# reconstruct state from GitHub issue data, detect drift on existing files.
|
|
141
|
+
PULL_RESULT=$(echo "$BOARD_ITEMS" | ACTIVE_DIR="$ACTIVE_DIR" MGW_DIR="$MGW_DIR" python3 << 'PYEOF'
|
|
142
|
+
import json, sys, os, subprocess, re
|
|
143
|
+
|
|
144
|
+
ACTIVE_DIR = os.environ['ACTIVE_DIR']
|
|
145
|
+
GSD_TOOLS = os.path.expanduser('~/.claude/get-shit-done/bin/gsd-tools.cjs')
|
|
146
|
+
|
|
147
|
+
STATUS_TO_STAGE = {
|
|
148
|
+
'New': 'new', 'Triaged': 'triaged',
|
|
149
|
+
'Needs Info': 'needs-info', 'Needs Security Review': 'needs-security-review',
|
|
150
|
+
'Discussing': 'discussing', 'Approved': 'approved',
|
|
151
|
+
'Planning': 'planning', 'Executing': 'executing',
|
|
152
|
+
'Verifying': 'verifying', 'PR Created': 'pr-created',
|
|
153
|
+
'Done': 'done', 'Failed': 'failed', 'Blocked': 'blocked'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
nodes = json.load(sys.stdin)
|
|
157
|
+
created = []
|
|
158
|
+
drift = []
|
|
159
|
+
errors = []
|
|
160
|
+
|
|
161
|
+
for node in nodes:
|
|
162
|
+
content = node.get('content', {})
|
|
163
|
+
num = content.get('number')
|
|
164
|
+
if num is None:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
status_label = ''
|
|
168
|
+
route_label = ''
|
|
169
|
+
for fv in node.get('fieldValues', {}).get('nodes', []):
|
|
170
|
+
fname = fv.get('field', {}).get('name', '')
|
|
171
|
+
if fname == 'Status':
|
|
172
|
+
status_label = fv.get('name', '')
|
|
173
|
+
elif fname == 'GSD Route':
|
|
174
|
+
route_label = fv.get('name', '')
|
|
175
|
+
|
|
176
|
+
board_stage = STATUS_TO_STAGE.get(status_label, 'new')
|
|
177
|
+
|
|
178
|
+
# Done items belong in completed/, not active/ — skip
|
|
179
|
+
if board_stage == 'done':
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# Check for existing local active file
|
|
183
|
+
existing = None
|
|
184
|
+
for fname in os.listdir(ACTIVE_DIR):
|
|
185
|
+
if fname.startswith(f'{num}-') and fname.endswith('.json'):
|
|
186
|
+
existing = os.path.join(ACTIVE_DIR, fname)
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
if existing is None:
|
|
190
|
+
# No local file — reconstruct from GitHub issue data
|
|
191
|
+
try:
|
|
192
|
+
r = subprocess.run(
|
|
193
|
+
['gh', 'issue', 'view', str(num),
|
|
194
|
+
'--json', 'number,title,url,labels,assignees'],
|
|
195
|
+
capture_output=True, text=True
|
|
196
|
+
)
|
|
197
|
+
issue = json.loads(r.stdout)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
errors.append({'number': num, 'error': str(e)})
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
title = issue.get('title', content.get('title', ''))
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
slug = subprocess.run(
|
|
206
|
+
['node', GSD_TOOLS, 'generate-slug', title, '--raw'],
|
|
207
|
+
capture_output=True, text=True
|
|
208
|
+
).stdout.strip()[:40]
|
|
209
|
+
except:
|
|
210
|
+
slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')[:40]
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
ts = subprocess.run(
|
|
214
|
+
['node', GSD_TOOLS, 'current-timestamp', '--raw'],
|
|
215
|
+
capture_output=True, text=True
|
|
216
|
+
).stdout.strip()
|
|
217
|
+
except:
|
|
218
|
+
from datetime import datetime
|
|
219
|
+
ts = datetime.utcnow().isoformat() + 'Z'
|
|
220
|
+
|
|
221
|
+
labels = [l.get('name', '') if isinstance(l, dict) else str(l)
|
|
222
|
+
for l in issue.get('labels', [])]
|
|
223
|
+
assignees = issue.get('assignees', [])
|
|
224
|
+
assignee = assignees[0].get('login') if assignees else None
|
|
225
|
+
|
|
226
|
+
state = {
|
|
227
|
+
'issue': {
|
|
228
|
+
'number': num,
|
|
229
|
+
'title': title,
|
|
230
|
+
'url': issue.get('url', content.get('url', '')),
|
|
231
|
+
'labels': labels,
|
|
232
|
+
'assignee': assignee
|
|
233
|
+
},
|
|
234
|
+
'triage': {
|
|
235
|
+
'scope': {'files': 0, 'systems': []},
|
|
236
|
+
'validity': 'confirmed',
|
|
237
|
+
'security_notes': '',
|
|
238
|
+
'conflicts': [],
|
|
239
|
+
'last_comment_count': 0,
|
|
240
|
+
'last_comment_at': None,
|
|
241
|
+
'gate_result': {
|
|
242
|
+
'status': 'passed',
|
|
243
|
+
'blockers': [],
|
|
244
|
+
'warnings': [f'Reconstructed from board state by mgw:sync — {ts}'],
|
|
245
|
+
'missing_fields': []
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
'gsd_route': route_label or None,
|
|
249
|
+
'gsd_artifacts': {'type': None, 'path': None},
|
|
250
|
+
'pipeline_stage': board_stage,
|
|
251
|
+
'reconstructed_from_board': True,
|
|
252
|
+
'comments_posted': [],
|
|
253
|
+
'linked_pr': None,
|
|
254
|
+
'linked_issues': [],
|
|
255
|
+
'linked_branches': []
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
state_path = os.path.join(ACTIVE_DIR, f'{num}-{slug}.json')
|
|
259
|
+
with open(state_path, 'w') as f:
|
|
260
|
+
json.dump(state, f, indent=2)
|
|
261
|
+
|
|
262
|
+
created.append({'number': num, 'title': title, 'stage': board_stage})
|
|
263
|
+
|
|
264
|
+
else:
|
|
265
|
+
# Local file exists — detect stage drift
|
|
266
|
+
try:
|
|
267
|
+
local = json.load(open(existing))
|
|
268
|
+
local_stage = local.get('pipeline_stage', 'new')
|
|
269
|
+
if local_stage != board_stage:
|
|
270
|
+
drift.append({
|
|
271
|
+
'number': num,
|
|
272
|
+
'title': content.get('title', ''),
|
|
273
|
+
'local': local_stage,
|
|
274
|
+
'board': board_stage
|
|
275
|
+
})
|
|
276
|
+
except:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
print(json.dumps({'created': created, 'drift': drift, 'errors': errors}))
|
|
280
|
+
PYEOF
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
BOARD_PULL_CREATED=$(echo "$PULL_RESULT" | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('created', [])))" 2>/dev/null || echo "0")
|
|
284
|
+
BOARD_PULL_DRIFT=$(echo "$PULL_RESULT" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin).get('drift', [])))" 2>/dev/null || echo "[]")
|
|
285
|
+
BOARD_PULL_ERRORS=$(echo "$PULL_RESULT" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin).get('errors', [])))" 2>/dev/null || echo "[]")
|
|
286
|
+
|
|
287
|
+
if [ "$BOARD_PULL_CREATED" -gt 0 ]; then
|
|
288
|
+
echo " Reconstructed ${BOARD_PULL_CREATED} issue(s) from board state"
|
|
289
|
+
echo "$PULL_RESULT" | python3 -c "
|
|
290
|
+
import json, sys
|
|
291
|
+
for item in json.load(sys.stdin).get('created', []):
|
|
292
|
+
print(f' ✓ #{item[\"number\"]} ({item[\"stage\"]}): {item[\"title\"][:60]}')
|
|
293
|
+
" 2>/dev/null
|
|
294
|
+
fi
|
|
295
|
+
|
|
296
|
+
# Offer drift resolution if any issues have mismatched stages
|
|
297
|
+
DRIFT_COUNT=$(echo "$BOARD_PULL_DRIFT" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
|
|
298
|
+
|
|
299
|
+
if [ "$DRIFT_COUNT" -gt 0 ]; then
|
|
300
|
+
echo ""
|
|
301
|
+
echo "Stage drift detected (board vs local):"
|
|
302
|
+
echo "$BOARD_PULL_DRIFT" | python3 -c "
|
|
303
|
+
import json, sys
|
|
304
|
+
for d in json.load(sys.stdin):
|
|
305
|
+
print(f' #{d[\"number\"]} {d[\"title\"][:45]}: local={d[\"local\"]} board={d[\"board\"]}')
|
|
306
|
+
" 2>/dev/null
|
|
307
|
+
echo ""
|
|
308
|
+
|
|
309
|
+
AskUserQuestion(
|
|
310
|
+
header: "Stage Drift",
|
|
311
|
+
question: "Board and local state disagree on pipeline stage for ${DRIFT_COUNT} issue(s). How should we resolve?",
|
|
312
|
+
options: [
|
|
313
|
+
{ label: "Pull from board", description: "Update all local files to match board stages (board is source of truth)" },
|
|
314
|
+
{ label: "Keep local", description: "Leave local stages as-is — board will be updated next time pipeline runs" },
|
|
315
|
+
{ label: "Skip", description: "Ignore drift for now — flag in report only" }
|
|
316
|
+
]
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if [ "$USER_CHOICE" = "Pull from board" ]; then
|
|
320
|
+
echo "$BOARD_PULL_DRIFT" | python3 -c "
|
|
321
|
+
import json, sys, os
|
|
322
|
+
|
|
323
|
+
drift = json.load(sys.stdin)
|
|
324
|
+
active_dir = '${ACTIVE_DIR}'
|
|
325
|
+
|
|
326
|
+
for d in drift:
|
|
327
|
+
num = d['number']
|
|
328
|
+
board_stage = d['board']
|
|
329
|
+
for fname in os.listdir(active_dir):
|
|
330
|
+
if fname.startswith(f'{num}-') and fname.endswith('.json'):
|
|
331
|
+
path = os.path.join(active_dir, fname)
|
|
332
|
+
state = json.load(open(path))
|
|
333
|
+
state['pipeline_stage'] = board_stage
|
|
334
|
+
with open(path, 'w') as f:
|
|
335
|
+
json.dump(state, f, indent=2)
|
|
336
|
+
print(f' Updated #{num}: {d[\"local\"]} → {board_stage}')
|
|
337
|
+
break
|
|
338
|
+
" 2>/dev/null
|
|
339
|
+
BOARD_PULL_DRIFT="[]" # Resolved
|
|
340
|
+
fi
|
|
341
|
+
fi
|
|
342
|
+
fi
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**Note:** `reconstructed_from_board: true` is set on any state file created this way.
|
|
346
|
+
Downstream commands (`/mgw:run`, `/mgw:issue`) will see this flag and know the state was
|
|
347
|
+
rebuilt from board data — triage results and GSD artifacts will need to be re-run if the
|
|
348
|
+
issue advances to `planning` or beyond.
|
|
349
|
+
</step>
|
|
350
|
+
|
|
31
351
|
<step name="scan_active">
|
|
32
352
|
**Scan all active issue states:**
|
|
33
353
|
|
|
@@ -214,11 +534,13 @@ git push origin --delete ${BRANCH_NAME} 2>/dev/null
|
|
|
214
534
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
215
535
|
|
|
216
536
|
Active: ${active_count} issues in progress
|
|
537
|
+
Pulled: ${BOARD_PULL_CREATED} reconstructed from board (0 if board not configured)
|
|
217
538
|
Completed: ${completed_count} archived
|
|
218
539
|
Stale: ${stale_count} need attention
|
|
219
540
|
Orphaned: ${orphaned_count} need attention
|
|
220
541
|
Comments: ${comment_drift_count} issues with unreviewed comments
|
|
221
542
|
Branches: ${deleted_count} cleaned up
|
|
543
|
+
${BOARD_DISCOVERED ? 'Board: discovered + registered ' + BOARD_DISCOVERED : ''}
|
|
222
544
|
${HEALTH ? 'GSD Health: ' + HEALTH.status : ''}
|
|
223
545
|
|
|
224
546
|
${details_for_each_non_active_item}
|
|
@@ -231,7 +553,16 @@ ${gsd_milestone_consistency ? 'GSD Milestone Links:\n' + gsd_milestone_consisten
|
|
|
231
553
|
</process>
|
|
232
554
|
|
|
233
555
|
<success_criteria>
|
|
234
|
-
- [ ]
|
|
556
|
+
- [ ] pull_board_state runs before scan_active
|
|
557
|
+
- [ ] Board discovery: if project_board.node_id empty, findExistingBoard() + getProjectFields() called; if found, registered in project.json
|
|
558
|
+
- [ ] Board pull: all board items fetched in one GraphQL call; issues with no local .mgw/active/ file are reconstructed from GitHub issue data + board Status
|
|
559
|
+
- [ ] Reconstructed files have reconstructed_from_board:true, pipeline_stage from board Status, gsd_route from board GSD Route field
|
|
560
|
+
- [ ] "Done" board items are skipped (not written to active/)
|
|
561
|
+
- [ ] Stage drift detected and reported: local pipeline_stage differs from board Status
|
|
562
|
+
- [ ] Drift resolution offered: pull from board / keep local / skip
|
|
563
|
+
- [ ] Board pull errors (failed gh issue view calls) are collected and shown in report, never abort sync
|
|
564
|
+
- [ ] BOARD_PULL_CREATED count shown in sync report
|
|
565
|
+
- [ ] All .mgw/active/ files scanned (including any reconstructed by pull_board_state)
|
|
235
566
|
- [ ] GitHub state checked for each issue, PR, branch
|
|
236
567
|
- [ ] Comment delta checked for each active issue
|
|
237
568
|
- [ ] GSD milestone consistency checked for all maps-to links
|
package/dist/bin/mgw.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
var claude = require('../claude-
|
|
4
|
+
var claude = require('../claude-Dk1oVsaG.cjs');
|
|
5
5
|
var require$$0 = require('commander');
|
|
6
6
|
var require$$1 = require('path');
|
|
7
7
|
var require$$0$2 = require('fs');
|
|
@@ -9,7 +9,7 @@ var require$$0$1 = require('child_process');
|
|
|
9
9
|
|
|
10
10
|
var mgw$1 = {};
|
|
11
11
|
|
|
12
|
-
var version = "0.1.
|
|
12
|
+
var version = "0.1.2";
|
|
13
13
|
var require$$8 = {
|
|
14
14
|
version: version};
|
|
15
15
|
|
|
@@ -107,6 +107,40 @@ function requireState () {
|
|
|
107
107
|
if (changed) {
|
|
108
108
|
writeProjectState(existing);
|
|
109
109
|
}
|
|
110
|
+
const activeDir = getActiveDir();
|
|
111
|
+
if (fs.existsSync(activeDir)) {
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = fs.readdirSync(activeDir);
|
|
115
|
+
} catch {
|
|
116
|
+
entries = [];
|
|
117
|
+
}
|
|
118
|
+
for (const file of entries) {
|
|
119
|
+
if (!file.endsWith(".json")) continue;
|
|
120
|
+
const filePath = path.join(activeDir, file);
|
|
121
|
+
let issueState;
|
|
122
|
+
try {
|
|
123
|
+
issueState = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
124
|
+
} catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
let issueChanged = false;
|
|
128
|
+
if (!issueState.hasOwnProperty("retry_count")) {
|
|
129
|
+
issueState.retry_count = 0;
|
|
130
|
+
issueChanged = true;
|
|
131
|
+
}
|
|
132
|
+
if (!issueState.hasOwnProperty("dead_letter")) {
|
|
133
|
+
issueState.dead_letter = false;
|
|
134
|
+
issueChanged = true;
|
|
135
|
+
}
|
|
136
|
+
if (issueChanged) {
|
|
137
|
+
try {
|
|
138
|
+
fs.writeFileSync(filePath, JSON.stringify(issueState, null, 2), "utf-8");
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
110
144
|
return existing;
|
|
111
145
|
}
|
|
112
146
|
function resolveActiveMilestoneIndex(state) {
|
|
@@ -197,6 +231,125 @@ function requireGithub () {
|
|
|
197
231
|
if (o.prerelease) cmd += " --prerelease";
|
|
198
232
|
return run(cmd);
|
|
199
233
|
}
|
|
234
|
+
function getProjectNodeId(owner, projectNumber) {
|
|
235
|
+
const userQuery = `'query($login: String!, $number: Int!) { user(login: $login) { projectV2(number: $number) { id } } }'`;
|
|
236
|
+
const orgQuery = `'query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { id } } }'`;
|
|
237
|
+
try {
|
|
238
|
+
const raw = run(
|
|
239
|
+
`gh api graphql -f query=${userQuery} -f login=${JSON.stringify(owner)} -F number=${projectNumber} --jq '.data.user.projectV2.id'`
|
|
240
|
+
);
|
|
241
|
+
if (raw && raw !== "null") return raw;
|
|
242
|
+
} catch (_) {
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const raw = run(
|
|
246
|
+
`gh api graphql -f query=${orgQuery} -f login=${JSON.stringify(owner)} -F number=${projectNumber} --jq '.data.organization.projectV2.id'`
|
|
247
|
+
);
|
|
248
|
+
if (raw && raw !== "null") return raw;
|
|
249
|
+
} catch (_) {
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
function findExistingBoard(owner, titlePattern) {
|
|
254
|
+
const pattern = titlePattern.toLowerCase();
|
|
255
|
+
try {
|
|
256
|
+
const raw = run(
|
|
257
|
+
`gh api graphql -f query='query($login: String!) { user(login: $login) { projectsV2(first: 20) { nodes { id number url title } } } }' -f login=${JSON.stringify(owner)} --jq '.data.user.projectsV2.nodes'`
|
|
258
|
+
);
|
|
259
|
+
const nodes = JSON.parse(raw);
|
|
260
|
+
const match = nodes.find((n) => n.title.toLowerCase().includes(pattern));
|
|
261
|
+
if (match) return { number: match.number, url: match.url, nodeId: match.id, title: match.title };
|
|
262
|
+
} catch (_) {
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const raw = run(
|
|
266
|
+
`gh api graphql -f query='query($login: String!) { organization(login: $login) { projectsV2(first: 20) { nodes { id number url title } } } }' -f login=${JSON.stringify(owner)} --jq '.data.organization.projectsV2.nodes'`
|
|
267
|
+
);
|
|
268
|
+
const nodes = JSON.parse(raw);
|
|
269
|
+
const match = nodes.find((n) => n.title.toLowerCase().includes(pattern));
|
|
270
|
+
if (match) return { number: match.number, url: match.url, nodeId: match.id, title: match.title };
|
|
271
|
+
} catch (_) {
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
function getProjectFields(owner, projectNumber) {
|
|
276
|
+
const query = `'query($login: String!, $number: Int!) { user(login: $login) { projectV2(number: $number) { fields(first: 20) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } ... on ProjectV2Field { id name dataType } } } } } }'`;
|
|
277
|
+
const orgQuery = `'query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { fields(first: 20) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } ... on ProjectV2Field { id name dataType } } } } } }'`;
|
|
278
|
+
let raw;
|
|
279
|
+
try {
|
|
280
|
+
raw = run(`gh api graphql -f query=${query} -f login=${JSON.stringify(owner)} -F number=${projectNumber} --jq '.data.user.projectV2.fields.nodes'`);
|
|
281
|
+
} catch (_) {
|
|
282
|
+
try {
|
|
283
|
+
raw = run(`gh api graphql -f query=${orgQuery} -f login=${JSON.stringify(owner)} -F number=${projectNumber} --jq '.data.organization.projectV2.fields.nodes'`);
|
|
284
|
+
} catch (_2) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const nodes = JSON.parse(raw);
|
|
289
|
+
const fields = {};
|
|
290
|
+
const statusNode = nodes.find((n) => n.name === "Status" && n.options);
|
|
291
|
+
if (statusNode) {
|
|
292
|
+
const stageMap = {
|
|
293
|
+
"new": "New",
|
|
294
|
+
"triaged": "Triaged",
|
|
295
|
+
"needs-info": "Needs Info",
|
|
296
|
+
"needs-security-review": "Needs Security Review",
|
|
297
|
+
"discussing": "Discussing",
|
|
298
|
+
"approved": "Approved",
|
|
299
|
+
"planning": "Planning",
|
|
300
|
+
"executing": "Executing",
|
|
301
|
+
"verifying": "Verifying",
|
|
302
|
+
"pr-created": "PR Created",
|
|
303
|
+
"done": "Done",
|
|
304
|
+
"failed": "Failed",
|
|
305
|
+
"blocked": "Blocked"
|
|
306
|
+
};
|
|
307
|
+
const nameToId = Object.fromEntries(statusNode.options.map((o) => [o.name, o.id]));
|
|
308
|
+
fields.status = {
|
|
309
|
+
field_id: statusNode.id,
|
|
310
|
+
field_name: "Status",
|
|
311
|
+
type: "SINGLE_SELECT",
|
|
312
|
+
options: Object.fromEntries(
|
|
313
|
+
Object.entries(stageMap).map(([stage, label]) => [stage, nameToId[label] || ""])
|
|
314
|
+
)
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const aiNode = nodes.find((n) => n.name === "AI Agent State" && !n.options);
|
|
318
|
+
if (aiNode) {
|
|
319
|
+
fields.ai_agent_state = { field_id: aiNode.id, field_name: "AI Agent State", type: "TEXT" };
|
|
320
|
+
}
|
|
321
|
+
const phaseNode = nodes.find((n) => n.name === "Phase" && !n.options);
|
|
322
|
+
if (phaseNode) {
|
|
323
|
+
fields.phase = { field_id: phaseNode.id, field_name: "Phase", type: "TEXT" };
|
|
324
|
+
}
|
|
325
|
+
const routeNode = nodes.find((n) => n.name === "GSD Route" && n.options);
|
|
326
|
+
if (routeNode) {
|
|
327
|
+
const routeMap = {
|
|
328
|
+
"gsd:quick": "quick",
|
|
329
|
+
"gsd:quick --full": "quick --full",
|
|
330
|
+
"gsd:plan-phase": "plan-phase",
|
|
331
|
+
"gsd:new-milestone": "new-milestone"
|
|
332
|
+
};
|
|
333
|
+
const nameToId = Object.fromEntries(routeNode.options.map((o) => [o.name, o.id]));
|
|
334
|
+
fields.gsd_route = {
|
|
335
|
+
field_id: routeNode.id,
|
|
336
|
+
field_name: "GSD Route",
|
|
337
|
+
type: "SINGLE_SELECT",
|
|
338
|
+
options: Object.fromEntries(
|
|
339
|
+
Object.entries(routeMap).map(([route, label]) => [route, nameToId[label] || ""])
|
|
340
|
+
)
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
const milestoneNode = nodes.find((n) => n.name === "Milestone");
|
|
344
|
+
if (milestoneNode) {
|
|
345
|
+
fields.milestone = {
|
|
346
|
+
field_id: milestoneNode.id,
|
|
347
|
+
field_name: "Milestone",
|
|
348
|
+
type: milestoneNode.dataType || (milestoneNode.options ? "SINGLE_SELECT" : "TEXT")
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return Object.keys(fields).length > 0 ? fields : null;
|
|
352
|
+
}
|
|
200
353
|
function createProject(owner, title) {
|
|
201
354
|
const raw = run(
|
|
202
355
|
`gh project create --owner ${JSON.stringify(owner)} --title ${JSON.stringify(title)} --format json`
|
|
@@ -287,6 +440,9 @@ function requireGithub () {
|
|
|
287
440
|
getRateLimit,
|
|
288
441
|
closeMilestone,
|
|
289
442
|
createRelease,
|
|
443
|
+
getProjectNodeId,
|
|
444
|
+
findExistingBoard,
|
|
445
|
+
getProjectFields,
|
|
290
446
|
createProject,
|
|
291
447
|
addItemToProject,
|
|
292
448
|
postMilestoneStartAnnouncement
|