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,12 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
for (let i = 0; i < args.length; i++) {
|
|
8
|
-
if (args[i] === "--help" || args[i] === "-h") {
|
|
9
|
-
process.stdout.write(`Usage: detect-change-scope.mjs [--base <ref>] [--head <ref>]
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
|
|
6
|
+
const USAGE = `Usage: detect-change-scope.mjs [--base <ref>] [--head <ref>]
|
|
10
7
|
Detect change scope from git diff for light-mode eligibility.
|
|
11
8
|
Options:
|
|
12
9
|
--base <ref> Override base ref (default: HEAD~1)
|
|
@@ -15,11 +12,39 @@ Options:
|
|
|
15
12
|
Exit codes:
|
|
16
13
|
0 Success
|
|
17
14
|
1 Error
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
function parseCliArgs(argv) {
|
|
18
|
+
const { tokens } = parseArgs({
|
|
19
|
+
args: [...argv],
|
|
20
|
+
options: {
|
|
21
|
+
base: { type: "string" },
|
|
22
|
+
head: { type: "string" },
|
|
23
|
+
help: { type: "boolean", short: "h" },
|
|
24
|
+
},
|
|
25
|
+
allowPositionals: true,
|
|
26
|
+
strict: false,
|
|
27
|
+
tokens: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const opts = { base: null, head: null };
|
|
31
|
+
for (const token of tokens) {
|
|
32
|
+
if (token.kind === "option") {
|
|
33
|
+
if (token.name === "help") {
|
|
34
|
+
if (token.value !== undefined) {
|
|
35
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
36
|
+
}
|
|
37
|
+
process.stdout.write(USAGE);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
if (token.name === "base") {
|
|
41
|
+
opts.base = token.value ?? null;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (token.name === "head") {
|
|
45
|
+
opts.head = token.value ?? null;
|
|
46
|
+
}
|
|
20
47
|
}
|
|
21
|
-
if (args[i] === "--base" && i + 1 < args.length) opts.base = args[++i];
|
|
22
|
-
else if (args[i] === "--head" && i + 1 < args.length) opts.head = args[++i];
|
|
23
48
|
}
|
|
24
49
|
return opts;
|
|
25
50
|
}
|
|
@@ -64,7 +89,7 @@ function isEligibleForLightMode(scope, threshold) {
|
|
|
64
89
|
return scope.filesChanged <= threshold.maxFiles && scope.linesChanged <= threshold.maxLines;
|
|
65
90
|
}
|
|
66
91
|
async function main() {
|
|
67
|
-
const opts =
|
|
92
|
+
const opts = parseCliArgs(process.argv.slice(2));
|
|
68
93
|
const scope = detectScope(opts);
|
|
69
94
|
let threshold = { maxFiles: 3, maxLines: 200 };
|
|
70
95
|
let eligible = false;
|
|
@@ -6,13 +6,13 @@ import {
|
|
|
6
6
|
formatCliError,
|
|
7
7
|
isCopilotLogin,
|
|
8
8
|
isDirectCliRun,
|
|
9
|
-
normalizeTimestamp,
|
|
10
9
|
parseJsonText,
|
|
11
10
|
parseReviewThreads,
|
|
11
|
+
resolveDraftGateRoundResetMs,
|
|
12
12
|
summarizeCopilotReviews,
|
|
13
13
|
} from "../_core-helpers.mjs";
|
|
14
14
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
15
|
-
import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
15
|
+
import { loadDevLoopConfig, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
16
16
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
17
17
|
import { buildSnapshotFromPrFacts, interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
|
|
18
18
|
import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
|
|
@@ -303,18 +303,13 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
303
303
|
const gateEvidence = await detectCheckpointEvidence(options, runtime);
|
|
304
304
|
// When draft gate was re-passed on a different head, use its timestamp
|
|
305
305
|
// to reset the Copilot round count — only reviews after the re-pass count.
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
&& typeof draftGateHeadSha === "string"
|
|
314
|
-
&& !draftGateOnCurrentHead
|
|
315
|
-
&& typeof gateEvidence.draftGate?.updatedAt === "string"
|
|
316
|
-
? normalizeTimestamp(gateEvidence.draftGate.updatedAt)
|
|
317
|
-
: null;
|
|
306
|
+
// Shared with request-copilot-review so both scripts compute the same
|
|
307
|
+
// completed round count / cap (#896). Prefix matching for the head SHA lets
|
|
308
|
+
// shortened SHAs (7+) from gate comments match the full headRefOid.
|
|
309
|
+
const draftGateResetAtMs = resolveDraftGateRoundResetMs({
|
|
310
|
+
draftGate: gateEvidence.draftGate,
|
|
311
|
+
currentHeadSha,
|
|
312
|
+
});
|
|
318
313
|
const reviewSummary = summarizeCopilotReviews(prData?.reviews, { headSha: currentHeadSha, draftGateResetAtMs });
|
|
319
314
|
const reviewRequestStatus = requestedReviewers.requested
|
|
320
315
|
? "requested"
|
|
@@ -338,8 +333,19 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
338
333
|
if (gateEvidence.currentHeadSha !== currentHeadSha) {
|
|
339
334
|
throw new Error(`PR head changed while loading gate coordination facts for ${options.repo}#${options.pr}; refuse to evaluate mixed-head gate state.`);
|
|
340
335
|
}
|
|
341
|
-
|
|
342
|
-
|
|
336
|
+
// Resolve the refinement config (round cap, low-signal heuristic) and feed it to
|
|
337
|
+
// the interpreter. Without it, the interpreter cannot see maxCopilotRounds and so
|
|
338
|
+
// never resolves ROUND_CAP_CLEAN_FALLBACK — a post-cap clean head would fall to
|
|
339
|
+
// READY_TO_REREQUEST_REVIEW, dead-ending the loop at the round cap (#896). This
|
|
340
|
+
// keeps the gate-coordination interpretation consistent with the standalone
|
|
341
|
+
// detect-copilot-loop-state path and with request-copilot-review's cap logic.
|
|
342
|
+
const interpreterRepoRoot = runtime.repoRoot ?? process.cwd();
|
|
343
|
+
const interpreterConfigResult = await loadDevLoopConfig({ repoRoot: interpreterRepoRoot });
|
|
344
|
+
const interpreterRefinementConfig = (Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0)
|
|
345
|
+
? resolveRefinement({ version: 1 })
|
|
346
|
+
: resolveRefinement(interpreterConfigResult.config ?? { version: 1 });
|
|
347
|
+
const interpretation = interpretLoopState(snapshot, interpreterRefinementConfig);
|
|
348
|
+
const disposition = summarizeLoopInterpretation(interpretation, interpreterRefinementConfig);
|
|
343
349
|
const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
|
|
344
350
|
? prData.mergeStateStatus.trim().toUpperCase()
|
|
345
351
|
: null;
|
|
@@ -362,6 +368,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
362
368
|
interpretation,
|
|
363
369
|
disposition,
|
|
364
370
|
refinementArtifact,
|
|
371
|
+
refinementConfig: interpreterRefinementConfig,
|
|
365
372
|
};
|
|
366
373
|
}
|
|
367
374
|
|
|
@@ -429,9 +436,13 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
429
436
|
&& typeof (context.snapshot?.copilotReviewRoundCount) === "number"
|
|
430
437
|
&& context.snapshot?.copilotReviewRoundCount >= maxCopilotRounds;
|
|
431
438
|
const sameHeadCleanConverged = context.interpretation?.sameHeadCleanConverged ?? false;
|
|
439
|
+
// Round-cap clean fallback (#896): the interpreter resolved a clean post-cap head
|
|
440
|
+
// (zero unresolved threads + green CI) that Copilot will not re-review. The formal
|
|
441
|
+
// request guard must not fire here — pre_approval_gate reviews the post-cap head.
|
|
442
|
+
const roundCapCleanFallback = context.interpretation?.roundCapCleanEligible ?? false;
|
|
432
443
|
const copilotReviewEverFormallyRequested = copilotReviewRequestStatus === "none"
|
|
433
444
|
&& guardBoundaries.has(result.gateBoundary)
|
|
434
|
-
&& !(roundCapReached && sameHeadCleanConverged)
|
|
445
|
+
&& !(roundCapReached && (sameHeadCleanConverged || roundCapCleanFallback))
|
|
435
446
|
? await fetchCopilotEverFormallyRequested(
|
|
436
447
|
{ repo: context.repo, pr: context.pr },
|
|
437
448
|
runtime,
|
|
@@ -442,7 +453,8 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
442
453
|
copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
|
|
443
454
|
copilotReviewEverFormallyRequested,
|
|
444
455
|
maxCopilotRounds,
|
|
445
|
-
sameHeadCleanConverged
|
|
456
|
+
sameHeadCleanConverged,
|
|
457
|
+
roundCapCleanFallback,
|
|
446
458
|
gateBoundary: result.gateBoundary,
|
|
447
459
|
})) {
|
|
448
460
|
result.gateBoundary = PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
4
5
|
import { interpretTrackerLoopState } from "@dev-loops/core/loop/tracker-first-loop-state";
|
|
6
|
+
|
|
5
7
|
function showHelp() {
|
|
6
8
|
process.stdout.write(`Usage: detect-tracker-first-loop-state.mjs --repo <owner/name> --issue <number>
|
|
7
9
|
Detect tracker-first loop state for a GitHub issue.
|
|
@@ -15,40 +17,65 @@ Exit codes:
|
|
|
15
17
|
`);
|
|
16
18
|
process.exit(0);
|
|
17
19
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
|
|
21
|
+
function parseCliArgs(argv) {
|
|
22
|
+
const { tokens } = parseArgs({
|
|
23
|
+
args: [...argv],
|
|
24
|
+
options: {
|
|
25
|
+
repo: { type: "string" },
|
|
26
|
+
issue: { type: "string" },
|
|
27
|
+
help: { type: "boolean", short: "h" },
|
|
28
|
+
},
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
strict: false,
|
|
31
|
+
tokens: true,
|
|
32
|
+
});
|
|
33
|
+
|
|
20
34
|
const opts = { repo: null, issue: null };
|
|
21
|
-
for (
|
|
22
|
-
if (
|
|
23
|
-
|
|
35
|
+
for (const token of tokens) {
|
|
36
|
+
if (token.kind === "option") {
|
|
37
|
+
if (token.name === "help") {
|
|
38
|
+
if (token.value !== undefined) {
|
|
39
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
40
|
+
}
|
|
41
|
+
showHelp();
|
|
42
|
+
}
|
|
43
|
+
if (token.name === "repo") {
|
|
44
|
+
opts.repo = token.value ?? null;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (token.name === "issue") {
|
|
48
|
+
opts.issue = token.value ?? null;
|
|
49
|
+
}
|
|
24
50
|
}
|
|
25
|
-
if (args[i] === "--repo" && i + 1 < args.length) opts.repo = args[++i];
|
|
26
|
-
else if (args[i] === "--issue" && i + 1 < args.length) opts.issue = args[++i];
|
|
27
51
|
}
|
|
28
52
|
return opts;
|
|
29
53
|
}
|
|
54
|
+
|
|
30
55
|
async function main() {
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
56
|
+
const rawOpts = parseCliArgs(process.argv.slice(2));
|
|
57
|
+
if (!rawOpts.repo || !rawOpts.issue) {
|
|
33
58
|
process.stderr.write(
|
|
34
59
|
JSON.stringify({ ok: false, error: "--repo and --issue required" }) + "\n"
|
|
35
60
|
);
|
|
36
61
|
process.exitCode = 1;
|
|
37
62
|
return;
|
|
38
63
|
}
|
|
64
|
+
const repo = rawOpts.repo;
|
|
65
|
+
const issue = rawOpts.issue;
|
|
39
66
|
let rawState = "";
|
|
40
67
|
let prContext = null;
|
|
41
68
|
try {
|
|
42
69
|
const issueJson = execFileSync(
|
|
43
70
|
"gh",
|
|
44
|
-
["issue", "view", String(
|
|
71
|
+
["issue", "view", String(issue), "--repo", repo, "--json", "state,title", "--jq", ".state"],
|
|
45
72
|
{ encoding: "utf8" }
|
|
46
73
|
).trim();
|
|
47
74
|
rawState = issueJson;
|
|
48
75
|
try {
|
|
49
76
|
const prJson = execFileSync(
|
|
50
77
|
"gh",
|
|
51
|
-
["pr", "list", "--repo",
|
|
78
|
+
["pr", "list", "--repo", repo, "--search", `${issue} in:body`, "--state", "open", "--json", "number,state,headRefName", "--jq", ".[0]"],
|
|
52
79
|
{ encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }
|
|
53
80
|
).trim();
|
|
54
81
|
if (prJson) prContext = JSON.parse(prJson);
|
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { parseArgs } from "node:util";
|
|
17
18
|
import { runQueue, DEFAULT_QUEUE_DRIVER_OPTIONS } from "@dev-loops/core/loop/queue-driver";
|
|
18
19
|
import { computeParallelSchedule } from "@dev-loops/core/loop/queue-parallel";
|
|
19
20
|
import { readQueue } from "@dev-loops/core/loop/queue-state";
|
|
21
|
+
import { reconcileBoardMembership } from "@dev-loops/core/loop/queue-membership";
|
|
20
22
|
import { parsePositiveInteger } from "@dev-loops/core/cli/primitives";
|
|
21
23
|
|
|
22
24
|
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
|
@@ -27,7 +29,7 @@ const USAGE = `Usage:
|
|
|
27
29
|
Run the dev-loop queue driver over entries in .pi/dev-loop-queue.json.
|
|
28
30
|
Exit codes: 0 success, 1 error`.trim();
|
|
29
31
|
|
|
30
|
-
function
|
|
32
|
+
function parseCliArgs(argv) {
|
|
31
33
|
const args = {
|
|
32
34
|
repo: null,
|
|
33
35
|
mergeAuthorized: false,
|
|
@@ -37,27 +39,58 @@ function parseArgs(argv) {
|
|
|
37
39
|
help: false,
|
|
38
40
|
};
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
const { tokens } = parseArgs({
|
|
43
|
+
args: [...argv],
|
|
44
|
+
options: {
|
|
45
|
+
repo: { type: "string" },
|
|
46
|
+
"merge-authorized": { type: "boolean" },
|
|
47
|
+
parallel: { type: "boolean" },
|
|
48
|
+
"redispatch-max-retries": { type: "string" },
|
|
49
|
+
"max-parallel": { type: "string" },
|
|
50
|
+
help: { type: "boolean", short: "h" },
|
|
51
|
+
},
|
|
52
|
+
allowPositionals: true,
|
|
53
|
+
strict: false,
|
|
54
|
+
tokens: true,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
for (const token of tokens) {
|
|
58
|
+
if (token.kind === "positional") {
|
|
59
|
+
throw new Error(`unknown argument: ${token.value}`);
|
|
60
|
+
}
|
|
61
|
+
if (token.kind !== "option") {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
switch (token.name) {
|
|
65
|
+
case "repo":
|
|
66
|
+
args.repo = token.value;
|
|
44
67
|
break;
|
|
45
|
-
case "
|
|
68
|
+
case "merge-authorized":
|
|
69
|
+
if (token.value !== undefined) {
|
|
70
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
71
|
+
}
|
|
46
72
|
args.mergeAuthorized = true;
|
|
47
73
|
break;
|
|
48
|
-
case "
|
|
74
|
+
case "parallel":
|
|
75
|
+
if (token.value !== undefined) {
|
|
76
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
77
|
+
}
|
|
49
78
|
args.parallel = true;
|
|
50
79
|
break;
|
|
51
|
-
case "
|
|
52
|
-
args.reDispatchMaxRetries = parsePositiveInteger(
|
|
80
|
+
case "redispatch-max-retries":
|
|
81
|
+
args.reDispatchMaxRetries = parsePositiveInteger(token.value, "--redispatch-max-retries");
|
|
53
82
|
break;
|
|
54
|
-
case "
|
|
55
|
-
args.maxParallel = parsePositiveInteger(
|
|
83
|
+
case "max-parallel":
|
|
84
|
+
args.maxParallel = parsePositiveInteger(token.value, "--max-parallel");
|
|
56
85
|
break;
|
|
57
|
-
case "
|
|
58
|
-
|
|
86
|
+
case "help":
|
|
87
|
+
if (token.value !== undefined) {
|
|
88
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
89
|
+
}
|
|
59
90
|
args.help = true;
|
|
60
91
|
break;
|
|
92
|
+
default:
|
|
93
|
+
throw new Error(`unknown argument: ${token.rawName}`);
|
|
61
94
|
}
|
|
62
95
|
}
|
|
63
96
|
|
|
@@ -65,7 +98,7 @@ function parseArgs(argv) {
|
|
|
65
98
|
}
|
|
66
99
|
|
|
67
100
|
async function main() {
|
|
68
|
-
const args =
|
|
101
|
+
const args = parseCliArgs(process.argv.slice(2));
|
|
69
102
|
|
|
70
103
|
if (args.help) {
|
|
71
104
|
console.log(USAGE);
|
|
@@ -79,7 +112,46 @@ async function main() {
|
|
|
79
112
|
|
|
80
113
|
const queue = await readQueue(REPO_ROOT);
|
|
81
114
|
|
|
82
|
-
|
|
115
|
+
// A configured GitHub Projects board is the authoritative queue MEMBERSHIP
|
|
116
|
+
// source (issue #864): fold its "Next Up" items into the queue before judging
|
|
117
|
+
// emptiness so a populated board with an empty local queue is no longer a
|
|
118
|
+
// silent no-op. Fail-open — a board hiccup falls back to the local queue.
|
|
119
|
+
// reconcileBoardMembership already logs an "added N ... from board Next Up"
|
|
120
|
+
// line to stderr (single source of truth); we deliberately do not duplicate
|
|
121
|
+
// it here to avoid noise for JSON consumers of stdout.
|
|
122
|
+
const membership = await reconcileBoardMembership(REPO_ROOT, args.repo, queue);
|
|
123
|
+
|
|
124
|
+
if (membership.emptiness === "board_empty") {
|
|
125
|
+
// Distinct from the legacy generic "Queue is empty": the board is the
|
|
126
|
+
// membership source and it currently has nothing in Next Up. This branch is
|
|
127
|
+
// only reached for a genuinely empty Next Up (reason == null), never for a
|
|
128
|
+
// resolution failure (which falls through to the local queue below).
|
|
129
|
+
console.log(JSON.stringify({
|
|
130
|
+
ok: true,
|
|
131
|
+
message: "Board configured but Next Up is empty; nothing to run",
|
|
132
|
+
boardConfigured: true,
|
|
133
|
+
reason: null,
|
|
134
|
+
results: [],
|
|
135
|
+
}));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (membership.emptiness === "board_unavailable") {
|
|
140
|
+
// The board IS configured but Next Up resolution failed (fail-open) and the
|
|
141
|
+
// local queue had nothing to fall back to. Do NOT claim "Next Up is empty";
|
|
142
|
+
// surface the real reason so consumers can distinguish an outage from an
|
|
143
|
+
// intentionally empty board.
|
|
144
|
+
console.log(JSON.stringify({
|
|
145
|
+
ok: true,
|
|
146
|
+
message: `Board configured but unavailable (${membership.reason}); nothing to run`,
|
|
147
|
+
boardConfigured: true,
|
|
148
|
+
reason: membership.reason ?? null,
|
|
149
|
+
results: [],
|
|
150
|
+
}));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (membership.emptiness === "queue_empty") {
|
|
83
155
|
console.log(JSON.stringify({ ok: true, message: "Queue is empty", results: [] }));
|
|
84
156
|
return;
|
|
85
157
|
}
|
|
@@ -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 add --repo <owner/name> --project <number|id> --item <number>
|
|
6
7
|
|
|
@@ -23,52 +24,68 @@ Exit codes:
|
|
|
23
24
|
3 — project, field, column, or issue/PR 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
|
+
status: { 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 Object.assign(new Error(`Unexpected argument: ${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
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
|
-
|
|
62
|
-
|
|
56
|
+
if (token.kind !== "option") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
switch (token.name) {
|
|
60
|
+
case "help":
|
|
61
|
+
if (token.value !== undefined) {
|
|
62
|
+
throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
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
|
+
const raw = requireValue(token, "--item requires a value (number)");
|
|
74
|
+
const val = Number(raw);
|
|
75
|
+
if (!Number.isInteger(val) || val < 1) {
|
|
76
|
+
throw Object.assign(
|
|
77
|
+
new Error(`--item must be a positive integer, got "${raw}"`),
|
|
78
|
+
{ code: "INVALID_ITEM" },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
args.item = val;
|
|
82
|
+
break;
|
|
63
83
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
new Error(`Unexpected argument: ${arg}`),
|
|
70
|
-
{ code: "INVALID_ARGS", usage: USAGE },
|
|
71
|
-
);
|
|
84
|
+
case "status":
|
|
85
|
+
args.status = requireValue(token, "--status requires a value");
|
|
86
|
+
break;
|
|
87
|
+
default:
|
|
88
|
+
throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
72
89
|
}
|
|
73
90
|
}
|
|
74
91
|
return args;
|
|
@@ -499,7 +516,7 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
499
516
|
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
|
|
500
517
|
let args;
|
|
501
518
|
try {
|
|
502
|
-
args =
|
|
519
|
+
args = parseCliArgs(argv);
|
|
503
520
|
} catch (err) {
|
|
504
521
|
stderr.write(`${formatCliError(err)}\n`);
|
|
505
522
|
process.exitCode = 1;
|