@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.
- package/README.md +56 -8
- package/dist/index.js +27 -9
- package/dist/index.js.map +1 -1
- package/dist/main.js +51 -15
- package/dist/main.js.map +1 -1
- package/dist/templates/parallel-planner/block-scope.sh +160 -0
- package/dist/templates/parallel-planner/conflict-prompt.md +22 -0
- package/dist/templates/parallel-planner/handoff.sh +175 -0
- package/dist/templates/parallel-planner/implement-prompt.md +10 -56
- package/dist/templates/parallel-planner/main.mts +434 -188
- package/dist/templates/parallel-planner/merge-prompt.md +9 -21
- package/dist/templates/parallel-planner/plan-prompt.md +5 -31
- package/dist/templates/parallel-planner/select-issues.mjs +788 -0
- package/dist/templates/parallel-planner/setup.sh +51 -0
- package/dist/templates/parallel-planner/spec-wave-prompt.md +16 -0
- package/dist/templates/parallel-planner/template.json +1 -1
- package/dist/templates/parallel-planner/triage-prompt.md +5 -0
- package/dist/templates/parallel-planner/verify-triage.sh +19 -0
- package/dist/templates/parallel-planner-with-review/block-scope.sh +160 -0
- package/dist/templates/parallel-planner-with-review/conflict-prompt.md +22 -0
- package/dist/templates/parallel-planner-with-review/handoff.sh +175 -0
- package/dist/templates/parallel-planner-with-review/implement-prompt.md +10 -56
- package/dist/templates/parallel-planner-with-review/main.mts +473 -205
- package/dist/templates/parallel-planner-with-review/merge-prompt.md +9 -21
- package/dist/templates/parallel-planner-with-review/plan-prompt.md +5 -31
- package/dist/templates/parallel-planner-with-review/review-prompt.md +11 -49
- package/dist/templates/parallel-planner-with-review/select-issues.mjs +788 -0
- package/dist/templates/parallel-planner-with-review/setup.sh +51 -0
- package/dist/templates/parallel-planner-with-review/spec-wave-prompt.md +16 -0
- package/dist/templates/parallel-planner-with-review/template.json +1 -1
- package/dist/templates/parallel-planner-with-review/triage-prompt.md +5 -0
- package/dist/templates/parallel-planner-with-review/verify-triage.sh +19 -0
- package/dist/templates/sequential-reviewer/block-scope.sh +160 -0
- package/dist/templates/sequential-reviewer/handoff.sh +175 -0
- package/dist/templates/sequential-reviewer/implement-prompt.md +12 -46
- package/dist/templates/sequential-reviewer/main.mts +199 -103
- package/dist/templates/sequential-reviewer/review-prompt.md +11 -49
- package/dist/templates/sequential-reviewer/select-issues.mjs +788 -0
- package/dist/templates/sequential-reviewer/setup.sh +51 -0
- package/dist/templates/sequential-reviewer/template.json +1 -1
- package/dist/templates/sequential-reviewer/triage-prompt.md +5 -0
- package/dist/templates/sequential-reviewer/verify-triage.sh +19 -0
- package/dist/templates/shared/block-scope.sh +160 -0
- package/dist/templates/shared/handoff.sh +175 -0
- package/dist/templates/shared/select-issues.mjs +788 -0
- package/dist/templates/shared/setup.sh +51 -0
- package/dist/templates/shared/triage-prompt.md +5 -0
- package/dist/templates/shared/verify-triage.sh +19 -0
- package/dist/templates/simple-loop/block-scope.sh +160 -0
- package/dist/templates/simple-loop/handoff.sh +175 -0
- package/dist/templates/simple-loop/main.mts +190 -45
- package/dist/templates/simple-loop/prompt.md +12 -46
- package/dist/templates/simple-loop/select-issues.mjs +788 -0
- package/dist/templates/simple-loop/setup.sh +51 -0
- package/dist/templates/simple-loop/template.json +1 -1
- package/dist/templates/simple-loop/triage-prompt.md +5 -0
- package/dist/templates/simple-loop/verify-triage.sh +19 -0
- 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`);
|