dev-loops 0.7.1 → 0.7.2
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 +7 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +2 -2
- package/.claude/agents/review.md +2 -2
- package/.claude/commands/loop-enqueue.md +1 -1
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/skills/copilot-pr-followup/SKILL.md +37 -23
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/acceptance-criteria-verification.md +3 -2
- package/.claude/skills/docs/anti-patterns.md +3 -2
- package/.claude/skills/docs/artifact-authority-contract.md +12 -11
- package/.claude/skills/docs/confirmation-rules.md +1 -0
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +18 -14
- package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +25 -19
- package/.claude/skills/docs/merge-preconditions.md +13 -11
- package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
- package/.claude/skills/docs/public-dev-loop-contract.md +59 -43
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +18 -5
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/local-implementation/SKILL.md +48 -142
- package/CHANGELOG.md +6 -0
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +2 -2
- package/agents/review.agent.md +2 -2
- package/package.json +5 -4
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +102 -17
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/request-copilot-review.mjs +92 -26
- package/scripts/github/upsert-checkpoint-verdict.mjs +74 -5
- package/scripts/github/verify-briefing-prefixes.mjs +191 -0
- package/scripts/github/verify-fresh-review-context.mjs +78 -3
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +213 -7
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/copilot-pr-handoff.mjs +29 -2
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +23 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/sanctioned-commands.mjs +2 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +50 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- package/scripts/projects/list-queue-items.mjs +3 -377
- package/scripts/projects/move-queue-item.mjs +3 -410
- package/scripts/projects/reorder-queue-item.mjs +3 -22
- package/skills/copilot-pr-followup/SKILL.md +37 -23
- package/skills/dev-loop/SKILL.md +1 -1
- package/skills/docs/acceptance-criteria-verification.md +3 -2
- package/skills/docs/anti-patterns.md +3 -2
- package/skills/docs/artifact-authority-contract.md +12 -11
- package/skills/docs/confirmation-rules.md +1 -0
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +18 -14
- package/skills/docs/cross-harness-regression-contract.md +2 -2
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +25 -19
- package/skills/docs/merge-preconditions.md +13 -11
- package/skills/docs/pr-lifecycle-contract.md +44 -35
- package/skills/docs/public-dev-loop-contract.md +59 -43
- package/skills/docs/required-rules.json +149 -0
- package/skills/docs/retrospective-checkpoint-contract.md +18 -5
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/validation-policy.md +4 -5
- package/skills/local-implementation/SKILL.md +48 -142
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
|
@@ -1,148 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { parse as parseYaml } from "yaml";
|
|
4
|
-
|
|
5
|
-
// Read .devloops (and extension variants) queue settings, mirroring the
|
|
6
|
-
// resolution used by ensure-queue-board.mjs. Returns { project }, { title },
|
|
7
|
-
// and/or { olderThanDays } when configured; never throws on a missing/bad file.
|
|
8
|
-
function resolveSettings(cwd) {
|
|
9
|
-
const basePath = path.join(cwd, ".devloops");
|
|
10
|
-
const extensions = ["", ".yaml", ".yml", ".json"];
|
|
11
|
-
for (const ext of extensions) {
|
|
12
|
-
try {
|
|
13
|
-
const raw = readFileSync(basePath + ext, "utf-8");
|
|
14
|
-
const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
|
15
|
-
const queue = settings?.queue;
|
|
16
|
-
if (!queue) return null;
|
|
17
|
-
const out = {};
|
|
18
|
-
if (typeof queue.projectNumber === "number" && Number.isInteger(queue.projectNumber) && queue.projectNumber > 0) {
|
|
19
|
-
out.project = queue.projectNumber;
|
|
20
|
-
} else if (typeof queue.boardTitle === "string" && queue.boardTitle.trim().length > 0) {
|
|
21
|
-
out.title = queue.boardTitle.trim();
|
|
22
|
-
}
|
|
23
|
-
if (typeof queue.archiveOlderThanDays === "number" && Number.isInteger(queue.archiveOlderThanDays) && queue.archiveOlderThanDays > 0) {
|
|
24
|
-
out.olderThanDays = queue.archiveOlderThanDays;
|
|
25
|
-
}
|
|
26
|
-
return out;
|
|
27
|
-
} catch {
|
|
28
|
-
// extension not present or unparseable — try next
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Parse a --project value into { kind:"id"|"number"|"uri", ... }. Throws
|
|
35
|
-
// INVALID_PROJECT on empty/malformed input (bare "0" is rejected too).
|
|
36
|
-
//
|
|
37
|
-
// Supported forms:
|
|
38
|
-
// <n> positive integer → { kind:"number", value:<n> }
|
|
39
|
-
// <NODE_ID> alphanumeric/_ ID → { kind:"id", value:<NODE_ID> }
|
|
40
|
-
// https://github.com/users/<login>/projects/<n>
|
|
41
|
-
// https://github.com/orgs/<login>/projects/<n>
|
|
42
|
-
// board URI → { kind:"uri", number:<n>, owner:<login>, ownerKind:"user"|"org" }
|
|
43
|
-
const GLOBAL_NODE_ID_RE = /^[A-Za-z0-9_]+$/;
|
|
44
|
-
|
|
45
|
-
// GitHub Projects V2 board URI pattern (user- or org-scoped boards).
|
|
46
|
-
const BOARD_URI_RE = /^https:\/\/github\.com\/(users|orgs)\/([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/projects\/(\d+)$/;
|
|
47
|
-
|
|
48
|
-
function parseProjectRef(raw) {
|
|
49
|
-
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
|
|
50
|
-
throw Object.assign(new Error("--project is required"), { code: "INVALID_PROJECT" });
|
|
51
|
-
}
|
|
52
|
-
const trimmed = raw.trim();
|
|
53
|
-
|
|
54
|
-
// Board URI: https://github.com/users/<login>/projects/<n>
|
|
55
|
-
// https://github.com/orgs/<login>/projects/<n>
|
|
56
|
-
const uriMatch = BOARD_URI_RE.exec(trimmed);
|
|
57
|
-
if (uriMatch) {
|
|
58
|
-
const ownerKind = uriMatch[1] === "users" ? "user" : "org";
|
|
59
|
-
const owner = uriMatch[2];
|
|
60
|
-
const number = Number(uriMatch[3]);
|
|
61
|
-
if (number < 1) {
|
|
62
|
-
throw Object.assign(
|
|
63
|
-
new Error(`--project board URI must reference a positive project number, got "${raw}"`),
|
|
64
|
-
{ code: "INVALID_PROJECT" },
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
return { kind: "uri", number, owner, ownerKind };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const asNum = Number(trimmed);
|
|
71
|
-
if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
|
|
72
|
-
return { kind: "number", value: asNum };
|
|
73
|
-
}
|
|
74
|
-
// Reject bare "0" — valid node ID character but not a meaningful project reference
|
|
75
|
-
if (trimmed === "0") {
|
|
76
|
-
throw Object.assign(
|
|
77
|
-
new Error(`--project must be a positive integer, a node ID, or a board URI, got "${raw}"`),
|
|
78
|
-
{ code: "INVALID_PROJECT" },
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
if (GLOBAL_NODE_ID_RE.test(trimmed)) {
|
|
82
|
-
return { kind: "id", value: trimmed };
|
|
83
|
-
}
|
|
84
|
-
throw Object.assign(
|
|
85
|
-
new Error(`--project must be a positive integer, a node ID, or a board URI, got "${raw}"`),
|
|
86
|
-
{ code: "INVALID_PROJECT" },
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Selector precedence: explicit --project ref wins; else resolve by board title
|
|
91
|
-
// from .devloops (passed as args.projectTitle by runCli). Fail closed if neither.
|
|
92
|
-
function resolveProjectSelector(args) {
|
|
93
|
-
const hasProjectRef = typeof args.project === "string" && args.project.trim().length > 0;
|
|
94
|
-
const projectRef = hasProjectRef ? parseProjectRef(args.project) : null;
|
|
95
|
-
const projectTitle = !hasProjectRef && typeof args.projectTitle === "string" && args.projectTitle.trim().length > 0
|
|
96
|
-
? args.projectTitle.trim()
|
|
97
|
-
: null;
|
|
98
|
-
if (!projectRef && !projectTitle) {
|
|
99
|
-
throw Object.assign(
|
|
100
|
-
new Error("--project is required (or set queue.projectNumber / queue.boardTitle in .devloops)"),
|
|
101
|
-
{ code: "INVALID_PROJECT" },
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
return { projectRef, projectTitle };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Find the project in `projects` matching the resolved selector; throws
|
|
108
|
-
// PROJECT_NOT_FOUND (desc: "<id>" / number N / title "T" / URI number N under "<owner>").
|
|
109
|
-
function findProject(projects, { projectRef, projectTitle }, owner) {
|
|
110
|
-
let project;
|
|
111
|
-
if (projectRef) {
|
|
112
|
-
if (projectRef.kind === "id") {
|
|
113
|
-
project = projects.find((p) => p.id === projectRef.value);
|
|
114
|
-
} else if (projectRef.kind === "uri") {
|
|
115
|
-
project = projects.find((p) => p.number === projectRef.number);
|
|
116
|
-
} else {
|
|
117
|
-
project = projects.find((p) => p.number === projectRef.value);
|
|
118
|
-
}
|
|
119
|
-
} else {
|
|
120
|
-
project = projects.find((p) => p.title === projectTitle);
|
|
121
|
-
}
|
|
122
|
-
if (!project) {
|
|
123
|
-
const desc = projectRef
|
|
124
|
-
? (projectRef.kind === "id"
|
|
125
|
-
? `"${projectRef.value}"`
|
|
126
|
-
: projectRef.kind === "uri"
|
|
127
|
-
? `URI number ${projectRef.number} under "${projectRef.owner}"`
|
|
128
|
-
: `number ${projectRef.value}`)
|
|
129
|
-
: `title "${projectTitle}"`;
|
|
130
|
-
throw Object.assign(
|
|
131
|
-
new Error(`Project ${desc} not found under owner "${owner}"`),
|
|
132
|
-
{ code: "PROJECT_NOT_FOUND" },
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
return project;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Apply .devloops board settings when --project was not passed. Precedence:
|
|
139
|
-
// explicit --project flag > queue.projectNumber/boardTitle. Mutates args.
|
|
140
|
-
function applyDevloopsBoard(args, cwd) {
|
|
141
|
-
if (args.project === undefined) {
|
|
142
|
-
const settings = resolveSettings(cwd);
|
|
143
|
-
if (settings?.project) args.project = String(settings.project);
|
|
144
|
-
else if (settings?.title) args.projectTitle = settings.title;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export { resolveSettings, parseProjectRef, resolveProjectSelector, findProject, applyDevloopsBoard };
|
|
1
|
+
export { resolveSettings, parseProjectRef, parseItemRef, resolveProjectSelector, findProject, applyDevloopsBoard } from "@dev-loops/core/projects/resolve-project";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { formatCliError, isDirectCliRun
|
|
3
|
-
import {
|
|
4
|
-
import { resolveProjectSelector, findProject, applyDevloopsBoard } from "./_resolve-project.mjs";
|
|
2
|
+
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
3
|
+
import { applyDevloopsBoard } from "./_resolve-project.mjs";
|
|
5
4
|
import { parseArgs } from "node:util";
|
|
6
5
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
6
|
+
import { main, classifyExitCode } from "@dev-loops/core/projects/list-queue-items";
|
|
7
7
|
|
|
8
8
|
const USAGE = `Usage: dev-loops queue list --repo <owner/name> [--project <number|id>] [--column <name>] [--limit <n>]
|
|
9
9
|
dev-loops queue list --repo <owner/name> [--project <number|id>] --summary [--done-limit <n>]
|
|
@@ -139,380 +139,6 @@ function parseCliArgs(argv) {
|
|
|
139
139
|
}
|
|
140
140
|
return args;
|
|
141
141
|
}
|
|
142
|
-
// ── Validation ───────────────────────────────────────────────────────────
|
|
143
|
-
|
|
144
|
-
const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
145
|
-
const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
|
|
146
|
-
|
|
147
|
-
function validateRepo(repo) {
|
|
148
|
-
if (!repo || typeof repo !== "string") {
|
|
149
|
-
throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO" });
|
|
150
|
-
}
|
|
151
|
-
const trimmed = repo.trim();
|
|
152
|
-
if (trimmed !== repo) {
|
|
153
|
-
throw Object.assign(
|
|
154
|
-
new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`),
|
|
155
|
-
{ code: "INVALID_REPO" },
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
const slashIdx = repo.indexOf("/");
|
|
159
|
-
if (slashIdx === -1) {
|
|
160
|
-
throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
|
|
161
|
-
}
|
|
162
|
-
const owner = repo.slice(0, slashIdx);
|
|
163
|
-
const name = repo.slice(slashIdx + 1);
|
|
164
|
-
if (!owner || !name || !OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
|
|
165
|
-
throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
|
|
166
|
-
}
|
|
167
|
-
return repo;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ── API helpers ──────────────────────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
async function ghGraphql(query, vars, env, runChild = _runChild) {
|
|
173
|
-
const fieldArgs = [];
|
|
174
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
175
|
-
fieldArgs.push("--field", `${key}=${value}`);
|
|
176
|
-
}
|
|
177
|
-
const result = await runChild(
|
|
178
|
-
"gh",
|
|
179
|
-
["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
|
|
180
|
-
env,
|
|
181
|
-
);
|
|
182
|
-
if (result.code !== 0) {
|
|
183
|
-
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
184
|
-
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
185
|
-
}
|
|
186
|
-
const payload = parseJsonText(result.stdout);
|
|
187
|
-
if (payload.errors && payload.errors.length > 0) {
|
|
188
|
-
throw Object.assign(
|
|
189
|
-
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
190
|
-
{ code: "GRAPHQL_ERROR" },
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
return payload;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ── GraphQL fragments ────────────────────────────────────────────────────
|
|
197
|
-
|
|
198
|
-
const GET_USER_ID = [
|
|
199
|
-
"query($login:String!) {",
|
|
200
|
-
" user(login:$login) { id }",
|
|
201
|
-
"}"
|
|
202
|
-
].join("\n");
|
|
203
|
-
|
|
204
|
-
const GET_ORG_ID = [
|
|
205
|
-
"query($login:String!) {",
|
|
206
|
-
" organization(login:$login) { id }",
|
|
207
|
-
"}"
|
|
208
|
-
].join("\n");
|
|
209
|
-
|
|
210
|
-
const LIST_USER_PROJECTS = [
|
|
211
|
-
"query($login:String!, $after:String) {",
|
|
212
|
-
" user(login:$login) {",
|
|
213
|
-
" projectsV2(first:50, after:$after) {",
|
|
214
|
-
" pageInfo { hasNextPage endCursor }",
|
|
215
|
-
" nodes { id number title url }",
|
|
216
|
-
" }",
|
|
217
|
-
" }",
|
|
218
|
-
"}"
|
|
219
|
-
].join("\n");
|
|
220
|
-
|
|
221
|
-
const LIST_ORG_PROJECTS = [
|
|
222
|
-
"query($login:String!, $after:String) {",
|
|
223
|
-
" organization(login:$login) {",
|
|
224
|
-
" projectsV2(first:50, after:$after) {",
|
|
225
|
-
" pageInfo { hasNextPage endCursor }",
|
|
226
|
-
" nodes { id number title url }",
|
|
227
|
-
" }",
|
|
228
|
-
" }",
|
|
229
|
-
"}"
|
|
230
|
-
].join("\n");
|
|
231
|
-
|
|
232
|
-
const GET_PROJECT_FIELDS = [
|
|
233
|
-
"query($projectId:ID!, $after:String) {",
|
|
234
|
-
" node(id:$projectId) {",
|
|
235
|
-
" ... on ProjectV2 {",
|
|
236
|
-
" fields(first:50, after:$after) {",
|
|
237
|
-
" pageInfo { hasNextPage endCursor }",
|
|
238
|
-
" nodes {",
|
|
239
|
-
" ... on ProjectV2SingleSelectField {",
|
|
240
|
-
" id name",
|
|
241
|
-
" options { id name }",
|
|
242
|
-
" }",
|
|
243
|
-
" }",
|
|
244
|
-
" }",
|
|
245
|
-
" }",
|
|
246
|
-
" }",
|
|
247
|
-
"}"
|
|
248
|
-
].join("\n");
|
|
249
|
-
|
|
250
|
-
const GET_PROJECT_ITEMS = [
|
|
251
|
-
"query($projectId:ID!, $after:String) {",
|
|
252
|
-
" node(id:$projectId) {",
|
|
253
|
-
" ... on ProjectV2 {",
|
|
254
|
-
" items(first:100, after:$after) {",
|
|
255
|
-
" pageInfo { hasNextPage endCursor }",
|
|
256
|
-
" nodes {",
|
|
257
|
-
" id",
|
|
258
|
-
" fieldValues(first:20) {",
|
|
259
|
-
" nodes {",
|
|
260
|
-
" ... on ProjectV2ItemFieldSingleSelectValue {",
|
|
261
|
-
" field { ... on ProjectV2SingleSelectField { id name } }",
|
|
262
|
-
" name",
|
|
263
|
-
" }",
|
|
264
|
-
" }",
|
|
265
|
-
" }",
|
|
266
|
-
" content {",
|
|
267
|
-
" ... on Issue { number title url id }",
|
|
268
|
-
" ... on PullRequest { number title url id }",
|
|
269
|
-
" }",
|
|
270
|
-
" }",
|
|
271
|
-
" }",
|
|
272
|
-
" }",
|
|
273
|
-
" }",
|
|
274
|
-
"}"
|
|
275
|
-
].join("\n");
|
|
276
|
-
|
|
277
|
-
// ── Owner resolution ────────────────────────────────────────────────────
|
|
278
|
-
|
|
279
|
-
async function resolveOwner(login, env, runChild) {
|
|
280
|
-
const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
|
|
281
|
-
if (userPayload?.data?.user?.id) {
|
|
282
|
-
return { id: userPayload.data.user.id, kind: "user" };
|
|
283
|
-
}
|
|
284
|
-
const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
|
|
285
|
-
if (orgPayload?.data?.organization?.id) {
|
|
286
|
-
return { id: orgPayload.data.organization.id, kind: "org" };
|
|
287
|
-
}
|
|
288
|
-
throw Object.assign(
|
|
289
|
-
new Error(`Could not resolve owner ID for "${login}"`),
|
|
290
|
-
{ code: "NO_USER_ID" },
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// ── Paginated project listing ────────────────────────────────────────────
|
|
295
|
-
|
|
296
|
-
async function listAllProjects(login, kind, env, runChild) {
|
|
297
|
-
const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
|
|
298
|
-
const projects = [];
|
|
299
|
-
let after = null;
|
|
300
|
-
while (true) {
|
|
301
|
-
const vars = { login };
|
|
302
|
-
if (after) vars.after = after;
|
|
303
|
-
const payload = await ghGraphql(query, vars, env, runChild);
|
|
304
|
-
const connection = kind === "org"
|
|
305
|
-
? payload?.data?.organization?.projectsV2
|
|
306
|
-
: payload?.data?.user?.projectsV2;
|
|
307
|
-
const nodes = connection?.nodes ?? [];
|
|
308
|
-
projects.push(...nodes);
|
|
309
|
-
const pageInfo = connection?.pageInfo ?? {};
|
|
310
|
-
if (!pageInfo.hasNextPage) break;
|
|
311
|
-
if (!pageInfo.endCursor) {
|
|
312
|
-
throw Object.assign(
|
|
313
|
-
new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
|
|
314
|
-
{ code: "GH_API_ERROR" },
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
after = pageInfo.endCursor;
|
|
318
|
-
}
|
|
319
|
-
return projects;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// ── Paginated field listing ──────────────────────────────────────────────
|
|
323
|
-
|
|
324
|
-
async function listAllFields(projectId, env, runChild) {
|
|
325
|
-
const fields = [];
|
|
326
|
-
let after = null;
|
|
327
|
-
while (true) {
|
|
328
|
-
const vars = { projectId };
|
|
329
|
-
if (after) vars.after = after;
|
|
330
|
-
const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
|
|
331
|
-
const connection = payload?.data?.node?.fields;
|
|
332
|
-
const nodes = connection?.nodes ?? [];
|
|
333
|
-
fields.push(...nodes);
|
|
334
|
-
const pageInfo = connection?.pageInfo ?? {};
|
|
335
|
-
if (!pageInfo.hasNextPage) break;
|
|
336
|
-
if (!pageInfo.endCursor) {
|
|
337
|
-
throw Object.assign(
|
|
338
|
-
new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
|
|
339
|
-
{ code: "GH_API_ERROR" },
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
after = pageInfo.endCursor;
|
|
343
|
-
}
|
|
344
|
-
return fields;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// ── Paginated item listing ───────────────────────────────────────────────
|
|
348
|
-
|
|
349
|
-
async function listAllItems(projectId, env, runChild) {
|
|
350
|
-
const items = [];
|
|
351
|
-
let after = null;
|
|
352
|
-
while (true) {
|
|
353
|
-
const vars = { projectId };
|
|
354
|
-
if (after) vars.after = after;
|
|
355
|
-
const payload = await ghGraphql(GET_PROJECT_ITEMS, vars, env, runChild);
|
|
356
|
-
const connection = payload?.data?.node?.items;
|
|
357
|
-
const nodes = connection?.nodes ?? [];
|
|
358
|
-
items.push(...nodes);
|
|
359
|
-
const pageInfo = connection?.pageInfo ?? {};
|
|
360
|
-
if (!pageInfo.hasNextPage) break;
|
|
361
|
-
if (!pageInfo.endCursor) {
|
|
362
|
-
throw Object.assign(
|
|
363
|
-
new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
|
|
364
|
-
{ code: "GH_API_ERROR" },
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
after = pageInfo.endCursor;
|
|
368
|
-
}
|
|
369
|
-
return items;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// ── Exit code classification ────────────────────────────────────────────
|
|
373
|
-
|
|
374
|
-
function classifyExitCode(err) {
|
|
375
|
-
if (err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT" || err.code === "INVALID_ARGS") return 1;
|
|
376
|
-
if (err.code === "PROJECT_NOT_FOUND" || err.code === "FIELD_NOT_FOUND" || err.code === "COLUMN_NOT_FOUND") return 3;
|
|
377
|
-
return 2;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// ── Main logic ──────────────────────────────────────────────────────────
|
|
381
|
-
|
|
382
|
-
async function main(args, { env = process.env, runChild } = {}) {
|
|
383
|
-
const child = runChild ?? _runChild;
|
|
384
|
-
const repo = validateRepo(args.repo);
|
|
385
|
-
const [owner] = repo.split("/");
|
|
386
|
-
const selector = resolveProjectSelector(args);
|
|
387
|
-
|
|
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;
|
|
415
|
-
|
|
416
|
-
// 2. Resolve project
|
|
417
|
-
const projects = await listAllProjects(projectOwner, ownerKind, env, child);
|
|
418
|
-
const project = findProject(projects, selector, projectOwner);
|
|
419
|
-
|
|
420
|
-
// 3. Resolve Status field and target column
|
|
421
|
-
const fieldNodes = await listAllFields(project.id, env, child);
|
|
422
|
-
const statusField = fieldNodes.find((f) => f.name === "Status" && f.options);
|
|
423
|
-
if (!statusField) {
|
|
424
|
-
throw Object.assign(
|
|
425
|
-
new Error(`Status field not found in project "${project.title}" (number ${project.number})`),
|
|
426
|
-
{ code: "FIELD_NOT_FOUND" },
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
let targetOption = null;
|
|
431
|
-
if (args.column) {
|
|
432
|
-
targetOption = statusField.options.find(
|
|
433
|
-
(o) => o.name === args.column,
|
|
434
|
-
);
|
|
435
|
-
if (!targetOption) {
|
|
436
|
-
const available = statusField.options.map((o) => o.name).join(", ");
|
|
437
|
-
throw Object.assign(
|
|
438
|
-
new Error(
|
|
439
|
-
`Column "${args.column}" not found in Status field. Available: ${available}`,
|
|
440
|
-
),
|
|
441
|
-
{ code: "COLUMN_NOT_FOUND" },
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// 4. List and filter items (ordered by position ascending, GraphQL default)
|
|
447
|
-
const rawItems = await listAllItems(project.id, env, child);
|
|
448
|
-
|
|
449
|
-
const results = [];
|
|
450
|
-
for (const item of rawItems) {
|
|
451
|
-
const content = item.content;
|
|
452
|
-
if (!content) continue;
|
|
453
|
-
|
|
454
|
-
// Determine status from field values
|
|
455
|
-
let status = null;
|
|
456
|
-
const fieldValues = item.fieldValues?.nodes ?? [];
|
|
457
|
-
for (const fv of fieldValues) {
|
|
458
|
-
if (fv && fv.field && fv.field.name === "Status") {
|
|
459
|
-
status = fv.name;
|
|
460
|
-
break;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Filter by column
|
|
465
|
-
if (args.column && status !== args.column) continue;
|
|
466
|
-
|
|
467
|
-
const isPr = content.__typename === "PullRequest";
|
|
468
|
-
|
|
469
|
-
results.push({
|
|
470
|
-
issueNumber: isPr ? null : content.number,
|
|
471
|
-
prNumber: isPr ? content.number : null,
|
|
472
|
-
title: content.title ?? null,
|
|
473
|
-
url: content.url ?? null,
|
|
474
|
-
itemId: item.id,
|
|
475
|
-
contentId: content.id ?? null,
|
|
476
|
-
status: status ?? null,
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
|
|
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.
|
|
509
|
-
const limited = args.limit ? results.slice(0, args.limit) : results;
|
|
510
|
-
|
|
511
|
-
return {
|
|
512
|
-
ok: true,
|
|
513
|
-
items: limited,
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
142
|
|
|
517
143
|
// ── CLI entrypoint ──────────────────────────────────────────────────────
|
|
518
144
|
|