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
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
6
|
+
import { runChild as _runChild } from "../_cli-primitives.mjs";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
|
|
9
|
+
const USAGE = `Usage: dev-loops project archive-done --repo <owner/name> [--project <number|id>] [--older-than <duration>] [--dry-run]
|
|
10
|
+
|
|
11
|
+
Archive GitHub Projects V2 items whose issue/PR has been closed for at least the
|
|
12
|
+
given duration. Operator-triggered (no webhooks). Uses archiveProjectV2Item.
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--repo <owner/name> Required. Repository to scope the project search.
|
|
16
|
+
--project <number|id> Project number (integer) or node ID. When omitted,
|
|
17
|
+
resolved from .devloops queue.projectNumber /
|
|
18
|
+
queue.boardTitle.
|
|
19
|
+
--older-than <duration> Closed-for threshold. Format: <n><unit> where unit is
|
|
20
|
+
h (hours), d (days), or w (weeks). Default resolves
|
|
21
|
+
from .devloops queue.archiveOlderThanDays, else 7d.
|
|
22
|
+
--dry-run Print the intended archive mutation(s) without executing.
|
|
23
|
+
--help, -h Show this help.
|
|
24
|
+
|
|
25
|
+
Output (stdout):
|
|
26
|
+
JSON: { ok: true, olderThan, scanned, archivable, archived: [{ itemId, issueNumber, prNumber, closedAt }] }
|
|
27
|
+
dry-run: { ok: true, dryRun: true, olderThan, scanned, archivable, mutations: [{ query, variables }] }
|
|
28
|
+
(scanned = all repo board items; archivable = items selected for archival)
|
|
29
|
+
|
|
30
|
+
Exit codes:
|
|
31
|
+
0 — success
|
|
32
|
+
1 — usage or argument error
|
|
33
|
+
2 — GitHub API error
|
|
34
|
+
3 — project not found
|
|
35
|
+
`.trim();
|
|
36
|
+
|
|
37
|
+
function parseCliArgs(argv) {
|
|
38
|
+
const requireValue = (token, message, code) => {
|
|
39
|
+
const v = token.value;
|
|
40
|
+
if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
|
|
41
|
+
throw Object.assign(new Error(message), { code });
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const args = {};
|
|
47
|
+
const { tokens } = parseArgs({
|
|
48
|
+
args: [...argv],
|
|
49
|
+
options: {
|
|
50
|
+
repo: { type: "string" },
|
|
51
|
+
project: { type: "string" },
|
|
52
|
+
"older-than": { type: "string" },
|
|
53
|
+
"dry-run": { type: "boolean" },
|
|
54
|
+
help: { type: "boolean", short: "h" },
|
|
55
|
+
},
|
|
56
|
+
allowPositionals: true,
|
|
57
|
+
strict: false,
|
|
58
|
+
tokens: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
for (const token of tokens) {
|
|
62
|
+
if (token.kind === "positional") {
|
|
63
|
+
throw Object.assign(new Error(`Unexpected argument: ${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
64
|
+
}
|
|
65
|
+
if (token.kind !== "option") {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
switch (token.name) {
|
|
69
|
+
case "help":
|
|
70
|
+
if (token.value !== undefined) {
|
|
71
|
+
throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
72
|
+
}
|
|
73
|
+
args.help = true;
|
|
74
|
+
break;
|
|
75
|
+
case "repo":
|
|
76
|
+
args.repo = requireValue(token, "--repo requires a value (owner/name)", "INVALID_REPO");
|
|
77
|
+
break;
|
|
78
|
+
case "project":
|
|
79
|
+
args.project = requireValue(token, "--project requires a value (number or node ID)", "INVALID_PROJECT");
|
|
80
|
+
break;
|
|
81
|
+
case "older-than":
|
|
82
|
+
args.olderThan = requireValue(token, "--older-than requires a value (e.g. 30d)", "INVALID_DURATION");
|
|
83
|
+
break;
|
|
84
|
+
case "dry-run":
|
|
85
|
+
if (token.value !== undefined) {
|
|
86
|
+
throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
87
|
+
}
|
|
88
|
+
args.dryRun = true;
|
|
89
|
+
break;
|
|
90
|
+
default:
|
|
91
|
+
throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return args;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Validation ───────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
100
|
+
const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
|
|
101
|
+
const GLOBAL_NODE_ID_RE = /^[A-Za-z0-9_]+$/;
|
|
102
|
+
|
|
103
|
+
function validateRepo(repo) {
|
|
104
|
+
if (!repo || typeof repo !== "string") {
|
|
105
|
+
throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO" });
|
|
106
|
+
}
|
|
107
|
+
const slashIdx = repo.indexOf("/");
|
|
108
|
+
if (slashIdx === -1) {
|
|
109
|
+
throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
|
|
110
|
+
}
|
|
111
|
+
const owner = repo.slice(0, slashIdx);
|
|
112
|
+
const name = repo.slice(slashIdx + 1);
|
|
113
|
+
if (!owner || !name || !OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
|
|
114
|
+
throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
|
|
115
|
+
}
|
|
116
|
+
return repo;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseProjectRef(raw) {
|
|
120
|
+
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
|
|
121
|
+
throw Object.assign(new Error("--project is required"), { code: "INVALID_PROJECT" });
|
|
122
|
+
}
|
|
123
|
+
const trimmed = raw.trim();
|
|
124
|
+
const asNum = Number(trimmed);
|
|
125
|
+
if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
|
|
126
|
+
return { kind: "number", value: asNum };
|
|
127
|
+
}
|
|
128
|
+
if (GLOBAL_NODE_ID_RE.test(trimmed) && trimmed !== "0") {
|
|
129
|
+
return { kind: "id", value: trimmed };
|
|
130
|
+
}
|
|
131
|
+
throw Object.assign(new Error(`--project must be a positive integer or a node ID, got "${raw}"`), { code: "INVALID_PROJECT" });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const DURATION_RE = /^(\d+)(h|d|w)$/;
|
|
135
|
+
const UNIT_MS = {
|
|
136
|
+
h: 60 * 60 * 1000,
|
|
137
|
+
d: 24 * 60 * 60 * 1000,
|
|
138
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function parseDuration(raw) {
|
|
142
|
+
if (!raw || typeof raw !== "string") {
|
|
143
|
+
throw Object.assign(new Error(`--older-than must be <n>(h|d|w), got "${raw}"`), { code: "INVALID_DURATION" });
|
|
144
|
+
}
|
|
145
|
+
const m = DURATION_RE.exec(raw.trim());
|
|
146
|
+
if (!m) {
|
|
147
|
+
throw Object.assign(new Error(`--older-than must be <n>(h|d|w), got "${raw}"`), { code: "INVALID_DURATION" });
|
|
148
|
+
}
|
|
149
|
+
const n = Number(m[1]);
|
|
150
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
151
|
+
throw Object.assign(new Error(`--older-than must be a positive amount, got "${raw}"`), { code: "INVALID_DURATION" });
|
|
152
|
+
}
|
|
153
|
+
return n * UNIT_MS[m[2]];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Settings fallback ──────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
// Read .devloops (and extension variants) queue settings, mirroring the
|
|
159
|
+
// resolution used by ensure-queue-board.mjs. Returns { project }, { title },
|
|
160
|
+
// and/or { olderThanDays } when configured; never throws on a missing/bad file.
|
|
161
|
+
function resolveSettings(cwd) {
|
|
162
|
+
const basePath = path.join(cwd, ".devloops");
|
|
163
|
+
const extensions = ["", ".yaml", ".yml", ".json"];
|
|
164
|
+
for (const ext of extensions) {
|
|
165
|
+
try {
|
|
166
|
+
const raw = readFileSync(basePath + ext, "utf-8");
|
|
167
|
+
const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
|
168
|
+
const queue = settings?.queue;
|
|
169
|
+
if (!queue) return null;
|
|
170
|
+
const out = {};
|
|
171
|
+
if (typeof queue.projectNumber === "number" && Number.isInteger(queue.projectNumber) && queue.projectNumber > 0) {
|
|
172
|
+
out.project = queue.projectNumber;
|
|
173
|
+
} else if (typeof queue.boardTitle === "string" && queue.boardTitle.trim().length > 0) {
|
|
174
|
+
out.title = queue.boardTitle.trim();
|
|
175
|
+
}
|
|
176
|
+
if (typeof queue.archiveOlderThanDays === "number" && Number.isInteger(queue.archiveOlderThanDays) && queue.archiveOlderThanDays > 0) {
|
|
177
|
+
out.olderThanDays = queue.archiveOlderThanDays;
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
} catch {
|
|
181
|
+
// extension not present or unparseable — try next
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── API helpers ──────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
async function ghGraphql(query, vars, env, runChild = _runChild) {
|
|
190
|
+
const fieldArgs = [];
|
|
191
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
192
|
+
fieldArgs.push("--field", `${key}=${value}`);
|
|
193
|
+
}
|
|
194
|
+
const result = await runChild("gh", ["api", "graphql", "--field", `query=${query}`, ...fieldArgs], env);
|
|
195
|
+
if (result.code !== 0) {
|
|
196
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
197
|
+
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
198
|
+
}
|
|
199
|
+
const payload = parseJsonText(result.stdout);
|
|
200
|
+
if (payload.errors && payload.errors.length > 0) {
|
|
201
|
+
throw Object.assign(
|
|
202
|
+
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
203
|
+
{ code: "GRAPHQL_ERROR" },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return payload;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── GraphQL fragments ────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
const GET_USER_ID = ["query($login:String!) {", " user(login:$login) { id }", "}"].join("\n");
|
|
212
|
+
const GET_ORG_ID = ["query($login:String!) {", " organization(login:$login) { id }", "}"].join("\n");
|
|
213
|
+
|
|
214
|
+
const LIST_USER_PROJECTS = [
|
|
215
|
+
"query($login:String!, $after:String) {",
|
|
216
|
+
" user(login:$login) {",
|
|
217
|
+
" projectsV2(first:50, after:$after) {",
|
|
218
|
+
" pageInfo { hasNextPage endCursor }",
|
|
219
|
+
" nodes { id number title url }",
|
|
220
|
+
" }",
|
|
221
|
+
" }",
|
|
222
|
+
"}",
|
|
223
|
+
].join("\n");
|
|
224
|
+
|
|
225
|
+
const LIST_ORG_PROJECTS = [
|
|
226
|
+
"query($login:String!, $after:String) {",
|
|
227
|
+
" organization(login:$login) {",
|
|
228
|
+
" projectsV2(first:50, after:$after) {",
|
|
229
|
+
" pageInfo { hasNextPage endCursor }",
|
|
230
|
+
" nodes { id number title url }",
|
|
231
|
+
" }",
|
|
232
|
+
" }",
|
|
233
|
+
"}",
|
|
234
|
+
].join("\n");
|
|
235
|
+
|
|
236
|
+
const GET_PROJECT_ITEMS = [
|
|
237
|
+
"query($projectId:ID!, $after:String) {",
|
|
238
|
+
" node(id:$projectId) {",
|
|
239
|
+
" ... on ProjectV2 {",
|
|
240
|
+
" items(first:50, after:$after, orderBy:{field:POSITION, direction:ASC}) {",
|
|
241
|
+
" pageInfo { hasNextPage endCursor }",
|
|
242
|
+
" nodes {",
|
|
243
|
+
" id",
|
|
244
|
+
" isArchived",
|
|
245
|
+
" fieldValues(first:20) {",
|
|
246
|
+
" nodes {",
|
|
247
|
+
" ... on ProjectV2ItemFieldSingleSelectValue {",
|
|
248
|
+
" field { ... on ProjectV2SingleSelectField { id name } }",
|
|
249
|
+
" name",
|
|
250
|
+
" }",
|
|
251
|
+
" }",
|
|
252
|
+
" }",
|
|
253
|
+
" content {",
|
|
254
|
+
" ... on Issue { __typename number closed closedAt repository { nameWithOwner } }",
|
|
255
|
+
" ... on PullRequest { __typename number closed closedAt repository { nameWithOwner } }",
|
|
256
|
+
" }",
|
|
257
|
+
" }",
|
|
258
|
+
" }",
|
|
259
|
+
" }",
|
|
260
|
+
" }",
|
|
261
|
+
"}",
|
|
262
|
+
].join("\n");
|
|
263
|
+
|
|
264
|
+
const ARCHIVE_ITEM = [
|
|
265
|
+
"mutation($projectId:ID!, $itemId:ID!) {",
|
|
266
|
+
" archiveProjectV2Item(input:{projectId:$projectId, itemId:$itemId}) {",
|
|
267
|
+
" item { id }",
|
|
268
|
+
" }",
|
|
269
|
+
"}",
|
|
270
|
+
].join("\n");
|
|
271
|
+
|
|
272
|
+
// ── Owner / project resolution ─────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
async function resolveOwner(login, env, runChild) {
|
|
275
|
+
const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
|
|
276
|
+
if (userPayload?.data?.user?.id) return { id: userPayload.data.user.id, kind: "user" };
|
|
277
|
+
const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
|
|
278
|
+
if (orgPayload?.data?.organization?.id) return { id: orgPayload.data.organization.id, kind: "org" };
|
|
279
|
+
throw Object.assign(new Error(`Could not resolve owner ID for "${login}"`), { code: "NO_USER_ID" });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function listAllProjects(login, kind, env, runChild) {
|
|
283
|
+
const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
|
|
284
|
+
const projects = [];
|
|
285
|
+
let after = null;
|
|
286
|
+
while (true) {
|
|
287
|
+
const vars = { login };
|
|
288
|
+
if (after) vars.after = after;
|
|
289
|
+
const payload = await ghGraphql(query, vars, env, runChild);
|
|
290
|
+
const connection = kind === "org" ? payload?.data?.organization?.projectsV2 : payload?.data?.user?.projectsV2;
|
|
291
|
+
const nodes = connection?.nodes ?? [];
|
|
292
|
+
projects.push(...nodes);
|
|
293
|
+
const pageInfo = connection?.pageInfo ?? {};
|
|
294
|
+
if (!pageInfo.hasNextPage) break;
|
|
295
|
+
if (!pageInfo.endCursor) {
|
|
296
|
+
throw Object.assign(new Error("Invalid projects list payload: hasNextPage true but endCursor missing"), { code: "GH_API_ERROR" });
|
|
297
|
+
}
|
|
298
|
+
after = pageInfo.endCursor;
|
|
299
|
+
}
|
|
300
|
+
return projects;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function fetchAllItems(projectId, env, runChild) {
|
|
304
|
+
const all = [];
|
|
305
|
+
let after = null;
|
|
306
|
+
while (true) {
|
|
307
|
+
const vars = { projectId };
|
|
308
|
+
if (after) vars.after = after;
|
|
309
|
+
const payload = await ghGraphql(GET_PROJECT_ITEMS, vars, env, runChild);
|
|
310
|
+
const connection = payload?.data?.node?.items;
|
|
311
|
+
const nodes = connection?.nodes ?? [];
|
|
312
|
+
all.push(...nodes);
|
|
313
|
+
const pageInfo = connection?.pageInfo ?? {};
|
|
314
|
+
if (!pageInfo.hasNextPage) break;
|
|
315
|
+
if (!pageInfo.endCursor) {
|
|
316
|
+
throw Object.assign(new Error("Invalid items payload: hasNextPage true but endCursor missing"), { code: "GH_API_ERROR" });
|
|
317
|
+
}
|
|
318
|
+
after = pageInfo.endCursor;
|
|
319
|
+
}
|
|
320
|
+
return all;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function statusOf(node) {
|
|
324
|
+
const fvs = node?.fieldValues?.nodes ?? [];
|
|
325
|
+
for (const fv of fvs) {
|
|
326
|
+
if (fv && fv.field && fv.field.name === "Status") return fv.name;
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Normalize a raw GraphQL item node into the shape selectArchivable expects.
|
|
332
|
+
function normalizeItem(node) {
|
|
333
|
+
return {
|
|
334
|
+
id: node.id,
|
|
335
|
+
isArchived: Boolean(node.isArchived),
|
|
336
|
+
status: statusOf(node),
|
|
337
|
+
content: node.content
|
|
338
|
+
? {
|
|
339
|
+
__typename: node.content.__typename,
|
|
340
|
+
number: node.content.number,
|
|
341
|
+
closed: Boolean(node.content.closed),
|
|
342
|
+
closedAt: node.content.closedAt ?? null,
|
|
343
|
+
repository: node.content.repository,
|
|
344
|
+
}
|
|
345
|
+
: null,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Selection logic (pure) ────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
// Select items whose issue/PR is closed and has been closed for >= olderThanMs.
|
|
352
|
+
function selectArchivable(items, { now, olderThanMs }) {
|
|
353
|
+
return items.filter((it) => {
|
|
354
|
+
if (it.isArchived) return false;
|
|
355
|
+
// Only archive items in the Done column — a closed issue/PR parked in
|
|
356
|
+
// another column (Backlog/Next Up/In Progress) must be left untouched.
|
|
357
|
+
if (it.status !== "Done") return false;
|
|
358
|
+
const c = it.content;
|
|
359
|
+
if (!c || !c.closed || !c.closedAt) return false;
|
|
360
|
+
const closedAtMs = Date.parse(c.closedAt);
|
|
361
|
+
if (Number.isNaN(closedAtMs)) return false;
|
|
362
|
+
return now - closedAtMs >= olderThanMs;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Exit code classification ────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
function classifyExitCode(err) {
|
|
369
|
+
if (err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT" ||
|
|
370
|
+
err.code === "INVALID_DURATION" || err.code === "INVALID_ARGS") return 1;
|
|
371
|
+
if (err.code === "PROJECT_NOT_FOUND") return 3;
|
|
372
|
+
return 2;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── Main logic ──────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
async function main(args, { env = process.env, runChild } = {}) {
|
|
378
|
+
const child = runChild ?? _runChild;
|
|
379
|
+
const repo = validateRepo(args.repo);
|
|
380
|
+
const [owner] = repo.split("/");
|
|
381
|
+
// Board: explicit --project ref wins; otherwise resolve by board title from
|
|
382
|
+
// .devloops (passed in as args.projectTitle by runCli). Fail closed if neither.
|
|
383
|
+
const hasProjectRef = typeof args.project === "string" && args.project.trim().length > 0;
|
|
384
|
+
const projectRef = hasProjectRef ? parseProjectRef(args.project) : null;
|
|
385
|
+
const projectTitle = !hasProjectRef && typeof args.projectTitle === "string" && args.projectTitle.trim().length > 0
|
|
386
|
+
? args.projectTitle.trim()
|
|
387
|
+
: null;
|
|
388
|
+
if (!projectRef && !projectTitle) {
|
|
389
|
+
throw Object.assign(
|
|
390
|
+
new Error("--project is required (or configure queue.projectNumber / queue.boardTitle in .devloops)"),
|
|
391
|
+
{ code: "INVALID_PROJECT" },
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const olderThanRaw = args.olderThan ?? args.olderThanDefault ?? "7d";
|
|
395
|
+
const olderThanMs = parseDuration(olderThanRaw);
|
|
396
|
+
const now = args.now ?? Date.now();
|
|
397
|
+
|
|
398
|
+
const { kind: ownerKind } = await resolveOwner(owner, env, child);
|
|
399
|
+
const projects = await listAllProjects(owner, ownerKind, env, child);
|
|
400
|
+
const project = projectRef
|
|
401
|
+
? (projectRef.kind === "id"
|
|
402
|
+
? projects.find((p) => p.id === projectRef.value)
|
|
403
|
+
: projects.find((p) => p.number === projectRef.value))
|
|
404
|
+
: projects.find((p) => p.title === projectTitle);
|
|
405
|
+
if (!project) {
|
|
406
|
+
const desc = projectRef
|
|
407
|
+
? (projectRef.kind === "id" ? `"${projectRef.value}"` : `number ${projectRef.value}`)
|
|
408
|
+
: `title "${projectTitle}"`;
|
|
409
|
+
throw Object.assign(
|
|
410
|
+
new Error(`Project ${desc} not found under owner "${owner}"`),
|
|
411
|
+
{ code: "PROJECT_NOT_FOUND" },
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const rawItems = await fetchAllItems(project.id, env, child);
|
|
416
|
+
// Only consider items whose content belongs to the target repo (single-repo scope).
|
|
417
|
+
const repoItems = rawItems
|
|
418
|
+
.filter((n) => n.content && n.content.repository?.nameWithOwner === repo)
|
|
419
|
+
.map(normalizeItem);
|
|
420
|
+
|
|
421
|
+
const archivable = selectArchivable(repoItems, { now, olderThanMs });
|
|
422
|
+
|
|
423
|
+
const mutations = archivable.map((it) => ({
|
|
424
|
+
query: ARCHIVE_ITEM,
|
|
425
|
+
variables: { projectId: project.id, itemId: it.id },
|
|
426
|
+
}));
|
|
427
|
+
|
|
428
|
+
if (args.dryRun) {
|
|
429
|
+
return {
|
|
430
|
+
ok: true,
|
|
431
|
+
dryRun: true,
|
|
432
|
+
olderThan: olderThanRaw,
|
|
433
|
+
scanned: repoItems.length,
|
|
434
|
+
archivable: archivable.length,
|
|
435
|
+
mutations,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const archived = [];
|
|
440
|
+
for (const it of archivable) {
|
|
441
|
+
const payload = await ghGraphql(ARCHIVE_ITEM, { projectId: project.id, itemId: it.id }, env, child);
|
|
442
|
+
if (!payload?.data?.archiveProjectV2Item?.item) {
|
|
443
|
+
throw Object.assign(new Error(`Failed to archive item ${it.id}`), { code: "MUTATION_FAILED" });
|
|
444
|
+
}
|
|
445
|
+
archived.push({
|
|
446
|
+
itemId: it.id,
|
|
447
|
+
issueNumber: it.content.__typename === "Issue" ? it.content.number : null,
|
|
448
|
+
prNumber: it.content.__typename === "PullRequest" ? it.content.number : null,
|
|
449
|
+
closedAt: it.content.closedAt,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
ok: true,
|
|
455
|
+
olderThan: olderThanRaw,
|
|
456
|
+
scanned: repoItems.length,
|
|
457
|
+
archivable: archivable.length,
|
|
458
|
+
archived,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── CLI entrypoint ──────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd() } = {}) {
|
|
465
|
+
let args;
|
|
466
|
+
try {
|
|
467
|
+
args = parseCliArgs(argv);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
stderr.write(`${formatCliError(err)}\n`);
|
|
470
|
+
process.exitCode = 1;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (args.help) {
|
|
474
|
+
stdout.write(USAGE);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Resolve board + threshold defaults from .devloops when the flags are absent.
|
|
479
|
+
// Precedence: explicit --project flag > queue.projectNumber/boardTitle.
|
|
480
|
+
// explicit --older-than flag > queue.archiveOlderThanDays > 7d.
|
|
481
|
+
const settings = resolveSettings(cwd);
|
|
482
|
+
if (args.project === undefined && settings) {
|
|
483
|
+
if (settings.project) args.project = String(settings.project);
|
|
484
|
+
else if (settings.title) args.projectTitle = settings.title;
|
|
485
|
+
}
|
|
486
|
+
if (args.olderThan === undefined && settings?.olderThanDays) {
|
|
487
|
+
args.olderThanDefault = `${settings.olderThanDays}d`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
const result = await main(args, { env });
|
|
492
|
+
stdout.write(JSON.stringify(result) + "\n");
|
|
493
|
+
} catch (err) {
|
|
494
|
+
stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
|
|
495
|
+
process.exitCode = classifyExitCode(err);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
500
|
+
runCli(process.argv.slice(2)).catch((error) => {
|
|
501
|
+
process.stderr.write(JSON.stringify({ ok: false, error: error.message, code: error.code ?? "UNKNOWN" }) + "\n");
|
|
502
|
+
process.exitCode = 2;
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export { main, parseCliArgs, parseDuration, selectArchivable, resolveSettings, runCli };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
4
5
|
import { parse as parseYaml } from "yaml";
|
|
5
6
|
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
6
7
|
import { runChild as _runChild } from "../_cli-primitives.mjs";
|
|
@@ -32,76 +33,76 @@ Exit codes:
|
|
|
32
33
|
3 — board schema/config mismatch (manual reconciliation needed)
|
|
33
34
|
`;
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
function parseCliArgs(argv) {
|
|
37
|
+
const parseError = (message) => Object.assign(new Error(message), { usage: USAGE });
|
|
38
|
+
const requireValue = (token, message) => {
|
|
39
|
+
const v = token.value;
|
|
40
|
+
if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
|
|
41
|
+
throw parseError(message);
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
};
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const args = {};
|
|
47
|
+
const { tokens } = parseArgs({
|
|
48
|
+
args: [...argv],
|
|
49
|
+
options: {
|
|
50
|
+
repo: { type: "string" },
|
|
51
|
+
project: { type: "string" },
|
|
52
|
+
title: { type: "string" },
|
|
53
|
+
"link-repo": { type: "string" },
|
|
54
|
+
"repair-rename": { type: "boolean" },
|
|
55
|
+
help: { type: "boolean", short: "h" },
|
|
56
|
+
},
|
|
57
|
+
allowPositionals: true,
|
|
58
|
+
strict: false,
|
|
59
|
+
tokens: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
for (const token of tokens) {
|
|
63
|
+
if (token.kind === "positional") {
|
|
64
|
+
throw parseError(`Unexpected argument: ${token.value}`);
|
|
48
65
|
}
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
args.project = num;
|
|
73
|
-
} else if (arg === "--title") {
|
|
74
|
-
if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
|
|
75
|
-
throw Object.assign(
|
|
76
|
-
new Error("--title requires a value"),
|
|
77
|
-
{ code: "INVALID_REPO", usage: USAGE },
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
args.title = argv[++i];
|
|
81
|
-
consumed.add(i);
|
|
82
|
-
} else if (arg === "--link-repo") {
|
|
83
|
-
if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
|
|
84
|
-
throw Object.assign(
|
|
85
|
-
new Error("--link-repo requires a value (owner/name)"),
|
|
86
|
-
{ code: "INVALID_REPO", usage: USAGE },
|
|
87
|
-
);
|
|
66
|
+
if (token.kind !== "option") {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
switch (token.name) {
|
|
70
|
+
case "help":
|
|
71
|
+
if (token.value !== undefined) {
|
|
72
|
+
throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
|
|
73
|
+
}
|
|
74
|
+
args.help = true;
|
|
75
|
+
break;
|
|
76
|
+
case "repo":
|
|
77
|
+
args.repo = requireValue(token, "--repo requires a value (owner/name)");
|
|
78
|
+
break;
|
|
79
|
+
case "project": {
|
|
80
|
+
const raw = requireValue(token, "--project requires a numeric value");
|
|
81
|
+
const num = Number(raw);
|
|
82
|
+
if (!Number.isInteger(num) || num <= 0) {
|
|
83
|
+
throw parseError(`--project must be a positive integer, got "${raw}"`);
|
|
84
|
+
}
|
|
85
|
+
args.project = num;
|
|
86
|
+
break;
|
|
88
87
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
case "title":
|
|
89
|
+
args.title = requireValue(token, "--title requires a value");
|
|
90
|
+
break;
|
|
91
|
+
case "link-repo":
|
|
92
|
+
args.linkRepo = requireValue(token, "--link-repo requires a value (owner/name)");
|
|
93
|
+
break;
|
|
94
|
+
case "repair-rename":
|
|
95
|
+
if (token.value !== undefined) {
|
|
96
|
+
throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
|
|
97
|
+
}
|
|
98
|
+
args.repairRename = true;
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
return args;
|
|
103
105
|
}
|
|
104
|
-
|
|
105
106
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
106
107
|
|
|
107
108
|
// GitHub slug rules: owner 1-39 chars (alnum/dash, no leading/trailing dash,
|
|
@@ -798,7 +799,7 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
798
799
|
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd() } = {}) {
|
|
799
800
|
let args;
|
|
800
801
|
try {
|
|
801
|
-
args =
|
|
802
|
+
args = parseCliArgs(argv);
|
|
802
803
|
} catch (err) {
|
|
803
804
|
stderr.write(`${formatCliError(err)}\n`);
|
|
804
805
|
process.exitCode = 1;
|