dev-loops 0.2.6 → 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 +2 -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 +60 -0
- package/agents/dev-loop.agent.md +5 -1
- 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 +42 -7
- package/package.json +2 -2
- package/scripts/README.md +6 -5
- package/scripts/_cli-primitives.mjs +2 -0
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/generate-claude-assets.mjs +12 -2
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/docs/validate-links.mjs +20 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/capture-review-threads.mjs +32 -14
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +123 -15
- package/scripts/github/detect-linked-issue-pr.mjs +22 -10
- package/scripts/github/manage-sub-issues.mjs +37 -16
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/probe-copilot-review.mjs +24 -12
- package/scripts/github/ready-for-review.mjs +17 -8
- package/scripts/github/reconcile-draft-gate.mjs +24 -12
- package/scripts/github/reply-resolve-review-threads.mjs +34 -15
- package/scripts/github/request-copilot-review.mjs +97 -19
- package/scripts/github/resolve-tracker-local-spec.mjs +29 -12
- package/scripts/github/stage-reviewer-draft.mjs +32 -14
- package/scripts/github/upsert-checkpoint-verdict.mjs +646 -41
- 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 +42 -21
- package/scripts/loop/build-handoff-envelope.mjs +32 -14
- package/scripts/loop/conductor-monitor.mjs +25 -9
- package/scripts/loop/conductor.mjs +31 -12
- package/scripts/loop/copilot-pr-handoff.mjs +31 -14
- package/scripts/loop/debt-remediate.mjs +28 -11
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-copilot-loop-state.mjs +29 -12
- package/scripts/loop/detect-copilot-session-activity.mjs +29 -12
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +26 -10
- package/scripts/loop/detect-internal-only-pr.mjs +31 -13
- package/scripts/loop/detect-issue-refinement-artifact.mjs +29 -12
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +56 -28
- package/scripts/loop/detect-reviewer-loop-state.mjs +35 -16
- package/scripts/loop/detect-stale-runner.mjs +32 -14
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/detect-tracker-pr-state.mjs +23 -8
- package/scripts/loop/info.mjs +28 -10
- package/scripts/loop/inspect-run-viewer/cli.mjs +44 -21
- package/scripts/loop/inspect-run.mjs +35 -16
- package/scripts/loop/outer-loop.mjs +35 -16
- package/scripts/loop/pr-runner-coordination.mjs +31 -12
- package/scripts/loop/pre-commit-branch-guard.mjs +26 -9
- package/scripts/loop/pre-flight-gate.mjs +25 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +24 -8
- package/scripts/loop/pre-push-main-guard.mjs +19 -5
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +23 -7
- package/scripts/loop/resolve-dev-loop-startup.mjs +29 -12
- package/scripts/loop/run-conductor-cycle.mjs +23 -8
- package/scripts/loop/run-queue.mjs +87 -15
- package/scripts/loop/run-refinement-audit.mjs +44 -22
- package/scripts/loop/run-watch-cycle.mjs +28 -12
- package/scripts/loop/steer-loop.mjs +122 -62
- package/scripts/loop/watch-initial-copilot-pr.mjs +28 -12
- package/scripts/projects/add-queue-item.mjs +60 -43
- package/scripts/projects/archive-done-items.mjs +506 -0
- 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 +384 -108
- package/scripts/projects/sync-item-status.mjs +198 -0
- package/scripts/refine/_refine-helpers.mjs +21 -9
- package/scripts/refine/verify.mjs +31 -13
- package/skills/copilot-pr-followup/SKILL.md +57 -3
- package/skills/dev-loop/SKILL.md +9 -5
- 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,21 +1,37 @@
|
|
|
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
|
-
const USAGE = `Usage:
|
|
6
|
+
const USAGE = `Usage:
|
|
7
|
+
dev-loops project reorder --repo <owner/name> --project <number|id> --item <number|node-id> [--after <number|node-id>]
|
|
8
|
+
dev-loops project reorder move-to-top <ref> --repo <owner/name> --project <number|id>
|
|
9
|
+
dev-loops project reorder move-after <ref> <after-ref> --repo <owner/name> --project <number|id>
|
|
10
|
+
dev-loops project reorder order <ref1> <ref2> ... --repo <owner/name> --project <number|id>
|
|
6
11
|
|
|
7
|
-
Reorder
|
|
8
|
-
|
|
12
|
+
Reorder GitHub Projects V2 items by board position via updateProjectV2ItemPosition.
|
|
13
|
+
|
|
14
|
+
Forms:
|
|
15
|
+
(no subcommand) Flag form. Moves --item to top, or after --after when provided.
|
|
16
|
+
move-to-top <ref> Move <ref> to the first position in its current Status column.
|
|
17
|
+
move-after <ref> <after-ref>
|
|
18
|
+
Move <ref> immediately after <after-ref>.
|
|
19
|
+
order <ref1> ... Set explicit ordering: ref1 first, ref2 after ref1, and so on.
|
|
20
|
+
|
|
21
|
+
A <ref> is an issue/PR number OR a project item node ID. Works for both issues and PRs.
|
|
9
22
|
|
|
10
23
|
Options:
|
|
11
24
|
--repo <owner/name> Required. Repository to scope the project search.
|
|
12
25
|
--project <number|id> Required. Project number (integer) or node ID.
|
|
13
|
-
--item <number|node-id>
|
|
14
|
-
--after <number|node-id>
|
|
26
|
+
--item <number|node-id> Flag form: item to reorder.
|
|
27
|
+
--after <number|node-id> Flag form: position after this item. When omitted, move to top.
|
|
28
|
+
--dry-run Print the intended GraphQL mutation(s) without executing.
|
|
15
29
|
--help, -h Show this help.
|
|
16
30
|
|
|
17
31
|
Output (stdout):
|
|
18
|
-
JSON: { ok
|
|
32
|
+
JSON. Move/move-to-top: { ok, item, after_ref|null, before, after }.
|
|
33
|
+
order: { ok, moves: [...], before, after }.
|
|
34
|
+
dry-run: { ok, dryRun: true, mutations: [{ query, variables }], before }.
|
|
19
35
|
|
|
20
36
|
Exit codes:
|
|
21
37
|
0 — success
|
|
@@ -24,47 +40,80 @@ Exit codes:
|
|
|
24
40
|
3 — project, item, or after-item not found
|
|
25
41
|
`.trim();
|
|
26
42
|
|
|
27
|
-
const
|
|
43
|
+
const SUBCOMMANDS = new Set(["move-to-top", "move-after", "order"]);
|
|
28
44
|
|
|
29
|
-
function
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
throw
|
|
35
|
-
new Error(`Unknown flag: ${arg}`),
|
|
36
|
-
{ code: "INVALID_ARGS", usage: USAGE },
|
|
37
|
-
);
|
|
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);
|
|
38
51
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
52
|
+
return v;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const args = { _positional: [] };
|
|
56
|
+
let rest = argv;
|
|
57
|
+
|
|
58
|
+
if (argv.length > 0 && SUBCOMMANDS.has(argv[0])) {
|
|
59
|
+
args._subcommand = argv[0];
|
|
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
|
+
});
|
|
77
|
+
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
if (token.kind === "positional") {
|
|
80
|
+
args._positional.push(token.value);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (token.kind !== "option") {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
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}`);
|
|
63
113
|
}
|
|
64
114
|
}
|
|
65
115
|
return args;
|
|
66
116
|
}
|
|
67
|
-
|
|
68
117
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
69
118
|
|
|
70
119
|
const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
@@ -296,6 +345,94 @@ async function listAllProjects(login, kind, env, runChild) {
|
|
|
296
345
|
return projects;
|
|
297
346
|
}
|
|
298
347
|
|
|
348
|
+
// ── Fetch all project items (paginated, position order) ────────────────
|
|
349
|
+
|
|
350
|
+
async function fetchAllItems(projectId, env, runChild) {
|
|
351
|
+
const allItems = [];
|
|
352
|
+
let after = null;
|
|
353
|
+
while (true) {
|
|
354
|
+
const vars = { projectId };
|
|
355
|
+
if (after) vars.after = after;
|
|
356
|
+
const itemsPayload = await ghGraphql(GET_PROJECT_ITEMS_BY_CONTENT, vars, env, runChild);
|
|
357
|
+
const connection = itemsPayload?.data?.node?.items;
|
|
358
|
+
const nodes = connection?.nodes ?? [];
|
|
359
|
+
allItems.push(...nodes);
|
|
360
|
+
const pageInfo = connection?.pageInfo ?? {};
|
|
361
|
+
if (!pageInfo.hasNextPage) break;
|
|
362
|
+
if (!pageInfo.endCursor) {
|
|
363
|
+
throw Object.assign(
|
|
364
|
+
new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
|
|
365
|
+
{ code: "GH_API_ERROR" },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
after = pageInfo.endCursor;
|
|
369
|
+
}
|
|
370
|
+
return allItems;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function statusOf(node) {
|
|
374
|
+
const fvs = node?.fieldValues?.nodes ?? [];
|
|
375
|
+
for (const fv of fvs) {
|
|
376
|
+
if (fv && fv.field && fv.field.name === "Status") return fv.name;
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function describeItem(node) {
|
|
382
|
+
return {
|
|
383
|
+
itemId: node.id,
|
|
384
|
+
issueNumber: node.content?.__typename === "Issue" ? node.content.number : null,
|
|
385
|
+
prNumber: node.content?.__typename === "PullRequest" ? node.content.number : null,
|
|
386
|
+
status: statusOf(node),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Build a diff-friendly snapshot of items in `repo` (optionally a single Status
|
|
391
|
+
// column), in board position order, from a pre-fetched item list.
|
|
392
|
+
function snapshotFromItems(items, repo, statusFilter) {
|
|
393
|
+
return items
|
|
394
|
+
.filter((it) => it.content && it.content.repository?.nameWithOwner === repo)
|
|
395
|
+
.filter((it) => (statusFilter == null ? true : statusOf(it) === statusFilter))
|
|
396
|
+
.map(describeItem);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function snapshotOrder(projectId, repo, statusFilter, env, runChild) {
|
|
400
|
+
const items = await fetchAllItems(projectId, env, runChild);
|
|
401
|
+
return snapshotFromItems(items, repo, statusFilter);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Resolve a ref (number or item node ID) against a pre-fetched item list,
|
|
405
|
+
// enforcing the same repo scope for BOTH number and id refs so a cross-project
|
|
406
|
+
// ref fails closed with ITEM_NOT_FOUND.
|
|
407
|
+
function resolveFromItems(items, itemRef, repo) {
|
|
408
|
+
let match;
|
|
409
|
+
if (itemRef.kind === "id") {
|
|
410
|
+
match = items.find(
|
|
411
|
+
(it) => it.id === itemRef.value && it.content?.repository?.nameWithOwner === repo,
|
|
412
|
+
);
|
|
413
|
+
if (!match) {
|
|
414
|
+
throw Object.assign(
|
|
415
|
+
new Error(`Item "${itemRef.value}" not found in project for repo "${repo}"`),
|
|
416
|
+
{ code: "ITEM_NOT_FOUND" },
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
match = items.find(
|
|
421
|
+
(it) =>
|
|
422
|
+
it.content &&
|
|
423
|
+
it.content.repository?.nameWithOwner === repo &&
|
|
424
|
+
it.content.number === itemRef.value,
|
|
425
|
+
);
|
|
426
|
+
if (!match) {
|
|
427
|
+
throw Object.assign(
|
|
428
|
+
new Error(`Item #${itemRef.value} not found in project for repo "${repo}"`),
|
|
429
|
+
{ code: "ITEM_NOT_FOUND" },
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return describeItem(match);
|
|
434
|
+
}
|
|
435
|
+
|
|
299
436
|
// ── Resolve an item in a project by reference (number or node ID) ──────
|
|
300
437
|
|
|
301
438
|
async function resolveProjectItem(projectId, itemRef, owner, repoName, repo, env, runChild) {
|
|
@@ -335,25 +472,7 @@ async function resolveProjectItem(projectId, itemRef, owner, repoName, repo, env
|
|
|
335
472
|
} else {
|
|
336
473
|
// Look up by issue/PR number in the project (paginated)
|
|
337
474
|
const targetNumber = itemRef.value;
|
|
338
|
-
|
|
339
|
-
let after = null;
|
|
340
|
-
while (true) {
|
|
341
|
-
const vars = { projectId };
|
|
342
|
-
if (after) vars.after = after;
|
|
343
|
-
const itemsPayload = await ghGraphql(GET_PROJECT_ITEMS_BY_CONTENT, vars, env, runChild);
|
|
344
|
-
const connection = itemsPayload?.data?.node?.items;
|
|
345
|
-
const nodes = connection?.nodes ?? [];
|
|
346
|
-
allItems.push(...nodes);
|
|
347
|
-
const pageInfo = connection?.pageInfo ?? {};
|
|
348
|
-
if (!pageInfo.hasNextPage) break;
|
|
349
|
-
if (!pageInfo.endCursor) {
|
|
350
|
-
throw Object.assign(
|
|
351
|
-
new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
|
|
352
|
-
{ code: "GH_API_ERROR" },
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
after = pageInfo.endCursor;
|
|
356
|
-
}
|
|
475
|
+
const allItems = await fetchAllItems(projectId, env, runChild);
|
|
357
476
|
|
|
358
477
|
// Filter by matching repo AND number exactly
|
|
359
478
|
const matchingItems = allItems.filter((it) => {
|
|
@@ -400,70 +519,68 @@ function classifyExitCode(err) {
|
|
|
400
519
|
return 2;
|
|
401
520
|
}
|
|
402
521
|
|
|
403
|
-
// ──
|
|
404
|
-
|
|
405
|
-
async function main(args, { env = process.env, runChild } = {}) {
|
|
406
|
-
const child = runChild ?? _runChild;
|
|
407
|
-
const repo = validateRepo(args.repo);
|
|
408
|
-
const [owner, repoName] = repo.split("/");
|
|
409
|
-
const projectRef = parseProjectRef(args.project);
|
|
410
|
-
const itemRef = parseItemRef(args.item);
|
|
411
|
-
|
|
412
|
-
// --after is optional
|
|
413
|
-
let afterRef = null;
|
|
414
|
-
if (args.after !== undefined) {
|
|
415
|
-
afterRef = parseItemRef(args.after);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// 1. Resolve owner
|
|
419
|
-
const { id: ownerId, kind: ownerKind } = await resolveOwner(owner, env, child);
|
|
522
|
+
// ── Resolve owner + project (shared) ──────────────────────────────────────
|
|
420
523
|
|
|
421
|
-
|
|
524
|
+
async function resolveProject(owner, projectRef, env, child) {
|
|
525
|
+
const { kind: ownerKind } = await resolveOwner(owner, env, child);
|
|
422
526
|
const projects = await listAllProjects(owner, ownerKind, env, child);
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
} else {
|
|
427
|
-
project = projects.find((p) => p.number === projectRef.value);
|
|
428
|
-
}
|
|
527
|
+
const project = projectRef.kind === "id"
|
|
528
|
+
? projects.find((p) => p.id === projectRef.value)
|
|
529
|
+
: projects.find((p) => p.number === projectRef.value);
|
|
429
530
|
if (!project) {
|
|
430
531
|
throw Object.assign(
|
|
431
532
|
new Error(`Project ${projectRef.kind === "id" ? `"${projectRef.value}"` : `number ${projectRef.value}`} not found under owner "${owner}"`),
|
|
432
533
|
{ code: "PROJECT_NOT_FOUND" },
|
|
433
534
|
);
|
|
434
535
|
}
|
|
536
|
+
return project;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function executePosition(projectId, itemId, afterId) {
|
|
540
|
+
return {
|
|
541
|
+
query: UPDATE_ITEM_POSITION,
|
|
542
|
+
variables: afterId
|
|
543
|
+
? { projectId, itemId, afterId }
|
|
544
|
+
: { projectId, itemId },
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── Legacy flag form: --item [--after] ────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
async function mainFlagForm(args, { env, child, repo, owner, repoName, project }) {
|
|
551
|
+
const itemRef = parseItemRef(args.item);
|
|
552
|
+
let afterRef = null;
|
|
553
|
+
if (args.after !== undefined) afterRef = parseItemRef(args.after);
|
|
435
554
|
|
|
436
|
-
// 3. Resolve the item to move
|
|
437
555
|
const item = await resolveProjectItem(project.id, itemRef, owner, repoName, repo, env, child);
|
|
438
556
|
|
|
439
|
-
// 4. Resolve the after-item (if provided)
|
|
440
557
|
let afterItem = null;
|
|
441
558
|
if (afterRef) {
|
|
442
559
|
afterItem = await resolveProjectItem(project.id, afterRef, owner, repoName, repo, env, child);
|
|
443
|
-
|
|
444
|
-
// Fail closed: cannot reorder after itself
|
|
445
560
|
if (afterItem.itemId === item.itemId) {
|
|
446
|
-
throw Object.assign(
|
|
447
|
-
new Error("Cannot reorder an item after itself"),
|
|
448
|
-
{ code: "INVALID_AFTER" },
|
|
449
|
-
);
|
|
561
|
+
throw Object.assign(new Error("Cannot reorder an item after itself"), { code: "INVALID_AFTER" });
|
|
450
562
|
}
|
|
451
563
|
}
|
|
452
564
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
565
|
+
const mutation = executePosition(project.id, item.itemId, afterItem ? afterItem.itemId : null);
|
|
566
|
+
|
|
567
|
+
if (args.dryRun) {
|
|
568
|
+
// Include the before snapshot for parity with the subcommand dry-run form.
|
|
569
|
+
const before = await snapshotOrder(project.id, repo, item.status ?? null, env, child);
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
dryRun: true,
|
|
573
|
+
mutations: [mutation],
|
|
574
|
+
before,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
461
577
|
|
|
578
|
+
const mutationPayload = await ghGraphql(mutation.query, mutation.variables, env, child);
|
|
462
579
|
if (!mutationPayload?.data?.updateProjectV2ItemPosition) {
|
|
463
580
|
throw Object.assign(new Error("Failed to reorder item"), { code: "MUTATION_FAILED" });
|
|
464
581
|
}
|
|
465
582
|
|
|
466
|
-
|
|
583
|
+
return {
|
|
467
584
|
ok: true,
|
|
468
585
|
item: {
|
|
469
586
|
itemId: item.itemId,
|
|
@@ -473,15 +590,174 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
473
590
|
position: afterItem ? "after" : "top",
|
|
474
591
|
},
|
|
475
592
|
after: afterItem
|
|
476
|
-
? {
|
|
477
|
-
itemId: afterItem.itemId,
|
|
478
|
-
issueNumber: afterItem.issueNumber,
|
|
479
|
-
prNumber: afterItem.prNumber,
|
|
480
|
-
}
|
|
593
|
+
? { itemId: afterItem.itemId, issueNumber: afterItem.issueNumber, prNumber: afterItem.prNumber }
|
|
481
594
|
: null,
|
|
482
595
|
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ── Subcommand forms: move-to-top / move-after / order ────────────────────
|
|
483
599
|
|
|
484
|
-
|
|
600
|
+
function requirePositionals(subcommand, positional) {
|
|
601
|
+
if (subcommand === "move-to-top") {
|
|
602
|
+
if (positional.length !== 1) {
|
|
603
|
+
throw Object.assign(new Error("move-to-top requires exactly one <ref>"), { code: "INVALID_ARGS", usage: USAGE });
|
|
604
|
+
}
|
|
605
|
+
} else if (subcommand === "move-after") {
|
|
606
|
+
if (positional.length !== 2) {
|
|
607
|
+
throw Object.assign(new Error("move-after requires <ref> <after-ref>"), { code: "INVALID_ARGS", usage: USAGE });
|
|
608
|
+
}
|
|
609
|
+
} else if (subcommand === "order") {
|
|
610
|
+
if (positional.length < 2) {
|
|
611
|
+
throw Object.assign(new Error("order requires at least two <ref> values"), { code: "INVALID_ARGS", usage: USAGE });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function mainSubcommand(args, { env, child, repo, project }) {
|
|
617
|
+
const subcommand = args._subcommand;
|
|
618
|
+
const positional = args._positional ?? [];
|
|
619
|
+
requirePositionals(subcommand, positional);
|
|
620
|
+
|
|
621
|
+
// Fetch the board item list ONCE, then resolve every ref (number or id) from
|
|
622
|
+
// that single list — avoids N full-board scans for N refs and enforces the
|
|
623
|
+
// same repo scope for both ref kinds (cross-project refs fail closed).
|
|
624
|
+
const items = await fetchAllItems(project.id, env, child);
|
|
625
|
+
|
|
626
|
+
// Resolve all referenced items up-front (fail closed before any mutation).
|
|
627
|
+
const refs = positional.map((p) => parseItemRef(p));
|
|
628
|
+
const resolved = refs.map((ref) => resolveFromItems(items, ref, repo));
|
|
629
|
+
|
|
630
|
+
// Reordering positions within a single Status column. The before/after snapshot is scoped
|
|
631
|
+
// to one column, and a cross-column move plan is misleading/invalid — so fail closed unless
|
|
632
|
+
// every resolved ref shares the first ref's Status (multi-ref subcommands only).
|
|
633
|
+
if (resolved.length > 1) {
|
|
634
|
+
const primaryStatus = resolved[0].status ?? null;
|
|
635
|
+
const offender = resolved.find((it) => (it.status ?? null) !== primaryStatus);
|
|
636
|
+
if (offender) {
|
|
637
|
+
throw Object.assign(
|
|
638
|
+
new Error(
|
|
639
|
+
`All reordered items must be in the same Status column as the first item (${primaryStatus ?? "(none)"}); ` +
|
|
640
|
+
`${offender.itemId} is in ${offender.status ?? "(none)"}.`,
|
|
641
|
+
),
|
|
642
|
+
{ code: "MIXED_STATUS" },
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Build the ordered list of position moves, each { item, afterItem|null }.
|
|
648
|
+
let plan;
|
|
649
|
+
if (subcommand === "move-to-top") {
|
|
650
|
+
plan = [{ item: resolved[0], afterItem: null }];
|
|
651
|
+
} else if (subcommand === "move-after") {
|
|
652
|
+
const [item, afterItem] = resolved;
|
|
653
|
+
if (item.itemId === afterItem.itemId) {
|
|
654
|
+
throw Object.assign(new Error("Cannot reorder an item after itself"), { code: "INVALID_AFTER" });
|
|
655
|
+
}
|
|
656
|
+
plan = [{ item, afterItem }];
|
|
657
|
+
} else {
|
|
658
|
+
// order: ref1 to top, then each subsequent ref after its predecessor.
|
|
659
|
+
plan = resolved.map((item, idx) => ({
|
|
660
|
+
item,
|
|
661
|
+
afterItem: idx === 0 ? null : resolved[idx - 1],
|
|
662
|
+
}));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const mutations = plan.map((m) => executePosition(project.id, m.item.itemId, m.afterItem ? m.afterItem.itemId : null));
|
|
666
|
+
|
|
667
|
+
// Status column for the diff snapshot: use the primary moved item's status.
|
|
668
|
+
// Reuse the already-fetched list for the before-snapshot (no extra fetch).
|
|
669
|
+
const statusFilter = resolved[0].status ?? null;
|
|
670
|
+
const before = snapshotFromItems(items, repo, statusFilter);
|
|
671
|
+
|
|
672
|
+
if (args.dryRun) {
|
|
673
|
+
return { ok: true, dryRun: true, mutations, before };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// NOTE: `order` applies N sequential position mutations and is NOT atomic. A
|
|
677
|
+
// mid-sequence failure leaves the board partially reordered; re-running the
|
|
678
|
+
// same `order` command is idempotent and is the supported recovery path. The
|
|
679
|
+
// thrown error reports how many moves were applied before failing.
|
|
680
|
+
for (let i = 0; i < mutations.length; i++) {
|
|
681
|
+
const mutation = mutations[i];
|
|
682
|
+
let payload;
|
|
683
|
+
try {
|
|
684
|
+
payload = await ghGraphql(mutation.query, mutation.variables, env, child);
|
|
685
|
+
} catch (err) {
|
|
686
|
+
if (subcommand === "order") {
|
|
687
|
+
err.message = `${err.message} (order partially applied: ${i} of ${mutations.length} moves completed; re-run the same order command to recover)`;
|
|
688
|
+
err.appliedMoves = i;
|
|
689
|
+
err.totalMoves = mutations.length;
|
|
690
|
+
}
|
|
691
|
+
throw err;
|
|
692
|
+
}
|
|
693
|
+
if (!payload?.data?.updateProjectV2ItemPosition) {
|
|
694
|
+
const detail = subcommand === "order"
|
|
695
|
+
? ` (order partially applied: ${i} of ${mutations.length} moves completed; re-run the same order command to recover)`
|
|
696
|
+
: "";
|
|
697
|
+
throw Object.assign(new Error(`Failed to reorder item${detail}`), {
|
|
698
|
+
code: "MUTATION_FAILED",
|
|
699
|
+
...(subcommand === "order" ? { appliedMoves: i, totalMoves: mutations.length } : {}),
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const after = await snapshotOrder(project.id, repo, statusFilter, env, child);
|
|
705
|
+
|
|
706
|
+
const moves = plan.map((m) => ({
|
|
707
|
+
itemId: m.item.itemId,
|
|
708
|
+
issueNumber: m.item.issueNumber,
|
|
709
|
+
prNumber: m.item.prNumber,
|
|
710
|
+
status: m.item.status,
|
|
711
|
+
position: m.afterItem ? "after" : "top",
|
|
712
|
+
afterId: m.afterItem ? m.afterItem.itemId : null,
|
|
713
|
+
}));
|
|
714
|
+
|
|
715
|
+
if (subcommand === "order") {
|
|
716
|
+
return { ok: true, moves, before, after };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// move-to-top / move-after: single move, richer shape.
|
|
720
|
+
const m = plan[0];
|
|
721
|
+
return {
|
|
722
|
+
ok: true,
|
|
723
|
+
item: {
|
|
724
|
+
itemId: m.item.itemId,
|
|
725
|
+
issueNumber: m.item.issueNumber,
|
|
726
|
+
prNumber: m.item.prNumber,
|
|
727
|
+
status: m.item.status,
|
|
728
|
+
position: m.afterItem ? "after" : "top",
|
|
729
|
+
},
|
|
730
|
+
after_ref: m.afterItem
|
|
731
|
+
? { itemId: m.afterItem.itemId, issueNumber: m.afterItem.issueNumber, prNumber: m.afterItem.prNumber }
|
|
732
|
+
: null,
|
|
733
|
+
before,
|
|
734
|
+
after,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ── Main logic ──────────────────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
async function main(args, { env = process.env, runChild } = {}) {
|
|
741
|
+
const child = runChild ?? _runChild;
|
|
742
|
+
const repo = validateRepo(args.repo);
|
|
743
|
+
const [owner, repoName] = repo.split("/");
|
|
744
|
+
const projectRef = parseProjectRef(args.project);
|
|
745
|
+
|
|
746
|
+
// Fail closed: the legacy flag form takes no positional arguments. A stray
|
|
747
|
+
// token (e.g. `reorder 630 --item ...`) must not be silently ignored.
|
|
748
|
+
if (!args._subcommand && (args._positional?.length ?? 0) > 0) {
|
|
749
|
+
throw Object.assign(
|
|
750
|
+
new Error(`Unexpected argument: ${args._positional[0]}`),
|
|
751
|
+
{ code: "INVALID_ARGS", usage: USAGE },
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const project = await resolveProject(owner, projectRef, env, child);
|
|
756
|
+
|
|
757
|
+
if (args._subcommand) {
|
|
758
|
+
return mainSubcommand(args, { env, child, repo, project });
|
|
759
|
+
}
|
|
760
|
+
return mainFlagForm(args, { env, child, repo, owner, repoName, project });
|
|
485
761
|
}
|
|
486
762
|
|
|
487
763
|
// ── CLI entrypoint ──────────────────────────────────────────────────────
|
|
@@ -489,7 +765,7 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
489
765
|
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
|
|
490
766
|
let args;
|
|
491
767
|
try {
|
|
492
|
-
args =
|
|
768
|
+
args = parseCliArgs(argv);
|
|
493
769
|
} catch (err) {
|
|
494
770
|
stderr.write(`${formatCliError(err)}\n`);
|
|
495
771
|
process.exitCode = 1;
|