dev-loops 0.5.0 → 0.7.1
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 +2 -1
- package/.claude/agents/review.md +2 -1
- package/.claude/commands/loop-auto.md +7 -0
- package/.claude/commands/loop-continue.md +15 -0
- package/.claude/commands/loop-enqueue.md +22 -0
- package/.claude/commands/loop-grill.md +17 -0
- package/.claude/commands/loop-info.md +7 -0
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/loop-start-spike.md +16 -0
- package/.claude/commands/loop-start.md +7 -0
- package/.claude/commands/loop-status.md +6 -0
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
- package/.claude/skills/dev-loop/SKILL.md +19 -10
- package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
- package/.claude/skills/docs/anti-patterns.md +2 -1
- package/.claude/skills/docs/artifact-authority-contract.md +22 -4
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/issue-intake-procedure.md +1 -0
- package/.claude/skills/docs/local-planning-flow.md +1 -1
- package/.claude/skills/docs/local-planning-worked-example.md +1 -1
- package/.claude/skills/docs/merge-preconditions.md +17 -1
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +26 -15
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +125 -0
- package/README.md +29 -10
- package/agents/dev-loop.agent.md +11 -3
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +1 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +1 -1
- package/agents/review.agent.md +3 -2
- package/cli/index.mjs +35 -40
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +16 -1
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +16 -13
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +10 -8
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +174 -0
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
- package/scripts/github/detect-linked-issue-pr.mjs +22 -6
- package/scripts/github/edit-pr.mjs +259 -0
- package/scripts/github/fetch-ci-logs.mjs +208 -0
- package/scripts/github/list-issues.mjs +184 -0
- package/scripts/github/manage-sub-issues.mjs +46 -10
- package/scripts/github/offer-human-handoff.mjs +8 -5
- package/scripts/github/post-gate-findings.mjs +14 -2
- package/scripts/github/probe-ci-status.mjs +8 -2
- package/scripts/github/probe-copilot-review.mjs +21 -14
- package/scripts/github/ready-for-review.mjs +26 -8
- package/scripts/github/reconcile-draft-gate.mjs +7 -3
- package/scripts/github/reply-resolve-review-thread.mjs +16 -5
- package/scripts/github/reply-resolve-review-threads.mjs +69 -7
- package/scripts/github/request-copilot-review.mjs +8 -2
- package/scripts/github/resolve-handoff-candidates.mjs +7 -3
- package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
- package/scripts/github/stage-reviewer-draft.mjs +10 -2
- package/scripts/github/tick-verified-checkboxes.mjs +202 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
- package/scripts/github/verify-fresh-review-context.mjs +150 -31
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/write-gate-context.mjs +248 -61
- package/scripts/github/write-gate-findings-log.mjs +75 -1
- package/scripts/lib/jq-output.mjs +18 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/_post-convergence-change.mjs +211 -0
- package/scripts/loop/_pr-runner-coordination.mjs +70 -0
- package/scripts/loop/_repo-root-resolver.mjs +47 -0
- package/scripts/loop/build-handoff-envelope.mjs +14 -4
- package/scripts/loop/check-retro-tooling.mjs +14 -3
- package/scripts/loop/checkpoint-contract.mjs +7 -4
- package/scripts/loop/cleanup-worktree.mjs +12 -3
- package/scripts/loop/conductor-monitor.mjs +12 -18
- package/scripts/loop/copilot-pr-handoff.mjs +135 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +36 -7
- package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
- package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
- package/scripts/loop/detect-internal-only-pr.mjs +8 -2
- package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +179 -66
- package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
- package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
- package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
- package/scripts/loop/ensure-worktree.mjs +8 -3
- package/scripts/loop/info.mjs +12 -4
- package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
- package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
- package/scripts/loop/inspect-run-viewer.mjs +67 -4
- package/scripts/loop/inspect-run.mjs +8 -2
- package/scripts/loop/outer-loop.mjs +8 -4
- package/scripts/loop/pr-runner-coordination.mjs +2 -9
- package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
- package/scripts/loop/pre-flight-gate.mjs +9 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
- package/scripts/loop/provision-worktree.mjs +74 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
- package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
- package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
- package/scripts/loop/run-conductor-cycle.mjs +13 -2
- package/scripts/loop/run-queue.mjs +18 -9
- package/scripts/loop/run-refinement-audit.mjs +8 -9
- package/scripts/loop/run-watch-cycle.mjs +2 -9
- package/scripts/loop/sanctioned-commands.mjs +104 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +207 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
- package/scripts/pages/build-site.mjs +59 -8
- package/scripts/pages/build-state-atlas.mjs +441 -0
- package/scripts/projects/_resolve-project.mjs +148 -0
- package/scripts/projects/add-queue-item.mjs +60 -54
- package/scripts/projects/archive-done-items.mjs +49 -84
- package/scripts/projects/ensure-queue-board.mjs +10 -36
- package/scripts/projects/list-queue-items.mjs +116 -65
- package/scripts/projects/move-queue-item.mjs +28 -49
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +41 -47
- package/scripts/projects/resolve-active-board-item.mjs +255 -0
- package/scripts/projects/sync-item-status.mjs +15 -10
- package/scripts/refine/_refine-helpers.mjs +21 -5
- package/scripts/refine/exit-spike.mjs +18 -8
- package/scripts/refine/promote-plan.mjs +22 -16
- package/scripts/refine/prose-linkage-detector.mjs +1 -1
- package/scripts/refine/refine-plan-file.mjs +13 -4
- package/scripts/refine/refinement-completeness-checker.mjs +1 -1
- package/scripts/refine/scaffold-spike-file.mjs +179 -0
- package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
- package/scripts/refine/tree-integrity-validator.mjs +1 -1
- package/scripts/refine/validate-plan-file.mjs +1 -1
- package/scripts/refine/validate-spike-file.mjs +1 -1
- package/scripts/refine/verify.mjs +11 -6
- package/scripts/release/assert-core-dependency-version.mjs +123 -0
- package/scripts/release/extract-changelog-section.mjs +125 -0
- package/skills/copilot-pr-followup/SKILL.md +24 -12
- package/skills/dev-loop/SKILL.md +22 -6
- package/skills/docs/acceptance-criteria-verification.md +6 -2
- package/skills/docs/anti-patterns.md +2 -1
- package/skills/docs/artifact-authority-contract.md +22 -4
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/issue-intake-procedure.md +1 -0
- package/skills/docs/local-planning-flow.md +1 -1
- package/skills/docs/local-planning-worked-example.md +1 -1
- package/skills/docs/merge-preconditions.md +17 -1
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/public-dev-loop-contract.md +1 -1
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/skills/docs/ui-e2e-scoping-step.md +134 -0
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +26 -15
- package/skills/loop-grill/SKILL.md +165 -0
- package/scripts/loop/conductor.mjs +0 -233
- package/scripts/loop/detect-stale-runner.mjs +0 -265
- package/scripts/loop/pre-push-main-guard.mjs +0 -117
|
@@ -1,10 +1,12 @@
|
|
|
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 { resolveProjectSelector, findProject, applyDevloopsBoard } from "./_resolve-project.mjs";
|
|
4
5
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
6
7
|
|
|
7
|
-
const USAGE = `Usage: dev-loops queue list --repo <owner/name> --project <number|id> [--column <name>] [--limit <n>]
|
|
8
|
+
const USAGE = `Usage: dev-loops queue list --repo <owner/name> [--project <number|id>] [--column <name>] [--limit <n>]
|
|
9
|
+
dev-loops queue list --repo <owner/name> [--project <number|id>] --summary [--done-limit <n>]
|
|
8
10
|
(dev-loops project list … is a back-compat alias)
|
|
9
11
|
|
|
10
12
|
List GitHub Projects V2 items filtered by Status column, ordered by position
|
|
@@ -12,13 +14,29 @@ ascending. Returns machine-readable JSON.
|
|
|
12
14
|
|
|
13
15
|
Options:
|
|
14
16
|
--repo <owner/name> Required. Repository to scope the project search.
|
|
15
|
-
--project <number|id>
|
|
17
|
+
--project <number|id> Project number (integer) or node ID. When omitted,
|
|
18
|
+
resolved from .devloops queue.projectNumber /
|
|
19
|
+
queue.boardTitle.
|
|
16
20
|
--column <name> Filter items by Status column value (e.g. "Next Up").
|
|
17
|
-
--limit <n> Return at most <n> items.
|
|
21
|
+
--limit <n> Return at most <n> items (flat mode only).
|
|
22
|
+
--summary Whole-board digest grouped by Status column, in board
|
|
23
|
+
column order. Emits { ok, groups: { <status>: { count, items } } }.
|
|
24
|
+
--group-by status Alias for --summary. Only "status" is supported.
|
|
25
|
+
--done-limit <n> With --summary: cap the "Done" group's items array to
|
|
26
|
+
<n> (or the last/terminal board column if no column is
|
|
27
|
+
named "Done"). Count stays the true total; use 0 for
|
|
28
|
+
counts only.
|
|
18
29
|
--help, -h Show this help.
|
|
19
30
|
|
|
31
|
+
Grouping / aggregation is done via --summary (this mode). Do NOT pipe flat
|
|
32
|
+
output through inline parsers (e.g. \`| python3\`) or reduce/group_by jq filters
|
|
33
|
+
to build a per-status digest — the summary mode is the sanctioned one-call path.
|
|
34
|
+
|
|
35
|
+
--summary is mutually exclusive with --column and --limit (both exit 1).
|
|
36
|
+
|
|
20
37
|
Output (stdout):
|
|
21
|
-
|
|
38
|
+
flat: { ok: true, items: [{ issueNumber, prNumber, title, url, itemId, contentId, status }, ...] }
|
|
39
|
+
summary: { ok: true, groups: { "<Status>": { count, items: [ <item>, ... ] }, ... } }
|
|
22
40
|
|
|
23
41
|
${JQ_OUTPUT_USAGE}
|
|
24
42
|
|
|
@@ -47,6 +65,9 @@ function parseCliArgs(argv) {
|
|
|
47
65
|
project: { type: "string" },
|
|
48
66
|
column: { type: "string" },
|
|
49
67
|
limit: { type: "string" },
|
|
68
|
+
summary: { type: "boolean" },
|
|
69
|
+
"group-by": { type: "string" },
|
|
70
|
+
"done-limit": { type: "string" },
|
|
50
71
|
help: { type: "boolean", short: "h" },
|
|
51
72
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
52
73
|
},
|
|
@@ -87,14 +108,33 @@ function parseCliArgs(argv) {
|
|
|
87
108
|
args.limit = val;
|
|
88
109
|
break;
|
|
89
110
|
}
|
|
90
|
-
case "
|
|
91
|
-
|
|
111
|
+
case "summary":
|
|
112
|
+
if (token.value !== undefined) {
|
|
113
|
+
throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
|
|
114
|
+
}
|
|
115
|
+
args.summary = true;
|
|
116
|
+
break;
|
|
117
|
+
case "group-by": {
|
|
118
|
+
const val = requireValue(token, "--group-by requires a value (only \"status\" is supported)");
|
|
119
|
+
if (val !== "status") {
|
|
120
|
+
throw parseError(`--group-by only supports "status", got "${val}"`);
|
|
121
|
+
}
|
|
122
|
+
args.summary = true;
|
|
92
123
|
break;
|
|
93
|
-
|
|
94
|
-
|
|
124
|
+
}
|
|
125
|
+
case "done-limit": {
|
|
126
|
+
const raw = requireValue(token, "--done-limit requires a non-negative integer");
|
|
127
|
+
const val = Number(raw);
|
|
128
|
+
if (!Number.isInteger(val) || val < 0) {
|
|
129
|
+
throw parseError(`--done-limit must be a non-negative integer, got "${raw}"`);
|
|
130
|
+
}
|
|
131
|
+
args.doneLimit = val;
|
|
95
132
|
break;
|
|
96
|
-
|
|
133
|
+
}
|
|
134
|
+
default: {
|
|
135
|
+
if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
|
|
97
136
|
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
137
|
+
}
|
|
98
138
|
}
|
|
99
139
|
}
|
|
100
140
|
return args;
|
|
@@ -103,7 +143,6 @@ function parseCliArgs(argv) {
|
|
|
103
143
|
|
|
104
144
|
const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
105
145
|
const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
|
|
106
|
-
const GLOBAL_NODE_ID_RE = /^[A-Za-z0-9_]+$/;
|
|
107
146
|
|
|
108
147
|
function validateRepo(repo) {
|
|
109
148
|
if (!repo || typeof repo !== "string") {
|
|
@@ -128,31 +167,6 @@ function validateRepo(repo) {
|
|
|
128
167
|
return repo;
|
|
129
168
|
}
|
|
130
169
|
|
|
131
|
-
function parseProjectRef(raw) {
|
|
132
|
-
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
|
|
133
|
-
throw Object.assign(new Error("--project is required"), { code: "INVALID_PROJECT" });
|
|
134
|
-
}
|
|
135
|
-
const trimmed = raw.trim();
|
|
136
|
-
const asNum = Number(trimmed);
|
|
137
|
-
if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
|
|
138
|
-
return { kind: "number", value: asNum };
|
|
139
|
-
}
|
|
140
|
-
// Reject bare "0" — valid node ID character but not a meaningful project reference
|
|
141
|
-
if (trimmed === "0") {
|
|
142
|
-
throw Object.assign(
|
|
143
|
-
new Error(`--project must be a positive integer or a node ID, got "${raw}"`),
|
|
144
|
-
{ code: "INVALID_PROJECT" },
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
if (GLOBAL_NODE_ID_RE.test(trimmed)) {
|
|
148
|
-
return { kind: "id", value: trimmed };
|
|
149
|
-
}
|
|
150
|
-
throw Object.assign(
|
|
151
|
-
new Error(`--project must be a positive integer or a node ID, got "${raw}"`),
|
|
152
|
-
{ code: "INVALID_PROJECT" },
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
170
|
// ── API helpers ──────────────────────────────────────────────────────────
|
|
157
171
|
|
|
158
172
|
async function ghGraphql(query, vars, env, runChild = _runChild) {
|
|
@@ -369,34 +383,39 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
369
383
|
const child = runChild ?? _runChild;
|
|
370
384
|
const repo = validateRepo(args.repo);
|
|
371
385
|
const [owner] = repo.split("/");
|
|
372
|
-
const
|
|
386
|
+
const selector = resolveProjectSelector(args);
|
|
373
387
|
|
|
374
|
-
//
|
|
375
|
-
|
|
388
|
+
// Mutual exclusion: --summary is the whole-board grouped view; --column/--limit
|
|
389
|
+
// are flat-mode knobs. Combining them is ambiguous.
|
|
390
|
+
if (args.summary && args.column) {
|
|
391
|
+
throw Object.assign(
|
|
392
|
+
new Error("--summary and --column are mutually exclusive (--column filters to one status; --summary groups the whole board)"),
|
|
393
|
+
{ code: "INVALID_ARGS" },
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (args.summary && args.limit) {
|
|
397
|
+
throw Object.assign(
|
|
398
|
+
new Error("--summary and --limit are mutually exclusive; use --done-limit to cap the Done group (or terminal column if no Done column exists)"),
|
|
399
|
+
{ code: "INVALID_ARGS" },
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
if (args.doneLimit !== undefined && !args.summary) {
|
|
403
|
+
throw Object.assign(
|
|
404
|
+
new Error("--done-limit only applies with --summary"),
|
|
405
|
+
{ code: "INVALID_ARGS" },
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 1. Resolve owner (user or org).
|
|
410
|
+
// URI refs encode owner+kind directly; skip the API round-trip for owner resolution.
|
|
411
|
+
const projectOwner = selector.projectRef?.kind === "uri" ? selector.projectRef.owner : owner;
|
|
412
|
+
const ownerKind = selector.projectRef?.kind === "uri"
|
|
413
|
+
? selector.projectRef.ownerKind
|
|
414
|
+
: (await resolveOwner(owner, env, child)).kind;
|
|
376
415
|
|
|
377
416
|
// 2. Resolve project
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// Direct ID: use it directly (verify it belongs to owner via projects list)
|
|
381
|
-
const projects = await listAllProjects(owner, ownerKind, env, child);
|
|
382
|
-
project = projects.find((p) => p.id === projectRef.value);
|
|
383
|
-
if (!project) {
|
|
384
|
-
throw Object.assign(
|
|
385
|
-
new Error(`Project with ID "${projectRef.value}" not found under owner "${owner}"`),
|
|
386
|
-
{ code: "PROJECT_NOT_FOUND" },
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
} else {
|
|
390
|
-
// By number
|
|
391
|
-
const projects = await listAllProjects(owner, ownerKind, env, child);
|
|
392
|
-
project = projects.find((p) => p.number === projectRef.value);
|
|
393
|
-
if (!project) {
|
|
394
|
-
throw Object.assign(
|
|
395
|
-
new Error(`Project number ${projectRef.value} not found under owner "${owner}"`),
|
|
396
|
-
{ code: "PROJECT_NOT_FOUND" },
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
417
|
+
const projects = await listAllProjects(projectOwner, ownerKind, env, child);
|
|
418
|
+
const project = findProject(projects, selector, projectOwner);
|
|
400
419
|
|
|
401
420
|
// 3. Resolve Status field and target column
|
|
402
421
|
const fieldNodes = await listAllFields(project.id, env, child);
|
|
@@ -458,7 +477,35 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
458
477
|
});
|
|
459
478
|
}
|
|
460
479
|
|
|
461
|
-
//
|
|
480
|
+
// 5a. Summary mode: group by Status column in board option order.
|
|
481
|
+
if (args.summary) {
|
|
482
|
+
// Object.create(null): board option names are free text, so a column named
|
|
483
|
+
// "__proto__"/"constructor" must be an own key, not touch Object.prototype.
|
|
484
|
+
const groups = Object.create(null);
|
|
485
|
+
for (const option of statusField.options) {
|
|
486
|
+
groups[option.name] = { count: 0, items: [] };
|
|
487
|
+
}
|
|
488
|
+
for (const r of results) {
|
|
489
|
+
// Items with null status belong to no Status option, so they are excluded here — matches --column filtering behavior.
|
|
490
|
+
if (r.status === null) continue;
|
|
491
|
+
const group = groups[r.status];
|
|
492
|
+
if (!group) continue; // status value not among current board options
|
|
493
|
+
group.count += 1;
|
|
494
|
+
group.items.push(r);
|
|
495
|
+
}
|
|
496
|
+
if (args.doneLimit !== undefined) {
|
|
497
|
+
// Cap "Done" per the issue AC; if no column is literally named "Done",
|
|
498
|
+
// fall back to the last board option (conventionally the terminal column)
|
|
499
|
+
// so --done-limit is honest instead of a silent no-op.
|
|
500
|
+
const doneGroup = groups.Done ?? groups[statusField.options.at(-1)?.name];
|
|
501
|
+
if (doneGroup) {
|
|
502
|
+
doneGroup.items = doneGroup.items.slice(0, args.doneLimit);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return { ok: true, groups };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 5b. Flat mode: items are returned in position order from GraphQL. Apply limit.
|
|
462
509
|
const limited = args.limit ? results.slice(0, args.limit) : results;
|
|
463
510
|
|
|
464
511
|
return {
|
|
@@ -469,7 +516,7 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
469
516
|
|
|
470
517
|
// ── CLI entrypoint ──────────────────────────────────────────────────────
|
|
471
518
|
|
|
472
|
-
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
|
|
519
|
+
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd(), runChild } = {}) {
|
|
473
520
|
let args;
|
|
474
521
|
try {
|
|
475
522
|
args = parseCliArgs(argv);
|
|
@@ -482,8 +529,12 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
|
|
|
482
529
|
stdout.write(USAGE);
|
|
483
530
|
return;
|
|
484
531
|
}
|
|
532
|
+
|
|
533
|
+
// Resolve the board from .devloops when --project is absent.
|
|
534
|
+
applyDevloopsBoard(args, cwd);
|
|
535
|
+
|
|
485
536
|
try {
|
|
486
|
-
const result = await main(args, { env });
|
|
537
|
+
const result = await main(args, { env, runChild });
|
|
487
538
|
process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
|
|
488
539
|
} catch (err) {
|
|
489
540
|
stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
|
|
@@ -498,4 +549,4 @@ if (isDirectCliRun(import.meta.url)) {
|
|
|
498
549
|
});
|
|
499
550
|
}
|
|
500
551
|
|
|
501
|
-
export { main };
|
|
552
|
+
export { main, parseCliArgs, runCli };
|
|
@@ -1,20 +1,24 @@
|
|
|
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 { resolveProjectSelector, findProject, applyDevloopsBoard } from "./_resolve-project.mjs";
|
|
4
5
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
6
7
|
|
|
7
|
-
const USAGE = `Usage: dev-loops queue move --repo <owner/name> --project <number|id> --item <number|node-id> --to-column <name>
|
|
8
|
+
const USAGE = `Usage: dev-loops queue move --repo <owner/name> --project <number|id|board-uri> --item <number|node-id> --to-column <name>
|
|
8
9
|
(dev-loops project move … is a back-compat alias)
|
|
9
10
|
|
|
10
11
|
Move a GitHub Projects V2 item between Status columns.
|
|
11
12
|
|
|
12
13
|
Options:
|
|
13
|
-
--repo <owner/name>
|
|
14
|
-
--project <number|id>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
--repo <owner/name> Required. Repository to scope the project search.
|
|
15
|
+
--project <number|id|board-uri> Project number, node ID, or board URI
|
|
16
|
+
(e.g. https://github.com/users/me/projects/3).
|
|
17
|
+
When omitted, resolved from .devloops
|
|
18
|
+
queue.projectNumber / queue.boardTitle.
|
|
19
|
+
--item <number|node-id> Required. Item to move: issue/PR number, or project item node ID.
|
|
20
|
+
--to-column <name> Required. Target Status column (e.g. "Next Up", "In Progress").
|
|
21
|
+
--help, -h Show this help.
|
|
18
22
|
|
|
19
23
|
Output (stdout):
|
|
20
24
|
JSON: { ok: true, item: { itemId, issueNumber, prNumber, previousColumn, newColumn } }
|
|
@@ -80,14 +84,10 @@ function parseCliArgs(argv) {
|
|
|
80
84
|
case "to-column":
|
|
81
85
|
args.toColumn = requireValue(token, "--to-column requires a value");
|
|
82
86
|
break;
|
|
83
|
-
|
|
84
|
-
args
|
|
85
|
-
break;
|
|
86
|
-
case "silent":
|
|
87
|
-
args.silent = true;
|
|
88
|
-
break;
|
|
89
|
-
default:
|
|
87
|
+
default: {
|
|
88
|
+
if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
|
|
90
89
|
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
90
|
+
}
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
return args;
|
|
@@ -118,24 +118,6 @@ function validateRepo(repo) {
|
|
|
118
118
|
return repo;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
function parseProjectRef(raw) {
|
|
122
|
-
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
|
|
123
|
-
throw Object.assign(new Error("--project is required"), { code: "INVALID_PROJECT" });
|
|
124
|
-
}
|
|
125
|
-
const trimmed = raw.trim();
|
|
126
|
-
const asNum = Number(trimmed);
|
|
127
|
-
if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
|
|
128
|
-
return { kind: "number", value: asNum };
|
|
129
|
-
}
|
|
130
|
-
if (trimmed === "0") {
|
|
131
|
-
throw Object.assign(new Error(`--project must be a positive integer or a node ID, got "${raw}"`), { code: "INVALID_PROJECT" });
|
|
132
|
-
}
|
|
133
|
-
if (GLOBAL_NODE_ID_RE.test(trimmed)) {
|
|
134
|
-
return { kind: "id", value: trimmed };
|
|
135
|
-
}
|
|
136
|
-
throw Object.assign(new Error(`--project must be a positive integer or a node ID, got "${raw}"`), { code: "INVALID_PROJECT" });
|
|
137
|
-
}
|
|
138
|
-
|
|
139
121
|
function parseItemRef(raw) {
|
|
140
122
|
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
|
|
141
123
|
throw Object.assign(new Error("--item is required"), { code: "INVALID_ITEM" });
|
|
@@ -390,30 +372,23 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
390
372
|
const child = runChild ?? _runChild;
|
|
391
373
|
const repo = validateRepo(args.repo);
|
|
392
374
|
const [owner, repoName] = repo.split("/");
|
|
393
|
-
const
|
|
375
|
+
const selector = resolveProjectSelector(args);
|
|
394
376
|
const itemRef = parseItemRef(args.item);
|
|
395
377
|
const toColumn = (args.toColumn ?? "").trim();
|
|
396
378
|
if (!toColumn) {
|
|
397
379
|
throw Object.assign(new Error("--to-column is required"), { code: "INVALID_COLUMN" });
|
|
398
380
|
}
|
|
399
381
|
|
|
400
|
-
// 1. Resolve owner
|
|
401
|
-
|
|
382
|
+
// 1. Resolve owner.
|
|
383
|
+
// URI refs encode owner+kind directly; skip the API round-trip for owner resolution.
|
|
384
|
+
const projectOwner = selector.projectRef?.kind === "uri" ? selector.projectRef.owner : owner;
|
|
385
|
+
const ownerKind = selector.projectRef?.kind === "uri"
|
|
386
|
+
? selector.projectRef.ownerKind
|
|
387
|
+
: (await resolveOwner(owner, env, child)).kind;
|
|
402
388
|
|
|
403
389
|
// 2. Resolve project
|
|
404
|
-
const projects = await listAllProjects(
|
|
405
|
-
|
|
406
|
-
if (projectRef.kind === "id") {
|
|
407
|
-
project = projects.find((p) => p.id === projectRef.value);
|
|
408
|
-
} else {
|
|
409
|
-
project = projects.find((p) => p.number === projectRef.value);
|
|
410
|
-
}
|
|
411
|
-
if (!project) {
|
|
412
|
-
throw Object.assign(
|
|
413
|
-
new Error(`Project ${projectRef.kind === "id" ? `"${projectRef.value}"` : `number ${projectRef.value}`} not found under owner "${owner}"`),
|
|
414
|
-
{ code: "PROJECT_NOT_FOUND" },
|
|
415
|
-
);
|
|
416
|
-
}
|
|
390
|
+
const projects = await listAllProjects(projectOwner, ownerKind, env, child);
|
|
391
|
+
const project = findProject(projects, selector, projectOwner);
|
|
417
392
|
|
|
418
393
|
// 3. Resolve Status field and target column
|
|
419
394
|
const fieldNodes = await listAllFields(project.id, env, child);
|
|
@@ -527,7 +502,7 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
527
502
|
|
|
528
503
|
// ── CLI entrypoint ──────────────────────────────────────────────────────
|
|
529
504
|
|
|
530
|
-
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
|
|
505
|
+
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd() } = {}) {
|
|
531
506
|
let args;
|
|
532
507
|
try {
|
|
533
508
|
args = parseCliArgs(argv);
|
|
@@ -540,6 +515,10 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
|
|
|
540
515
|
stdout.write(USAGE);
|
|
541
516
|
return;
|
|
542
517
|
}
|
|
518
|
+
|
|
519
|
+
// Resolve the board from .devloops when --project is absent.
|
|
520
|
+
applyDevloopsBoard(args, cwd);
|
|
521
|
+
|
|
543
522
|
try {
|
|
544
523
|
const result = await main(args, { env });
|
|
545
524
|
process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Reconcile board Status columns from live GitHub state (#1069). For each queue
|
|
3
|
+
// item it derives the target column from live facts (merged PR / closed issue →
|
|
4
|
+
// Done; open ready non-draft PR → In Progress) and moves ONLY the items whose
|
|
5
|
+
// derived column differs from their current Status. Backlog/Next Up ordering is
|
|
6
|
+
// left untouched (items that derive null are skipped). Idempotent: a second run
|
|
7
|
+
// over a converged board performs no moves.
|
|
8
|
+
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
11
|
+
import { applyDevloopsBoard } from "./_resolve-project.mjs";
|
|
12
|
+
import { main as listQueueItems } from "./list-queue-items.mjs";
|
|
13
|
+
import { main as moveQueueItem } from "./move-queue-item.mjs";
|
|
14
|
+
import { detectLinkedIssuePr } from "../github/detect-linked-issue-pr.mjs";
|
|
15
|
+
import { planReconcile, loadStateColumnMap, LOGICAL_COLUMN } from "@dev-loops/core/loop/queue-board-sync";
|
|
16
|
+
import { runChild as _runChild } from "../_cli-primitives.mjs";
|
|
17
|
+
|
|
18
|
+
const USAGE = `Usage: dev-loops queue reconcile --repo <owner/name> [--project <number|id|board-uri>]
|
|
19
|
+
(dev-loops project reconcile … is a back-compat alias)
|
|
20
|
+
|
|
21
|
+
Reconcile board Status columns from live GitHub state. Derives each item's
|
|
22
|
+
target column from live GitHub facts (merged PR or closed issue → Done; an open,
|
|
23
|
+
ready non-draft PR → In Progress) and moves ONLY the items whose derived column
|
|
24
|
+
differs from their current Status. Backlog/Next Up ordering is left untouched.
|
|
25
|
+
Idempotent: a second run over a converged board performs no moves. Best-effort:
|
|
26
|
+
individual per-item failures are recorded but do not fail the run.
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--repo <owner/name> Required. Repository to scope the project search.
|
|
30
|
+
--project <number|id|board-uri>
|
|
31
|
+
Project number (integer), node ID, or board URI. When
|
|
32
|
+
omitted, resolved from .devloops queue.projectNumber /
|
|
33
|
+
queue.boardTitle.
|
|
34
|
+
--help, -h Show this help.
|
|
35
|
+
|
|
36
|
+
Output (stdout):
|
|
37
|
+
JSON: { ok: true, moved, unchanged, reconciled: [{ number, from, to, ok }, ...] }
|
|
38
|
+
|
|
39
|
+
${JQ_OUTPUT_USAGE}
|
|
40
|
+
|
|
41
|
+
Exit codes:
|
|
42
|
+
0 — success (reconcile is best-effort; per-item failures do not fail the run)
|
|
43
|
+
1 — usage or argument error
|
|
44
|
+
2 — GitHub API error / invalid --jq filter
|
|
45
|
+
3 — project, field, or column not found
|
|
46
|
+
`.trim();
|
|
47
|
+
|
|
48
|
+
function parseCliArgs(argv) {
|
|
49
|
+
const parseError = (message) => Object.assign(new Error(message), { usage: USAGE, code: "INVALID_ARGS" });
|
|
50
|
+
const requireValue = (token, message) => {
|
|
51
|
+
const v = token.value;
|
|
52
|
+
if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
|
|
53
|
+
throw parseError(message);
|
|
54
|
+
}
|
|
55
|
+
return v;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const args = {};
|
|
59
|
+
const { tokens } = parseArgs({
|
|
60
|
+
args: [...argv],
|
|
61
|
+
options: {
|
|
62
|
+
repo: { type: "string" },
|
|
63
|
+
project: { type: "string" },
|
|
64
|
+
help: { type: "boolean", short: "h" },
|
|
65
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
66
|
+
},
|
|
67
|
+
allowPositionals: true,
|
|
68
|
+
strict: false,
|
|
69
|
+
tokens: true,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
for (const token of tokens) {
|
|
73
|
+
if (token.kind === "positional") {
|
|
74
|
+
throw parseError(`Unexpected argument: ${token.value}`);
|
|
75
|
+
}
|
|
76
|
+
if (token.kind !== "option") {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
switch (token.name) {
|
|
80
|
+
case "help":
|
|
81
|
+
if (token.value !== undefined) {
|
|
82
|
+
throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
|
|
83
|
+
}
|
|
84
|
+
args.help = true;
|
|
85
|
+
break;
|
|
86
|
+
case "repo":
|
|
87
|
+
args.repo = requireValue(token, "--repo requires a value (owner/name)");
|
|
88
|
+
break;
|
|
89
|
+
case "project":
|
|
90
|
+
args.project = requireValue(token, "--project requires a value (number or node ID)");
|
|
91
|
+
break;
|
|
92
|
+
default: {
|
|
93
|
+
if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
|
|
94
|
+
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return args;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function ghJson(argv, { env, runChild }) {
|
|
102
|
+
const child = runChild ?? _runChild;
|
|
103
|
+
const result = await child("gh", argv, env);
|
|
104
|
+
if (result.code !== 0) {
|
|
105
|
+
const detail = result.stderr?.trim() || `exit code ${result.code}`;
|
|
106
|
+
throw Object.assign(new Error(`gh command failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
107
|
+
}
|
|
108
|
+
return parseJsonText(result.stdout);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Real live-facts gatherer. For each item, resolve GitHub state into the fact
|
|
112
|
+
// shape consumed by deriveReconcileColumn. Keyed by the stable item node id
|
|
113
|
+
// (item.itemId), not the bare number, so a multi-repo board where two items
|
|
114
|
+
// share a number (repo-A PR #5 vs repo-B issue #5) cannot collide. Per-item gh
|
|
115
|
+
// failures are swallowed (best-effort): the item records all-null PR fields →
|
|
116
|
+
// derives null → untouched.
|
|
117
|
+
export async function gatherLiveFacts(items, repo, { env, runChild, doneColumn } = {}) {
|
|
118
|
+
const byItemId = new Map();
|
|
119
|
+
for (const item of items) {
|
|
120
|
+
const number = item.prNumber != null ? item.prNumber : item.issueNumber;
|
|
121
|
+
if (number == null || item.itemId == null) continue;
|
|
122
|
+
// Done is terminal for reconcile; planReconcile treats a missing fact as
|
|
123
|
+
// unchanged. At loop startup (doneColumn set) we skip the gh calls for
|
|
124
|
+
// Done items entirely — terminal + perf. An explicit `dev-loops queue
|
|
125
|
+
// reconcile` run passes no doneColumn, so Done items ARE gathered and a
|
|
126
|
+
// reopened/re-linked artifact can be moved back out of Done (recovery).
|
|
127
|
+
if (doneColumn != null && item.status === doneColumn) continue;
|
|
128
|
+
try {
|
|
129
|
+
if (item.prNumber != null) {
|
|
130
|
+
const pr = await ghJson(["pr", "view", String(item.prNumber), "--repo", repo, "--json", "state,isDraft,mergedAt"], { env, runChild });
|
|
131
|
+
byItemId.set(item.itemId, {
|
|
132
|
+
itemKind: "pr",
|
|
133
|
+
issueState: null,
|
|
134
|
+
prState: pr?.mergedAt ? "MERGED" : String(pr?.state ?? "").toUpperCase(),
|
|
135
|
+
prIsDraft: pr?.isDraft === true,
|
|
136
|
+
});
|
|
137
|
+
} else {
|
|
138
|
+
const issue = await ghJson(["issue", "view", String(item.issueNumber), "--repo", repo, "--json", "state"], { env, runChild });
|
|
139
|
+
const issueState = String(issue?.state ?? "").toUpperCase();
|
|
140
|
+
if (issueState === "CLOSED") {
|
|
141
|
+
byItemId.set(item.itemId, { itemKind: "issue", issueState: "CLOSED", prState: null, prIsDraft: null });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
let prState = null;
|
|
145
|
+
let prIsDraft = null;
|
|
146
|
+
const linkage = await detectLinkedIssuePr({ repo, issue: item.issueNumber }, { env, runChild });
|
|
147
|
+
if (linkage?.hasOpenLinkedPr) {
|
|
148
|
+
const pr = await ghJson(["pr", "view", String(linkage.prNumber), "--repo", repo, "--json", "state,isDraft,mergedAt"], { env, runChild });
|
|
149
|
+
prState = pr?.mergedAt ? "MERGED" : String(pr?.state ?? "").toUpperCase();
|
|
150
|
+
prIsDraft = pr?.isDraft === true;
|
|
151
|
+
}
|
|
152
|
+
byItemId.set(item.itemId, { itemKind: "issue", issueState: "OPEN", prState, prIsDraft });
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Best-effort: record inert facts so the item derives null (untouched).
|
|
156
|
+
byItemId.set(item.itemId, {
|
|
157
|
+
itemKind: item.prNumber != null ? "pr" : "issue",
|
|
158
|
+
issueState: item.prNumber != null ? null : "OPEN",
|
|
159
|
+
prState: null,
|
|
160
|
+
prIsDraft: null,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return byItemId;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function main(args, { env = process.env, runChild, cwd = process.cwd(), listItems, gatherFacts, moveItem, skipTerminalColumn = false } = {}) {
|
|
168
|
+
// Resolve the board from .devloops when --project is absent. Idempotent (only
|
|
169
|
+
// mutates when args.project === undefined), so callers that reach main()
|
|
170
|
+
// directly (e.g. the loop-startup self-heal) still resolve the board.
|
|
171
|
+
applyDevloopsBoard(args, cwd);
|
|
172
|
+
|
|
173
|
+
const list = await (listItems ?? ((a, o) => listQueueItems(a, o)))(
|
|
174
|
+
{ repo: args.repo, project: args.project, projectTitle: args.projectTitle },
|
|
175
|
+
{ env, runChild },
|
|
176
|
+
);
|
|
177
|
+
const items = list.items ?? [];
|
|
178
|
+
|
|
179
|
+
const { columnNames } = loadStateColumnMap(cwd);
|
|
180
|
+
|
|
181
|
+
// Loop startup (skipTerminalColumn: true) skips Done items for speed. An
|
|
182
|
+
// explicit CLI run (default false) leaves doneColumn null so Done items are
|
|
183
|
+
// gathered and a reopened/re-linked artifact can be recovered out of Done.
|
|
184
|
+
const doneColumn = skipTerminalColumn ? columnNames[LOGICAL_COLUMN.DONE] : null;
|
|
185
|
+
|
|
186
|
+
const factsByItemId = await (gatherFacts ?? ((its, repo, o) => gatherLiveFacts(its, repo, o)))(
|
|
187
|
+
items,
|
|
188
|
+
args.repo,
|
|
189
|
+
{ env, runChild, doneColumn },
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const { moves, unchanged } = planReconcile(items, factsByItemId, columnNames);
|
|
193
|
+
|
|
194
|
+
const move = moveItem ?? ((a, o) => moveQueueItem(a, o));
|
|
195
|
+
const reconciled = [];
|
|
196
|
+
let moved = 0;
|
|
197
|
+
for (const m of moves) {
|
|
198
|
+
try {
|
|
199
|
+
// Move by the stable item node id (move-queue-item accepts a node id per
|
|
200
|
+
// its `--item <number|node-id>` contract) so a multi-repo number collision
|
|
201
|
+
// can never target the wrong item.
|
|
202
|
+
await move(
|
|
203
|
+
{ repo: args.repo, project: args.project, projectTitle: args.projectTitle, item: m.itemId, toColumn: m.to },
|
|
204
|
+
{ env, runChild },
|
|
205
|
+
);
|
|
206
|
+
reconciled.push({ number: m.number, from: m.from, to: m.to, ok: true });
|
|
207
|
+
moved += 1;
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// Best-effort: a single failed move does not abort the reconcile loop.
|
|
210
|
+
reconciled.push({ number: m.number, from: m.from, to: m.to, ok: false, error: err?.message ?? "move failed" });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { ok: true, moved, unchanged, reconciled };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function classifyExitCode(err) {
|
|
218
|
+
if (err.code === "INVALID_ARGS" || err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT") return 1;
|
|
219
|
+
if (err.code === "PROJECT_NOT_FOUND" || err.code === "FIELD_NOT_FOUND" || err.code === "COLUMN_NOT_FOUND") return 3;
|
|
220
|
+
return 2;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd(), runChild } = {}) {
|
|
224
|
+
let args;
|
|
225
|
+
try {
|
|
226
|
+
args = parseCliArgs(argv);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
stderr.write(`${formatCliError(err)}\n`);
|
|
229
|
+
process.exitCode = 1;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (args.help) {
|
|
233
|
+
stdout.write(USAGE);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const result = await main(args, { env, runChild, cwd });
|
|
239
|
+
process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
|
|
240
|
+
} catch (err) {
|
|
241
|
+
stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
|
|
242
|
+
process.exitCode = classifyExitCode(err);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
247
|
+
runCli(process.argv.slice(2)).catch((error) => {
|
|
248
|
+
process.stderr.write(JSON.stringify({ ok: false, error: error.message, code: error.code ?? "UNKNOWN" }) + "\n");
|
|
249
|
+
process.exitCode = 2;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export { main, parseCliArgs, runCli };
|