@snappedly-tools/shipyard 0.5.0 → 0.6.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.
Files changed (58) hide show
  1. package/README.md +56 -8
  2. package/dist/index.js +27 -9
  3. package/dist/index.js.map +1 -1
  4. package/dist/main.js +51 -15
  5. package/dist/main.js.map +1 -1
  6. package/dist/templates/parallel-planner/block-scope.sh +160 -0
  7. package/dist/templates/parallel-planner/conflict-prompt.md +22 -0
  8. package/dist/templates/parallel-planner/handoff.sh +175 -0
  9. package/dist/templates/parallel-planner/implement-prompt.md +10 -56
  10. package/dist/templates/parallel-planner/main.mts +434 -188
  11. package/dist/templates/parallel-planner/merge-prompt.md +9 -21
  12. package/dist/templates/parallel-planner/plan-prompt.md +5 -31
  13. package/dist/templates/parallel-planner/select-issues.mjs +788 -0
  14. package/dist/templates/parallel-planner/setup.sh +51 -0
  15. package/dist/templates/parallel-planner/spec-wave-prompt.md +16 -0
  16. package/dist/templates/parallel-planner/template.json +1 -1
  17. package/dist/templates/parallel-planner/triage-prompt.md +5 -0
  18. package/dist/templates/parallel-planner/verify-triage.sh +19 -0
  19. package/dist/templates/parallel-planner-with-review/block-scope.sh +160 -0
  20. package/dist/templates/parallel-planner-with-review/conflict-prompt.md +22 -0
  21. package/dist/templates/parallel-planner-with-review/handoff.sh +175 -0
  22. package/dist/templates/parallel-planner-with-review/implement-prompt.md +10 -56
  23. package/dist/templates/parallel-planner-with-review/main.mts +473 -205
  24. package/dist/templates/parallel-planner-with-review/merge-prompt.md +9 -21
  25. package/dist/templates/parallel-planner-with-review/plan-prompt.md +5 -31
  26. package/dist/templates/parallel-planner-with-review/review-prompt.md +11 -49
  27. package/dist/templates/parallel-planner-with-review/select-issues.mjs +788 -0
  28. package/dist/templates/parallel-planner-with-review/setup.sh +51 -0
  29. package/dist/templates/parallel-planner-with-review/spec-wave-prompt.md +16 -0
  30. package/dist/templates/parallel-planner-with-review/template.json +1 -1
  31. package/dist/templates/parallel-planner-with-review/triage-prompt.md +5 -0
  32. package/dist/templates/parallel-planner-with-review/verify-triage.sh +19 -0
  33. package/dist/templates/sequential-reviewer/block-scope.sh +160 -0
  34. package/dist/templates/sequential-reviewer/handoff.sh +175 -0
  35. package/dist/templates/sequential-reviewer/implement-prompt.md +12 -46
  36. package/dist/templates/sequential-reviewer/main.mts +199 -103
  37. package/dist/templates/sequential-reviewer/review-prompt.md +11 -49
  38. package/dist/templates/sequential-reviewer/select-issues.mjs +788 -0
  39. package/dist/templates/sequential-reviewer/setup.sh +51 -0
  40. package/dist/templates/sequential-reviewer/template.json +1 -1
  41. package/dist/templates/sequential-reviewer/triage-prompt.md +5 -0
  42. package/dist/templates/sequential-reviewer/verify-triage.sh +19 -0
  43. package/dist/templates/shared/block-scope.sh +160 -0
  44. package/dist/templates/shared/handoff.sh +175 -0
  45. package/dist/templates/shared/select-issues.mjs +788 -0
  46. package/dist/templates/shared/setup.sh +51 -0
  47. package/dist/templates/shared/triage-prompt.md +5 -0
  48. package/dist/templates/shared/verify-triage.sh +19 -0
  49. package/dist/templates/simple-loop/block-scope.sh +160 -0
  50. package/dist/templates/simple-loop/handoff.sh +175 -0
  51. package/dist/templates/simple-loop/main.mts +190 -45
  52. package/dist/templates/simple-loop/prompt.md +12 -46
  53. package/dist/templates/simple-loop/select-issues.mjs +788 -0
  54. package/dist/templates/simple-loop/setup.sh +51 -0
  55. package/dist/templates/simple-loop/template.json +1 -1
  56. package/dist/templates/simple-loop/triage-prompt.md +5 -0
  57. package/dist/templates/simple-loop/verify-triage.sh +19 -0
  58. package/package.json +1 -1
@@ -0,0 +1,788 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const gh = (...args) =>
7
+ execFileSync("gh", args, {
8
+ encoding: "utf8",
9
+ stdio: ["ignore", "pipe", "pipe"],
10
+ }).trim();
11
+ const json = (...args) => JSON.parse(gh(...args));
12
+ const repository =
13
+ process.env.GH_REPO ||
14
+ gh("repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner");
15
+ const pendingDir = resolve(
16
+ execFileSync("git", ["rev-parse", "--git-path", "shipyard-pending"], {
17
+ encoding: "utf8",
18
+ }).trim(),
19
+ );
20
+ if (existsSync(pendingDir)) {
21
+ for (const filename of readdirSync(pendingDir)
22
+ .filter((name) => name.endsWith(".pending"))
23
+ .sort()) {
24
+ const contents = readFileSync(resolve(pendingDir, filename), "utf8");
25
+ const separator = contents.indexOf("\n");
26
+ if (separator < 0)
27
+ throw new Error(`Invalid pending block record: ${filename}`);
28
+ const [root, failed, recordedRepo, scope, branch, confirmed = ""] = contents
29
+ .slice(0, separator)
30
+ .split("\t");
31
+ if (
32
+ recordedRepo !== repository ||
33
+ !/^\d+-\d+\.pending$/.test(filename) ||
34
+ filename !== `${root}-${failed}.pending` ||
35
+ (confirmed !== "" && !/^\d+(,\d+)*$/.test(confirmed)) ||
36
+ confirmed
37
+ .split(",")
38
+ .filter(Boolean)
39
+ .some((id) => !scope.split(",").includes(id))
40
+ )
41
+ throw new Error(`Invalid pending block record: ${filename}`);
42
+ const reactivated = confirmed
43
+ .split(",")
44
+ .filter(Boolean)
45
+ .some((id) => {
46
+ const labels = execFileSync(
47
+ "gh",
48
+ [
49
+ "issue",
50
+ "view",
51
+ id,
52
+ "--repo",
53
+ repository,
54
+ "--json",
55
+ "labels",
56
+ "--jq",
57
+ ".labels[].name",
58
+ ],
59
+ { encoding: "utf8" },
60
+ ).split(/\r?\n/);
61
+ return (
62
+ labels.includes("shipyard") && !labels.includes("shipyard:blocked")
63
+ );
64
+ });
65
+ if (reactivated) {
66
+ // A fresh activation in GitHub supersedes this local failure report.
67
+ unlinkSync(resolve(pendingDir, filename));
68
+ continue;
69
+ }
70
+ execFileSync(
71
+ "bash",
72
+ [
73
+ fileURLToPath(new URL("./block-scope.sh", import.meta.url)),
74
+ root,
75
+ failed,
76
+ recordedRepo,
77
+ scope,
78
+ branch,
79
+ ],
80
+ { input: contents.slice(separator + 1), encoding: "utf8" },
81
+ );
82
+ }
83
+ }
84
+ const endpoint = (id, suffix) => `repos/${repository}/issues/${id}/${suffix}`;
85
+ const number = (value) => {
86
+ const id = String(value);
87
+ if (!/^\d+$/.test(id)) throw new Error(`Invalid GitHub issue number: ${id}`);
88
+ return id;
89
+ };
90
+ class SelectionError extends Error {
91
+ constructor(message, issueId) {
92
+ super(message);
93
+ this.issueId = issueId;
94
+ }
95
+ }
96
+ const invalid = (message, issueId) => {
97
+ throw new SelectionError(message, issueId);
98
+ };
99
+ const notFound = (error) => /HTTP 404/.test(String(error.stderr));
100
+ const api = (id, suffix) => json("api", `${endpoint(id, suffix)}?per_page=100`);
101
+ const parentLink = (id) => {
102
+ try {
103
+ return number(json("api", endpoint(id, "parent")).number);
104
+ } catch (error) {
105
+ if (notFound(error)) return undefined;
106
+ throw error;
107
+ }
108
+ };
109
+ const children = (id) => {
110
+ const result = api(id, "sub_issues");
111
+ if (!Array.isArray(result) || result.length === 100)
112
+ invalid(`Could not resolve complete child scope for #${id}`, id);
113
+ return result;
114
+ };
115
+ const dependencies = (id) => {
116
+ const result = api(id, "dependencies/blocked_by");
117
+ if (!Array.isArray(result) || result.length === 100)
118
+ invalid(`Could not resolve complete dependencies for #${id}`, id);
119
+ return result.map((item) => ({
120
+ id: number(item.number),
121
+ title: item.title,
122
+ state: item.state,
123
+ }));
124
+ };
125
+ const planningSpec = (body) =>
126
+ /work item type:\*?\*?\s*planning spec/i.test(body ?? "");
127
+ const textParent = (body, issueId) => {
128
+ const match = /^##?\s*Parent\b[^\n]*\n/im.exec(body ?? "");
129
+ if (!match) return undefined;
130
+ const tail = body.slice(match.index + match[0].length);
131
+ const next = /^##?\s/m.exec(tail);
132
+ const section = next ? tail.slice(0, next.index) : tail;
133
+ const refs = [...section.matchAll(/#(\d+)/g)].map((entry) => entry[1]);
134
+ if (new Set(refs).size !== 1 || refs.length === 0)
135
+ invalid("Ambiguous or missing ## Parent issue reference", issueId);
136
+ return refs[0];
137
+ };
138
+ let issueCatalog;
139
+ const allIssues = () => {
140
+ if (!issueCatalog) {
141
+ issueCatalog = json(
142
+ "issue",
143
+ "list",
144
+ "--state",
145
+ "all",
146
+ "--limit",
147
+ "1000",
148
+ "--json",
149
+ "number,title,body,state,labels",
150
+ );
151
+ if (!Array.isArray(issueCatalog) || issueCatalog.length === 1000)
152
+ invalid("Could not resolve complete issue relationship catalog");
153
+ }
154
+ return issueCatalog;
155
+ };
156
+ let specPRCatalog;
157
+ const specPRs = () => {
158
+ if (!specPRCatalog) {
159
+ specPRCatalog = json(
160
+ "pr",
161
+ "list",
162
+ "--repo",
163
+ repository,
164
+ "--state",
165
+ "open",
166
+ "--limit",
167
+ "1000",
168
+ "--json",
169
+ "number,headRefName,body",
170
+ );
171
+ if (!Array.isArray(specPRCatalog) || specPRCatalog.length === 1000)
172
+ invalid("Could not resolve complete open PR catalog");
173
+ }
174
+ return specPRCatalog.filter(
175
+ (pr) =>
176
+ /^shipyard\/spec-\d+$/.test(pr.headRefName ?? "") &&
177
+ pr.body?.includes("<!-- shipyard:verified-handoff -->"),
178
+ );
179
+ };
180
+ const sourceIds = (body) =>
181
+ body
182
+ ?.match(/^Source issues:[ \t]*((?:#[0-9]+[ \t]*)+)$/m)?.[1]
183
+ ?.match(/#[0-9]+/g)
184
+ ?.map((issue) => issue.slice(1)) ?? [];
185
+ const prParentLink = (id) => {
186
+ const roots = specPRs()
187
+ .filter((pr) => sourceIds(pr.body).includes(id))
188
+ .map((pr) => number(pr.headRefName.slice("shipyard/spec-".length)))
189
+ .filter((root) => root !== id);
190
+ if (new Set(roots).size > 1)
191
+ invalid(`Ticket #${id} is linked to multiple Shipyard spec PRs`, id);
192
+ return roots[0];
193
+ };
194
+ const prChildren = (rootId) => {
195
+ const ids = new Set(
196
+ specPRs()
197
+ .filter((pr) => pr.headRefName === `shipyard/spec-${rootId}`)
198
+ .flatMap((pr) => sourceIds(pr.body))
199
+ .filter((id) => id !== rootId),
200
+ );
201
+ if (!ids.size) return [];
202
+ const tickets = allIssues().filter((item) => ids.has(number(item.number)));
203
+ if (tickets.length !== ids.size)
204
+ invalid(`Spec PR for #${rootId} references an unknown ticket`, rootId);
205
+ return tickets;
206
+ };
207
+ const fullIssue = (id) =>
208
+ json(
209
+ "issue",
210
+ "view",
211
+ id,
212
+ "--repo",
213
+ repository,
214
+ "--json",
215
+ "number,title,body,state,labels",
216
+ );
217
+ const statusLabels = [
218
+ "shipyard:blocked",
219
+ "shipyard:complete",
220
+ "shipyard:outstanding-tasks",
221
+ ];
222
+ const statusFor = (tickets) =>
223
+ tickets.some(
224
+ (ticket) =>
225
+ ticket.labels.some((label) => label.name === "shipyard:blocked") &&
226
+ !ticket.labels.some((label) => label.name === "shipyard:complete"),
227
+ )
228
+ ? "shipyard:blocked"
229
+ : tickets.every((ticket) =>
230
+ ticket.labels.some((label) => label.name === "shipyard:complete"),
231
+ )
232
+ ? "shipyard:complete"
233
+ : "shipyard:outstanding-tasks";
234
+ const syncStatus = (root, tickets, pr) => {
235
+ const status = statusFor(tickets);
236
+ let synced = true;
237
+ const cosmetic = (...args) => {
238
+ try {
239
+ gh(...args);
240
+ } catch (error) {
241
+ synced = false;
242
+ console.error(`Could not synchronize ${status}: ${String(error)}`);
243
+ }
244
+ };
245
+ const currentLabels = (kind, id) => {
246
+ try {
247
+ return json(
248
+ kind,
249
+ "view",
250
+ id,
251
+ "--repo",
252
+ repository,
253
+ "--json",
254
+ "labels",
255
+ ).labels.map((label) => label.name);
256
+ } catch (error) {
257
+ synced = false;
258
+ console.error(
259
+ `Could not inspect ${kind} #${id} labels: ${String(error)}`,
260
+ );
261
+ return [];
262
+ }
263
+ };
264
+ cosmetic(
265
+ "label",
266
+ "create",
267
+ status,
268
+ "--repo",
269
+ repository,
270
+ "--color",
271
+ status === "shipyard:blocked"
272
+ ? "B60205"
273
+ : status === "shipyard:complete"
274
+ ? "0E8A16"
275
+ : "FBCA04",
276
+ "--description",
277
+ status === "shipyard:blocked"
278
+ ? "Shipyard work needs intervention"
279
+ : status === "shipyard:complete"
280
+ ? "Shipyard work ready for human review"
281
+ : "Spec has uncompleted tickets",
282
+ "--force",
283
+ );
284
+ cosmetic(
285
+ "issue",
286
+ "edit",
287
+ number(root.number),
288
+ "--repo",
289
+ repository,
290
+ "--add-label",
291
+ status,
292
+ );
293
+ if (pr)
294
+ cosmetic(
295
+ "pr",
296
+ "edit",
297
+ String(pr.number),
298
+ "--repo",
299
+ repository,
300
+ "--add-label",
301
+ status,
302
+ );
303
+ const rootLabels = currentLabels("issue", number(root.number));
304
+ const prLabels = pr ? currentLabels("pr", String(pr.number)) : [];
305
+ for (const stale of statusLabels.filter((label) => label !== status)) {
306
+ if (rootLabels.includes(stale))
307
+ cosmetic(
308
+ "issue",
309
+ "edit",
310
+ number(root.number),
311
+ "--repo",
312
+ repository,
313
+ "--remove-label",
314
+ stale,
315
+ );
316
+ if (pr && prLabels.includes(stale))
317
+ cosmetic(
318
+ "pr",
319
+ "edit",
320
+ String(pr.number),
321
+ "--repo",
322
+ repository,
323
+ "--remove-label",
324
+ stale,
325
+ );
326
+ }
327
+ return synced;
328
+ };
329
+ const activated = json(
330
+ "issue",
331
+ "list",
332
+ "--state",
333
+ "open",
334
+ "--label",
335
+ "shipyard",
336
+ "--limit",
337
+ "100",
338
+ "--json",
339
+ "number,title,body,labels",
340
+ );
341
+ if (activated.length === 100)
342
+ throw new Error("Could not resolve complete activated issue set");
343
+ const scopes = new Map();
344
+ const processedBranches = new Set();
345
+ const blockedIds = new Set();
346
+ const activeIds = new Set(activated.map((item) => number(item.number)));
347
+ for (const candidate of activated) {
348
+ const id = number(candidate.number);
349
+ if (blockedIds.has(id)) continue;
350
+ let resolvedRootId;
351
+ let resolvedSpec = false;
352
+ let selectedTicketIds = new Set();
353
+ try {
354
+ const nativeParent = parentLink(id);
355
+ const bodyParent = textParent(candidate.body, id);
356
+ const prParent = prParentLink(id);
357
+ if (new Set([nativeParent, bodyParent, prParent].filter(Boolean)).size > 1)
358
+ invalid(`Conflicting parent links for #${id}`, id);
359
+ const parentId = nativeParent ?? bodyParent ?? prParent;
360
+ const root = parentId ? fullIssue(parentId) : candidate;
361
+ const rootId = number(root.number);
362
+ resolvedRootId = rootId;
363
+ const nativeChildren = children(rootId);
364
+ const isSpec =
365
+ planningSpec(root.body) || nativeChildren.length > 0 || !!parentId;
366
+ resolvedSpec = isSpec;
367
+ const linked = isSpec
368
+ ? [
369
+ ...new Map(
370
+ [
371
+ ...nativeChildren,
372
+ ...allIssues().filter(
373
+ (item) => textParent(item.body, number(item.number)) === rootId,
374
+ ),
375
+ ...prChildren(rootId),
376
+ ].map((item) => [number(item.number), item]),
377
+ ).values(),
378
+ ]
379
+ : [];
380
+ if (isSpec && !planningSpec(root.body))
381
+ invalid(`Parent #${rootId} is not a planning spec`, id);
382
+ if (isSpec && linked.length === 0)
383
+ invalid(`Planning spec #${rootId} has no linked executable tickets`, id);
384
+ const branch = isSpec ? `shipyard/spec-${rootId}` : `shipyard/issue-${id}`;
385
+ if (processedBranches.has(branch)) continue;
386
+ processedBranches.add(branch);
387
+ if (
388
+ !isSpec &&
389
+ candidate.labels?.some((label) => label.name === "shipyard:complete")
390
+ ) {
391
+ const existing = json(
392
+ "pr",
393
+ "list",
394
+ "--repo",
395
+ repository,
396
+ "--head",
397
+ branch,
398
+ "--state",
399
+ "open",
400
+ "--json",
401
+ "number,isDraft,labels,body",
402
+ );
403
+ const synced = syncStatus(candidate, [candidate], existing[0]);
404
+ if (candidate.labels.some((label) => label.name === "shipyard:pending"))
405
+ gh(
406
+ "issue",
407
+ "edit",
408
+ id,
409
+ "--repo",
410
+ repository,
411
+ "--remove-label",
412
+ "shipyard:pending",
413
+ );
414
+ if (synced)
415
+ gh(
416
+ "issue",
417
+ "edit",
418
+ id,
419
+ "--repo",
420
+ repository,
421
+ "--remove-label",
422
+ "shipyard",
423
+ );
424
+ continue;
425
+ }
426
+ if (
427
+ !isSpec &&
428
+ candidate.labels?.some((label) => label.name === "shipyard:blocked")
429
+ ) {
430
+ gh(
431
+ "issue",
432
+ "edit",
433
+ id,
434
+ "--repo",
435
+ repository,
436
+ "--remove-label",
437
+ "shipyard",
438
+ );
439
+ continue;
440
+ }
441
+ const linkedTickets = isSpec
442
+ ? linked.map((child) => ({
443
+ id: number(child.number),
444
+ title: child.title,
445
+ body: child.body ?? "",
446
+ state: child.state,
447
+ labels: child.labels ?? [],
448
+ }))
449
+ : [];
450
+ const blockedActiveIds = [];
451
+ for (const ticket of linkedTickets) {
452
+ if (
453
+ ticket.labels.some((label) => label.name === "shipyard:blocked") &&
454
+ ticket.labels.some((label) => label.name === "shipyard")
455
+ )
456
+ blockedActiveIds.push(ticket.id);
457
+ if (
458
+ ticket.labels.some((label) => label.name === "shipyard:complete") &&
459
+ ticket.labels.some((label) => label.name === "shipyard:pending")
460
+ )
461
+ gh(
462
+ "issue",
463
+ "edit",
464
+ ticket.id,
465
+ "--repo",
466
+ repository,
467
+ "--remove-label",
468
+ "shipyard:pending",
469
+ );
470
+ }
471
+ const selectableTickets = linkedTickets.filter(
472
+ (ticket) =>
473
+ ticket.labels.some((label) => label.name === "shipyard") &&
474
+ !ticket.labels.some((label) =>
475
+ ["shipyard:complete", "shipyard:blocked"].includes(label.name),
476
+ ),
477
+ );
478
+ selectedTicketIds = new Set(selectableTickets.map((ticket) => ticket.id));
479
+ const tickets = selectableTickets.map(({ labels: _labels, ...ticket }) => ({
480
+ ...ticket,
481
+ blockedBy: dependencies(ticket.id),
482
+ }));
483
+ const completedTicketIds = linkedTickets
484
+ .filter((ticket) =>
485
+ ticket.labels.some((label) => label.name === "shipyard:complete"),
486
+ )
487
+ .map((ticket) => ticket.id);
488
+ const outstandingTicketIds = linkedTickets
489
+ .filter(
490
+ (ticket) =>
491
+ !selectedTicketIds.has(ticket.id) &&
492
+ !completedTicketIds.includes(ticket.id),
493
+ )
494
+ .map((ticket) => ticket.id);
495
+ if (isSpec) {
496
+ if (parentId && !linkedTickets.some((ticket) => ticket.id === id))
497
+ invalid(
498
+ `Activated child #${id} is absent from parent #${rootId}'s linked scope`,
499
+ id,
500
+ );
501
+ for (const ticket of tickets) {
502
+ if (!/work item type:\*?\*?\s*executable/i.test(ticket.body))
503
+ invalid(
504
+ `Linked child #${ticket.id} is not an executable ticket`,
505
+ ticket.id,
506
+ );
507
+ const native = parentLink(ticket.id);
508
+ const textual = textParent(ticket.body, ticket.id);
509
+ const linkedPR = prParentLink(ticket.id);
510
+ if (
511
+ (native && native !== rootId) ||
512
+ (textual && textual !== rootId) ||
513
+ (linkedPR && linkedPR !== rootId)
514
+ )
515
+ invalid(`Conflicting parent links for #${ticket.id}`, ticket.id);
516
+ }
517
+ }
518
+ const existing = json(
519
+ "pr",
520
+ "list",
521
+ "--repo",
522
+ repository,
523
+ "--head",
524
+ branch,
525
+ "--state",
526
+ "open",
527
+ "--json",
528
+ "number,isDraft,labels,body",
529
+ );
530
+ const ready = existing.find(
531
+ (pr) =>
532
+ !pr.isDraft && pr.body?.includes("<!-- shipyard:verified-handoff -->"),
533
+ );
534
+ const statusSynced = isSpec
535
+ ? syncStatus(root, linkedTickets, existing[0])
536
+ : true;
537
+ if (statusSynced) {
538
+ for (const blockedId of blockedActiveIds)
539
+ gh(
540
+ "issue",
541
+ "edit",
542
+ blockedId,
543
+ "--repo",
544
+ repository,
545
+ "--remove-label",
546
+ "shipyard",
547
+ );
548
+ }
549
+ if (ready) {
550
+ const recordedIds = ready.body
551
+ ?.match(/^Source issues:[ \t]*((?:#[0-9]+[ \t]*)+)$/m)?.[1]
552
+ ?.match(/#[0-9]+/g)
553
+ ?.map((issue) => issue.slice(1));
554
+ const implementedIds =
555
+ ready.body
556
+ ?.match(/^Implemented tickets:[ \t]*((?:#[0-9]+[ \t]*)*)$/m)?.[1]
557
+ ?.match(/#[0-9]+/g)
558
+ ?.map((issue) => issue.slice(1)) ??
559
+ recordedIds?.slice(1) ??
560
+ [];
561
+ const recordedSet = new Set(recordedIds ?? []);
562
+ const implementedSet = new Set(implementedIds);
563
+ const knownIds = new Set([
564
+ rootId,
565
+ ...linkedTickets.map((ticket) => ticket.id),
566
+ ]);
567
+ if (
568
+ !recordedIds ||
569
+ !recordedSet.has(rootId) ||
570
+ recordedIds.length !== recordedSet.size ||
571
+ recordedIds.some((issue) => !knownIds.has(issue))
572
+ )
573
+ invalid(
574
+ `PR #${ready.number} issue scope differs from current #${rootId} scope`,
575
+ id,
576
+ );
577
+ if (tickets.every((ticket) => implementedSet.has(ticket.id))) {
578
+ for (const ticket of tickets) {
579
+ gh(
580
+ "issue",
581
+ "edit",
582
+ ticket.id,
583
+ "--repo",
584
+ repository,
585
+ "--add-label",
586
+ "shipyard:complete",
587
+ );
588
+ if (
589
+ linkedTickets
590
+ .find((item) => item.id === ticket.id)
591
+ ?.labels.some((label) => label.name === "shipyard:blocked")
592
+ )
593
+ gh(
594
+ "issue",
595
+ "edit",
596
+ ticket.id,
597
+ "--repo",
598
+ repository,
599
+ "--remove-label",
600
+ "shipyard:blocked",
601
+ );
602
+ if (
603
+ linkedTickets
604
+ .find((item) => item.id === ticket.id)
605
+ ?.labels.some((label) => label.name === "shipyard:pending")
606
+ )
607
+ gh(
608
+ "issue",
609
+ "edit",
610
+ ticket.id,
611
+ "--repo",
612
+ repository,
613
+ "--remove-label",
614
+ "shipyard:pending",
615
+ );
616
+ }
617
+ for (const ticket of linkedTickets) {
618
+ if (
619
+ ticket.labels.some((label) => label.name === "shipyard:complete") &&
620
+ ticket.labels.some((label) => label.name === "shipyard:blocked")
621
+ )
622
+ gh(
623
+ "issue",
624
+ "edit",
625
+ ticket.id,
626
+ "--repo",
627
+ repository,
628
+ "--remove-label",
629
+ "shipyard:blocked",
630
+ );
631
+ }
632
+ let completedStatusSynced = statusSynced;
633
+ if (isSpec) {
634
+ completedStatusSynced = syncStatus(
635
+ root,
636
+ linkedTickets.map((ticket) =>
637
+ selectedTicketIds.has(ticket.id)
638
+ ? { ...ticket, labels: [{ name: "shipyard:complete" }] }
639
+ : ticket,
640
+ ),
641
+ ready,
642
+ );
643
+ } else {
644
+ gh(
645
+ "issue",
646
+ "edit",
647
+ rootId,
648
+ "--repo",
649
+ repository,
650
+ "--add-label",
651
+ "shipyard:complete",
652
+ );
653
+ if (root.labels?.some((label) => label.name === "shipyard:pending"))
654
+ gh(
655
+ "issue",
656
+ "edit",
657
+ rootId,
658
+ "--repo",
659
+ repository,
660
+ "--remove-label",
661
+ "shipyard:pending",
662
+ );
663
+ gh(
664
+ "pr",
665
+ "edit",
666
+ String(ready.number),
667
+ "--repo",
668
+ repository,
669
+ "--add-label",
670
+ "shipyard:complete",
671
+ );
672
+ }
673
+ const activatedIds = [
674
+ rootId,
675
+ ...tickets.map((ticket) => ticket.id),
676
+ ...completedTicketIds,
677
+ ].filter((item) => activeIds.has(item));
678
+ if (completedStatusSynced)
679
+ for (const activeId of activatedIds)
680
+ gh(
681
+ "issue",
682
+ "edit",
683
+ activeId,
684
+ "--repo",
685
+ repository,
686
+ "--remove-label",
687
+ "shipyard",
688
+ );
689
+ continue;
690
+ }
691
+ }
692
+ if (isSpec) {
693
+ if (root.state && String(root.state).toLowerCase() !== "open")
694
+ invalid(`Planning spec #${rootId} is closed`, id);
695
+ const completedIds = new Set(completedTicketIds);
696
+ for (const ticket of tickets) {
697
+ if (String(ticket.state).toLowerCase() !== "open")
698
+ invalid(
699
+ `Linked executable ticket #${ticket.id} is closed`,
700
+ ticket.id,
701
+ );
702
+ for (const blocker of ticket.blockedBy) {
703
+ if (
704
+ String(blocker.state).toLowerCase() !== "closed" &&
705
+ !selectedTicketIds.has(blocker.id) &&
706
+ !completedIds.has(blocker.id)
707
+ )
708
+ invalid(
709
+ `Ticket #${ticket.id} has unresolved external dependency #${blocker.id}`,
710
+ ticket.id,
711
+ );
712
+ }
713
+ }
714
+ const waiting = new Set(selectedTicketIds);
715
+ while (waiting.size) {
716
+ const ready = tickets.filter(
717
+ (ticket) =>
718
+ waiting.has(ticket.id) &&
719
+ ticket.blockedBy.every(
720
+ (blocker) =>
721
+ String(blocker.state).toLowerCase() === "closed" ||
722
+ !waiting.has(blocker.id),
723
+ ),
724
+ );
725
+ if (!ready.length)
726
+ invalid(`Spec #${rootId} has cyclic dependencies`, id);
727
+ for (const ticket of ready) waiting.delete(ticket.id);
728
+ }
729
+ }
730
+ if (isSpec && tickets.length === 0) {
731
+ if (statusSynced)
732
+ for (const activeId of [rootId, ...completedTicketIds].filter((item) =>
733
+ activeIds.has(item),
734
+ ))
735
+ gh(
736
+ "issue",
737
+ "edit",
738
+ activeId,
739
+ "--repo",
740
+ repository,
741
+ "--remove-label",
742
+ "shipyard",
743
+ );
744
+ continue;
745
+ }
746
+ scopes.set(
747
+ branch,
748
+ isSpec
749
+ ? {
750
+ id: rootId,
751
+ title: root.title,
752
+ body: root.body ?? "",
753
+ branch,
754
+ kind: "spec",
755
+ tickets,
756
+ completedTicketIds,
757
+ outstandingTicketIds,
758
+ }
759
+ : { id, title: candidate.title, branch, kind: "standalone" },
760
+ );
761
+ } catch (error) {
762
+ if (!(error instanceof SelectionError)) throw error;
763
+ const affectedId =
764
+ error.issueId &&
765
+ (activeIds.has(error.issueId) || selectedTicketIds.has(error.issueId))
766
+ ? error.issueId
767
+ : id;
768
+ const specChild = resolvedSpec && resolvedRootId !== affectedId;
769
+ const blockRoot = specChild ? resolvedRootId : affectedId;
770
+ execFileSync(
771
+ "bash",
772
+ [
773
+ fileURLToPath(new URL("./block-scope.sh", import.meta.url)),
774
+ blockRoot,
775
+ affectedId,
776
+ repository,
777
+ specChild ? `${blockRoot},${affectedId}` : affectedId,
778
+ specChild
779
+ ? `shipyard/spec-${blockRoot}`
780
+ : `shipyard/issue-${affectedId}`,
781
+ ],
782
+ { input: error.message, encoding: "utf8" },
783
+ );
784
+ blockedIds.add(affectedId);
785
+ console.error(`Shipyard blocked issue #${affectedId}: ${error.message}`);
786
+ }
787
+ }
788
+ process.stdout.write(`${JSON.stringify([...scopes.values()])}\n`);