dev-loops 0.2.7 → 0.3.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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/skills/copilot-pr-followup/SKILL.md +57 -3
- package/.claude/skills/dev-loop/SKILL.md +5 -5
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +2 -2
- package/.claude/skills/local-implementation/SKILL.md +17 -3
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +43 -0
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +39 -6
- package/package.json +2 -2
- package/scripts/README.md +6 -5
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +69 -8
- package/scripts/github/upsert-checkpoint-verdict.mjs +597 -15
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/run-queue.mjs +87 -15
- package/scripts/projects/add-queue-item.mjs +60 -43
- package/scripts/projects/archive-done-items.mjs +135 -39
- package/scripts/projects/ensure-queue-board.mjs +65 -64
- package/scripts/projects/list-queue-items.mjs +57 -56
- package/scripts/projects/move-queue-item.mjs +123 -124
- package/scripts/projects/reorder-queue-item.mjs +62 -44
- package/scripts/projects/sync-item-status.mjs +198 -0
- package/skills/copilot-pr-followup/SKILL.md +57 -3
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +2 -2
- package/skills/local-implementation/SKILL.md +17 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
3
3
|
import { runChild as _runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
4
5
|
|
|
5
6
|
const USAGE = `Usage: dev-loops project move --repo <owner/name> --project <number|id> --item <number|node-id> --to-column <name>
|
|
6
7
|
|
|
@@ -23,47 +24,63 @@ Exit codes:
|
|
|
23
24
|
3 — project, field, column, or item not found
|
|
24
25
|
`.trim();
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
function parseCliArgs(argv) {
|
|
28
|
+
const parseError = (message) => Object.assign(new Error(message), { usage: USAGE });
|
|
29
|
+
const requireValue = (token, message) => {
|
|
30
|
+
const v = token.value;
|
|
31
|
+
if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
|
|
32
|
+
throw parseError(message);
|
|
33
|
+
}
|
|
34
|
+
return v;
|
|
35
|
+
};
|
|
27
36
|
|
|
28
|
-
function parseArgs(argv) {
|
|
29
37
|
const args = {};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
const { tokens } = parseArgs({
|
|
39
|
+
args: [...argv],
|
|
40
|
+
options: {
|
|
41
|
+
repo: { type: "string" },
|
|
42
|
+
project: { type: "string" },
|
|
43
|
+
item: { type: "string" },
|
|
44
|
+
"to-column": { type: "string" },
|
|
45
|
+
help: { type: "boolean", short: "h" },
|
|
46
|
+
},
|
|
47
|
+
allowPositionals: true,
|
|
48
|
+
strict: false,
|
|
49
|
+
tokens: true,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
for (const token of tokens) {
|
|
53
|
+
if (token.kind === "positional") {
|
|
54
|
+
throw parseError(`Unexpected argument: ${token.value}`);
|
|
37
55
|
}
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
if (token.kind !== "option") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
switch (token.name) {
|
|
60
|
+
case "help":
|
|
61
|
+
if (token.value !== undefined) {
|
|
62
|
+
throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
|
|
63
|
+
}
|
|
64
|
+
args.help = true;
|
|
65
|
+
break;
|
|
66
|
+
case "repo":
|
|
67
|
+
args.repo = requireValue(token, "--repo requires a value (owner/name)");
|
|
68
|
+
break;
|
|
69
|
+
case "project":
|
|
70
|
+
args.project = requireValue(token, "--project requires a value (number or node ID)");
|
|
71
|
+
break;
|
|
72
|
+
case "item":
|
|
73
|
+
args.item = requireValue(token, "--item requires a value (number or node ID)");
|
|
74
|
+
break;
|
|
75
|
+
case "to-column":
|
|
76
|
+
args.toColumn = requireValue(token, "--to-column requires a value");
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
62
80
|
}
|
|
63
81
|
}
|
|
64
82
|
return args;
|
|
65
83
|
}
|
|
66
|
-
|
|
67
84
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
68
85
|
|
|
69
86
|
const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
@@ -210,7 +227,7 @@ const GET_PROJECT_ITEMS_BY_CONTENT = [
|
|
|
210
227
|
"query($projectId:ID!, $after:String) {",
|
|
211
228
|
" node(id:$projectId) {",
|
|
212
229
|
" ... on ProjectV2 {",
|
|
213
|
-
" items(first:
|
|
230
|
+
" items(first:100, after:$after, orderBy:{field:POSITION, direction:ASC}) {",
|
|
214
231
|
" pageInfo { hasNextPage endCursor }",
|
|
215
232
|
" nodes {",
|
|
216
233
|
" id",
|
|
@@ -223,8 +240,8 @@ const GET_PROJECT_ITEMS_BY_CONTENT = [
|
|
|
223
240
|
" }",
|
|
224
241
|
" }",
|
|
225
242
|
" content {",
|
|
226
|
-
" ... on Issue { number repository { nameWithOwner } }",
|
|
227
|
-
" ... on PullRequest { number repository { nameWithOwner } }",
|
|
243
|
+
" ... on Issue { __typename number repository { nameWithOwner } }",
|
|
244
|
+
" ... on PullRequest { __typename number repository { nameWithOwner } }",
|
|
228
245
|
" }",
|
|
229
246
|
" }",
|
|
230
247
|
" }",
|
|
@@ -233,30 +250,6 @@ const GET_PROJECT_ITEMS_BY_CONTENT = [
|
|
|
233
250
|
"}"
|
|
234
251
|
].join("\n");
|
|
235
252
|
|
|
236
|
-
const GET_PROJECT_ITEM = [
|
|
237
|
-
"query($projectId:ID!, $itemId:ID!) {",
|
|
238
|
-
" node(id:$projectId) {",
|
|
239
|
-
" ... on ProjectV2 {",
|
|
240
|
-
" item: item(id:$itemId) {",
|
|
241
|
-
" id",
|
|
242
|
-
" fieldValues(first:20) {",
|
|
243
|
-
" nodes {",
|
|
244
|
-
" ... on ProjectV2ItemFieldSingleSelectValue {",
|
|
245
|
-
" field { ... on ProjectV2SingleSelectField { id name } }",
|
|
246
|
-
" name",
|
|
247
|
-
" }",
|
|
248
|
-
" }",
|
|
249
|
-
" }",
|
|
250
|
-
" content {",
|
|
251
|
-
" ... on Issue { number title url }",
|
|
252
|
-
" ... on PullRequest { number title url }",
|
|
253
|
-
" }",
|
|
254
|
-
" }",
|
|
255
|
-
" }",
|
|
256
|
-
" }",
|
|
257
|
-
"}"
|
|
258
|
-
].join("\n");
|
|
259
|
-
|
|
260
253
|
const UPDATE_ITEM_FIELD = [
|
|
261
254
|
"mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) {",
|
|
262
255
|
" updateProjectV2ItemFieldValue(input:{projectId:$projectId, itemId:$itemId, fieldId:$fieldId, value:{singleSelectOptionId:$optionId}}) {",
|
|
@@ -337,6 +330,39 @@ async function listAllFields(projectId, env, runChild) {
|
|
|
337
330
|
return fields;
|
|
338
331
|
}
|
|
339
332
|
|
|
333
|
+
// ── Paginated item listing (position order) ──────────────────────────────
|
|
334
|
+
|
|
335
|
+
async function fetchAllItems(projectId, env, runChild) {
|
|
336
|
+
const items = [];
|
|
337
|
+
let after = null;
|
|
338
|
+
while (true) {
|
|
339
|
+
const vars = { projectId };
|
|
340
|
+
if (after) vars.after = after;
|
|
341
|
+
const payload = await ghGraphql(GET_PROJECT_ITEMS_BY_CONTENT, vars, env, runChild);
|
|
342
|
+
const connection = payload?.data?.node?.items;
|
|
343
|
+
const nodes = connection?.nodes ?? [];
|
|
344
|
+
items.push(...nodes);
|
|
345
|
+
const pageInfo = connection?.pageInfo ?? {};
|
|
346
|
+
if (!pageInfo.hasNextPage) break;
|
|
347
|
+
if (!pageInfo.endCursor) {
|
|
348
|
+
throw Object.assign(
|
|
349
|
+
new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
|
|
350
|
+
{ code: "GH_API_ERROR" },
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
after = pageInfo.endCursor;
|
|
354
|
+
}
|
|
355
|
+
return items;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function statusOf(node) {
|
|
359
|
+
const fvs = node?.fieldValues?.nodes ?? [];
|
|
360
|
+
for (const fv of fvs) {
|
|
361
|
+
if (fv && fv.field && fv.field.name === "Status") return fv.name;
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
340
366
|
// ── Exit code classification ────────────────────────────────────────────
|
|
341
367
|
|
|
342
368
|
function classifyExitCode(err) {
|
|
@@ -397,80 +423,53 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
397
423
|
);
|
|
398
424
|
}
|
|
399
425
|
|
|
400
|
-
// 4. Find the item
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
426
|
+
// 4. Find the item.
|
|
427
|
+
//
|
|
428
|
+
// Fetch the full board item list ONCE (paginated, position order) and resolve
|
|
429
|
+
// BOTH ref kinds against it. This reuses the proven pattern from
|
|
430
|
+
// reorder-queue-item / list-queue-items: a node-id ref matches by item.id, a
|
|
431
|
+
// number ref matches by content.number. Both are scoped to the requested repo
|
|
432
|
+
// so a cross-project ref fails closed with ITEM_NOT_FOUND. (The previous code
|
|
433
|
+
// used `ProjectV2.item` — a field that does not exist — for the node-id path,
|
|
434
|
+
// and a single non-paginated `items(first:10)` page for the number path, so it
|
|
435
|
+
// could not find items beyond the first page.)
|
|
436
|
+
const allItems = await fetchAllItems(project.id, env, child);
|
|
437
|
+
|
|
438
|
+
let match;
|
|
406
439
|
if (itemRef.kind === "id") {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}, env, child);
|
|
412
|
-
const item = itemPayload?.data?.node?.item;
|
|
413
|
-
if (!item) {
|
|
440
|
+
match = allItems.find(
|
|
441
|
+
(it) => it.id === itemRef.value && it.content?.repository?.nameWithOwner === repo,
|
|
442
|
+
);
|
|
443
|
+
if (!match) {
|
|
414
444
|
throw Object.assign(
|
|
415
|
-
new Error(`Item "${itemRef.value}" not found in project "${project.title}"`),
|
|
445
|
+
new Error(`Item "${itemRef.value}" not found in project "${project.title}" for repo "${repo}"`),
|
|
416
446
|
{ code: "ITEM_NOT_FOUND" },
|
|
417
447
|
);
|
|
418
448
|
}
|
|
419
|
-
itemId = item.id;
|
|
420
|
-
const fvs = item.fieldValues?.nodes ?? [];
|
|
421
|
-
for (const fv of fvs) {
|
|
422
|
-
if (fv && fv.field && fv.field.name === "Status") {
|
|
423
|
-
previousColumn = fv.name;
|
|
424
|
-
break;
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
if (item.content) {
|
|
428
|
-
if (item.content.__typename === "Issue") {
|
|
429
|
-
issueNumber = item.content.number;
|
|
430
|
-
} else {
|
|
431
|
-
prNumber = item.content.number;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
449
|
} else {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const matchingItems = items.filter((it) => {
|
|
443
|
-
if (!it.content) return false;
|
|
444
|
-
const repoMatch = it.content.repository?.nameWithOwner === repo;
|
|
445
|
-
if (itemRef.kind === "number") {
|
|
446
|
-
return repoMatch && it.content.number === itemRef.value;
|
|
447
|
-
}
|
|
448
|
-
return repoMatch;
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
if (matchingItems.length === 0) {
|
|
450
|
+
match = allItems.find(
|
|
451
|
+
(it) =>
|
|
452
|
+
it.content &&
|
|
453
|
+
it.content.repository?.nameWithOwner === repo &&
|
|
454
|
+
it.content.number === itemRef.value,
|
|
455
|
+
);
|
|
456
|
+
if (!match) {
|
|
452
457
|
throw Object.assign(
|
|
453
458
|
new Error(`Item #${itemRef.value} not found in project "${project.title}" for repo "${repo}"`),
|
|
454
459
|
{ code: "ITEM_NOT_FOUND" },
|
|
455
460
|
);
|
|
456
461
|
}
|
|
462
|
+
}
|
|
457
463
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
if (match.content) {
|
|
469
|
-
if (match.content.__typename === "Issue") {
|
|
470
|
-
issueNumber = match.content.number;
|
|
471
|
-
} else {
|
|
472
|
-
prNumber = match.content.number;
|
|
473
|
-
}
|
|
464
|
+
const itemId = match.id;
|
|
465
|
+
const previousColumn = statusOf(match);
|
|
466
|
+
let issueNumber = null;
|
|
467
|
+
let prNumber = null;
|
|
468
|
+
if (match.content) {
|
|
469
|
+
if (match.content.__typename === "PullRequest") {
|
|
470
|
+
prNumber = match.content.number;
|
|
471
|
+
} else {
|
|
472
|
+
issueNumber = match.content.number;
|
|
474
473
|
}
|
|
475
474
|
}
|
|
476
475
|
|
|
@@ -520,7 +519,7 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
520
519
|
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
|
|
521
520
|
let args;
|
|
522
521
|
try {
|
|
523
|
-
args =
|
|
522
|
+
args = parseCliArgs(argv);
|
|
524
523
|
} catch (err) {
|
|
525
524
|
stderr.write(`${formatCliError(err)}\n`);
|
|
526
525
|
process.exitCode = 1;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
3
3
|
import { runChild as _runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
4
5
|
|
|
5
6
|
const USAGE = `Usage:
|
|
6
7
|
dev-loops project reorder --repo <owner/name> --project <number|id> --item <number|node-id> [--after <number|node-id>]
|
|
@@ -40,62 +41,79 @@ Exit codes:
|
|
|
40
41
|
`.trim();
|
|
41
42
|
|
|
42
43
|
const SUBCOMMANDS = new Set(["move-to-top", "move-after", "order"]);
|
|
43
|
-
const VALID_ARGS = new Set(["--repo", "--project", "--item", "--after", "--dry-run", "--help", "-h"]);
|
|
44
44
|
|
|
45
|
-
function
|
|
45
|
+
function parseCliArgs(argv) {
|
|
46
|
+
const parseError = (message) => Object.assign(new Error(message), { usage: USAGE });
|
|
47
|
+
const requireValue = (token, message) => {
|
|
48
|
+
const v = token.value;
|
|
49
|
+
if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
|
|
50
|
+
throw parseError(message);
|
|
51
|
+
}
|
|
52
|
+
return v;
|
|
53
|
+
};
|
|
54
|
+
|
|
46
55
|
const args = { _positional: [] };
|
|
47
|
-
let
|
|
56
|
+
let rest = argv;
|
|
48
57
|
|
|
49
|
-
// Optional leading positional subcommand.
|
|
50
58
|
if (argv.length > 0 && SUBCOMMANDS.has(argv[0])) {
|
|
51
59
|
args._subcommand = argv[0];
|
|
52
|
-
|
|
53
|
-
}
|
|
60
|
+
rest = argv.slice(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { tokens } = parseArgs({
|
|
64
|
+
args: [...rest],
|
|
65
|
+
options: {
|
|
66
|
+
repo: { type: "string" },
|
|
67
|
+
project: { type: "string" },
|
|
68
|
+
item: { type: "string" },
|
|
69
|
+
after: { type: "string" },
|
|
70
|
+
"dry-run": { type: "boolean" },
|
|
71
|
+
help: { type: "boolean", short: "h" },
|
|
72
|
+
},
|
|
73
|
+
allowPositionals: true,
|
|
74
|
+
strict: false,
|
|
75
|
+
tokens: true,
|
|
76
|
+
});
|
|
54
77
|
|
|
55
|
-
for (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// Positional ref (only meaningful with a subcommand).
|
|
59
|
-
args._positional.push(arg);
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
if (token.kind === "positional") {
|
|
80
|
+
args._positional.push(token.value);
|
|
60
81
|
continue;
|
|
61
82
|
}
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
new Error(`Unknown flag: ${arg}`),
|
|
65
|
-
{ code: "INVALID_ARGS", usage: USAGE },
|
|
66
|
-
);
|
|
83
|
+
if (token.kind !== "option") {
|
|
84
|
+
continue;
|
|
67
85
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
switch (token.name) {
|
|
87
|
+
case "help":
|
|
88
|
+
if (token.value !== undefined) {
|
|
89
|
+
throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
|
|
90
|
+
}
|
|
91
|
+
args.help = true;
|
|
92
|
+
break;
|
|
93
|
+
case "repo":
|
|
94
|
+
args.repo = requireValue(token, "--repo requires a value (owner/name)");
|
|
95
|
+
break;
|
|
96
|
+
case "project":
|
|
97
|
+
args.project = requireValue(token, "--project requires a value (number or node ID)");
|
|
98
|
+
break;
|
|
99
|
+
case "item":
|
|
100
|
+
args.item = requireValue(token, "--item requires a value (number or node ID)");
|
|
101
|
+
break;
|
|
102
|
+
case "after":
|
|
103
|
+
args.after = requireValue(token, "--after requires a value (number or node ID)");
|
|
104
|
+
break;
|
|
105
|
+
case "dry-run":
|
|
106
|
+
if (token.value !== undefined) {
|
|
107
|
+
throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
|
|
108
|
+
}
|
|
109
|
+
args.dryRun = true;
|
|
110
|
+
break;
|
|
111
|
+
default:
|
|
112
|
+
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
94
113
|
}
|
|
95
114
|
}
|
|
96
115
|
return args;
|
|
97
116
|
}
|
|
98
|
-
|
|
99
117
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
100
118
|
|
|
101
119
|
const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
@@ -747,7 +765,7 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
747
765
|
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
|
|
748
766
|
let args;
|
|
749
767
|
try {
|
|
750
|
-
args =
|
|
768
|
+
args = parseCliArgs(argv);
|
|
751
769
|
} catch (err) {
|
|
752
770
|
stderr.write(`${formatCliError(err)}\n`);
|
|
753
771
|
process.exitCode = 1;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
3
|
+
import { runChild as _runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { syncBoardStatus } from "@dev-loops/core/loop/queue-board-sync";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
|
|
7
|
+
const USAGE = `Usage: dev-loops project sync-status --repo <owner/name> --item <number> --to-column <name>
|
|
8
|
+
|
|
9
|
+
Sync a queued issue/PR's board Status column on a dev-loop transition (e.g.
|
|
10
|
+
PR opened → "In Progress", merged → "Done"). Resolves the board from .devloops
|
|
11
|
+
and uses local gh auth.
|
|
12
|
+
|
|
13
|
+
This command is BEST-EFFORT and NON-FATAL: a board that is not configured, an
|
|
14
|
+
item that is not on the board, or any GitHub API failure exits 0 with a JSON
|
|
15
|
+
result describing the skip/failure. It never fails the caller.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--repo <owner/name> Required. Repository to scope the project search.
|
|
19
|
+
--item <number> Required. Linked issue/PR number (positive integer).
|
|
20
|
+
--to-column <name> Required. Target Status column (e.g. "In Progress", "Done").
|
|
21
|
+
--help, -h Show this help.
|
|
22
|
+
|
|
23
|
+
Output (stdout):
|
|
24
|
+
JSON: the syncBoardStatus result, e.g.
|
|
25
|
+
{ ok: true, skipped: false, result: { item: { newColumn } } }
|
|
26
|
+
{ ok: true, skipped: true, reason: "board not configured" }
|
|
27
|
+
|
|
28
|
+
Exit codes:
|
|
29
|
+
0 — always on a parsed command (best-effort sync; skips/failures are reported in JSON)
|
|
30
|
+
1 — usage or argument error
|
|
31
|
+
`.trim();
|
|
32
|
+
|
|
33
|
+
function parseCliArgs(argv) {
|
|
34
|
+
const requireValue = (token, message, code) => {
|
|
35
|
+
const v = token.value;
|
|
36
|
+
if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
|
|
37
|
+
throw Object.assign(new Error(message), { code, usage: USAGE });
|
|
38
|
+
}
|
|
39
|
+
return v;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const args = {};
|
|
43
|
+
const { tokens } = parseArgs({
|
|
44
|
+
args: [...argv],
|
|
45
|
+
options: {
|
|
46
|
+
repo: { type: "string" },
|
|
47
|
+
item: { type: "string" },
|
|
48
|
+
"to-column": { type: "string" },
|
|
49
|
+
help: { type: "boolean", short: "h" },
|
|
50
|
+
},
|
|
51
|
+
allowPositionals: true,
|
|
52
|
+
strict: false,
|
|
53
|
+
tokens: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
for (const token of tokens) {
|
|
57
|
+
if (token.kind === "positional") {
|
|
58
|
+
throw Object.assign(new Error(`Unexpected argument: ${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
59
|
+
}
|
|
60
|
+
if (token.kind !== "option") {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
switch (token.name) {
|
|
64
|
+
case "help":
|
|
65
|
+
if (token.value !== undefined) {
|
|
66
|
+
throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
67
|
+
}
|
|
68
|
+
args.help = true;
|
|
69
|
+
break;
|
|
70
|
+
case "repo":
|
|
71
|
+
args.repo = requireValue(token, "--repo requires a value (owner/name)", "INVALID_REPO");
|
|
72
|
+
break;
|
|
73
|
+
case "item":
|
|
74
|
+
args.item = requireValue(token, "--item requires a value (positive integer)", "INVALID_ITEM");
|
|
75
|
+
break;
|
|
76
|
+
case "to-column":
|
|
77
|
+
args.toColumn = requireValue(token, "--to-column requires a value", "INVALID_COLUMN");
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return args;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Validation ───────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
89
|
+
const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
|
|
90
|
+
|
|
91
|
+
function validateRepo(repo) {
|
|
92
|
+
if (!repo || typeof repo !== "string") {
|
|
93
|
+
throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO" });
|
|
94
|
+
}
|
|
95
|
+
const trimmed = repo.trim();
|
|
96
|
+
if (trimmed !== repo) {
|
|
97
|
+
throw Object.assign(
|
|
98
|
+
new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`),
|
|
99
|
+
{ code: "INVALID_REPO" },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const slashIdx = repo.indexOf("/");
|
|
103
|
+
if (slashIdx === -1) {
|
|
104
|
+
throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
|
|
105
|
+
}
|
|
106
|
+
const owner = repo.slice(0, slashIdx);
|
|
107
|
+
const name = repo.slice(slashIdx + 1);
|
|
108
|
+
if (!owner || !name || !OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
|
|
109
|
+
throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
|
|
110
|
+
}
|
|
111
|
+
return repo;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseItemNumber(raw) {
|
|
115
|
+
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
|
|
116
|
+
throw Object.assign(new Error("--item is required"), { code: "INVALID_ITEM" });
|
|
117
|
+
}
|
|
118
|
+
const trimmed = raw.trim();
|
|
119
|
+
const asNum = Number(trimmed);
|
|
120
|
+
if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
|
|
121
|
+
return asNum;
|
|
122
|
+
}
|
|
123
|
+
throw Object.assign(new Error(`--item must be a positive integer, got "${raw}"`), { code: "INVALID_ITEM" });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Main logic ──────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async function main(args, { env = process.env, runChild, cwd = process.cwd() } = {}) {
|
|
129
|
+
const child = runChild ?? _runChild;
|
|
130
|
+
const repo = validateRepo(args.repo);
|
|
131
|
+
const item = parseItemNumber(args.item);
|
|
132
|
+
const toColumn = (args.toColumn ?? "").trim();
|
|
133
|
+
if (!toColumn) {
|
|
134
|
+
throw Object.assign(new Error("--to-column is required"), { code: "INVALID_COLUMN" });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// syncBoardStatus owns the fail-open contract: a not-configured board, an
|
|
138
|
+
// item not on the board, or any gh/API failure resolves to a skipped/failure
|
|
139
|
+
// result rather than throwing. We surface that result verbatim and exit 0.
|
|
140
|
+
return syncBoardStatus(repo, cwd, item, toColumn, env, { runChild: child });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── CLI entrypoint ──────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd() } = {}) {
|
|
146
|
+
let args;
|
|
147
|
+
try {
|
|
148
|
+
args = parseCliArgs(argv);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
stderr.write(`${formatCliError(err)}\n`);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (args.help) {
|
|
155
|
+
stdout.write(USAGE);
|
|
156
|
+
// Help is a success path: clear any pre-existing non-zero process.exitCode
|
|
157
|
+
// so help output can't inherit a leaked failure code.
|
|
158
|
+
process.exitCode = 0;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Argument validation errors (bad --repo / --item / missing --to-column) are
|
|
163
|
+
// genuine usage errors → exit 1. Everything past validation is best-effort:
|
|
164
|
+
// syncBoardStatus never throws for board/API conditions, so a parsed command
|
|
165
|
+
// always reports its result on stdout and exits 0.
|
|
166
|
+
let result;
|
|
167
|
+
try {
|
|
168
|
+
result = await main(args, { env, cwd });
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (err.code === "INVALID_REPO" || err.code === "INVALID_ITEM" || err.code === "INVALID_COLUMN" || err.code === "INVALID_ARGS") {
|
|
171
|
+
stderr.write(`${formatCliError(err)}\n`);
|
|
172
|
+
process.exitCode = 1;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Defensive: any unexpected error stays best-effort/non-fatal — report it
|
|
176
|
+
// as a skip and keep exit 0 so it never blocks the PR/merge caller.
|
|
177
|
+
stdout.write(JSON.stringify({ ok: true, skipped: true, reason: err.message ?? "board sync failed" }) + "\n");
|
|
178
|
+
// Explicitly assert success: a pre-existing non-zero process.exitCode from a
|
|
179
|
+
// long-lived caller must not leak through this best-effort path.
|
|
180
|
+
process.exitCode = 0;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
stdout.write(JSON.stringify(result) + "\n");
|
|
184
|
+
// Best-effort contract: a parsed command always reports success. Explicitly
|
|
185
|
+
// clear any pre-existing non-zero process.exitCode so it cannot leak.
|
|
186
|
+
process.exitCode = 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
190
|
+
runCli(process.argv.slice(2)).catch((error) => {
|
|
191
|
+
// Best-effort: even an unexpected failure must not fail the caller.
|
|
192
|
+
process.stdout.write(JSON.stringify({ ok: true, skipped: true, reason: error.message ?? "board sync failed" }) + "\n");
|
|
193
|
+
// Force the documented exit-0 contract even on this last-resort path.
|
|
194
|
+
process.exitCode = 0;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export { main, parseCliArgs, parseItemNumber, runCli };
|