@dev-loops/core 0.7.1 → 0.8.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.
@@ -0,0 +1,394 @@
1
+ import { runChild as _runChild } from "../cli/primitives.mjs";
2
+ import { parseJsonText } from "../github/review-threads.mjs";
3
+ import { resolveProjectSelector, findProject, parseItemRef } from "./resolve-project.mjs";
4
+
5
+ // ── Validation ───────────────────────────────────────────────────────────
6
+
7
+ const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
8
+ const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
9
+
10
+ function validateRepo(repo) {
11
+ if (!repo || typeof repo !== "string") {
12
+ throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO" });
13
+ }
14
+ const trimmed = repo.trim();
15
+ if (trimmed !== repo) {
16
+ throw Object.assign(new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`), { code: "INVALID_REPO" });
17
+ }
18
+ const slashIdx = repo.indexOf("/");
19
+ if (slashIdx === -1) {
20
+ throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
21
+ }
22
+ const owner = repo.slice(0, slashIdx);
23
+ const name = repo.slice(slashIdx + 1);
24
+ if (!owner || !name || !OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
25
+ throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
26
+ }
27
+ return repo;
28
+ }
29
+
30
+ // ── API helpers ──────────────────────────────────────────────────────────
31
+
32
+ async function ghGraphql(query, vars, env, runChild = _runChild) {
33
+ const fieldArgs = [];
34
+ for (const [key, value] of Object.entries(vars)) {
35
+ fieldArgs.push("--field", `${key}=${value}`);
36
+ }
37
+ const result = await runChild(
38
+ "gh",
39
+ ["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
40
+ env,
41
+ );
42
+ if (result.code !== 0) {
43
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
44
+ throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
45
+ }
46
+ const payload = parseJsonText(result.stdout);
47
+ if (payload.errors && payload.errors.length > 0) {
48
+ throw Object.assign(
49
+ new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
50
+ { code: "GRAPHQL_ERROR" },
51
+ );
52
+ }
53
+ return payload;
54
+ }
55
+
56
+ // ── GraphQL fragments ────────────────────────────────────────────────────
57
+
58
+ const GET_USER_ID = [
59
+ "query($login:String!) {",
60
+ " user(login:$login) { id }",
61
+ "}"
62
+ ].join("\n");
63
+
64
+ const GET_ORG_ID = [
65
+ "query($login:String!) {",
66
+ " organization(login:$login) { id }",
67
+ "}"
68
+ ].join("\n");
69
+
70
+ const LIST_USER_PROJECTS = [
71
+ "query($login:String!, $after:String) {",
72
+ " user(login:$login) {",
73
+ " projectsV2(first:50, after:$after) {",
74
+ " pageInfo { hasNextPage endCursor }",
75
+ " nodes { id number title url }",
76
+ " }",
77
+ " }",
78
+ "}"
79
+ ].join("\n");
80
+
81
+ const LIST_ORG_PROJECTS = [
82
+ "query($login:String!, $after:String) {",
83
+ " organization(login:$login) {",
84
+ " projectsV2(first:50, after:$after) {",
85
+ " pageInfo { hasNextPage endCursor }",
86
+ " nodes { id number title url }",
87
+ " }",
88
+ " }",
89
+ "}"
90
+ ].join("\n");
91
+
92
+ const GET_PROJECT_FIELDS = [
93
+ "query($projectId:ID!, $after:String) {",
94
+ " node(id:$projectId) {",
95
+ " ... on ProjectV2 {",
96
+ " fields(first:50, after:$after) {",
97
+ " pageInfo { hasNextPage endCursor }",
98
+ " nodes {",
99
+ " ... on ProjectV2SingleSelectField {",
100
+ " id name",
101
+ " options { id name }",
102
+ " }",
103
+ " }",
104
+ " }",
105
+ " }",
106
+ " }",
107
+ "}"
108
+ ].join("\n");
109
+
110
+ const GET_PROJECT_ITEMS_BY_CONTENT = [
111
+ "query($projectId:ID!, $after:String) {",
112
+ " node(id:$projectId) {",
113
+ " ... on ProjectV2 {",
114
+ " items(first:100, after:$after, orderBy:{field:POSITION, direction:ASC}) {",
115
+ " pageInfo { hasNextPage endCursor }",
116
+ " nodes {",
117
+ " id",
118
+ " fieldValues(first:20) {",
119
+ " nodes {",
120
+ " ... on ProjectV2ItemFieldSingleSelectValue {",
121
+ " field { ... on ProjectV2SingleSelectField { id name } }",
122
+ " name",
123
+ " }",
124
+ " }",
125
+ " }",
126
+ " content {",
127
+ " ... on Issue { __typename number repository { nameWithOwner } }",
128
+ " ... on PullRequest { __typename number repository { nameWithOwner } }",
129
+ " }",
130
+ " }",
131
+ " }",
132
+ " }",
133
+ " }",
134
+ "}"
135
+ ].join("\n");
136
+
137
+ const UPDATE_ITEM_FIELD = [
138
+ "mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) {",
139
+ " updateProjectV2ItemFieldValue(input:{projectId:$projectId, itemId:$itemId, fieldId:$fieldId, value:{singleSelectOptionId:$optionId}}) {",
140
+ " projectV2Item {",
141
+ " id",
142
+ " }",
143
+ " }",
144
+ "}"
145
+ ].join("\n");
146
+
147
+ // ── Owner resolution ────────────────────────────────────────────────────
148
+
149
+ async function resolveOwner(login, env, runChild) {
150
+ const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
151
+ if (userPayload?.data?.user?.id) {
152
+ return { id: userPayload.data.user.id, kind: "user" };
153
+ }
154
+ const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
155
+ if (orgPayload?.data?.organization?.id) {
156
+ return { id: orgPayload.data.organization.id, kind: "org" };
157
+ }
158
+ throw Object.assign(
159
+ new Error(`Could not resolve owner ID for "${login}"`),
160
+ { code: "NO_USER_ID" },
161
+ );
162
+ }
163
+
164
+ // ── Paginated project listing ────────────────────────────────────────────
165
+
166
+ async function listAllProjects(login, kind, env, runChild) {
167
+ const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
168
+ const projects = [];
169
+ let after = null;
170
+ while (true) {
171
+ const vars = { login };
172
+ if (after) vars.after = after;
173
+ const payload = await ghGraphql(query, vars, env, runChild);
174
+ const connection = kind === "org"
175
+ ? payload?.data?.organization?.projectsV2
176
+ : payload?.data?.user?.projectsV2;
177
+ const nodes = connection?.nodes ?? [];
178
+ projects.push(...nodes);
179
+ const pageInfo = connection?.pageInfo ?? {};
180
+ if (!pageInfo.hasNextPage) break;
181
+ if (!pageInfo.endCursor) {
182
+ throw Object.assign(
183
+ new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
184
+ { code: "GH_API_ERROR" },
185
+ );
186
+ }
187
+ after = pageInfo.endCursor;
188
+ }
189
+ return projects;
190
+ }
191
+
192
+ // ── Paginated field listing ──────────────────────────────────────────────
193
+
194
+ async function listAllFields(projectId, env, runChild) {
195
+ const fields = [];
196
+ let after = null;
197
+ while (true) {
198
+ const vars = { projectId };
199
+ if (after) vars.after = after;
200
+ const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
201
+ const connection = payload?.data?.node?.fields;
202
+ const nodes = connection?.nodes ?? [];
203
+ fields.push(...nodes);
204
+ const pageInfo = connection?.pageInfo ?? {};
205
+ if (!pageInfo.hasNextPage) break;
206
+ if (!pageInfo.endCursor) {
207
+ throw Object.assign(
208
+ new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
209
+ { code: "GH_API_ERROR" },
210
+ );
211
+ }
212
+ after = pageInfo.endCursor;
213
+ }
214
+ return fields;
215
+ }
216
+
217
+ // ── Paginated item listing (position order) ──────────────────────────────
218
+
219
+ async function fetchAllItems(projectId, env, runChild) {
220
+ const items = [];
221
+ let after = null;
222
+ while (true) {
223
+ const vars = { projectId };
224
+ if (after) vars.after = after;
225
+ const payload = await ghGraphql(GET_PROJECT_ITEMS_BY_CONTENT, vars, env, runChild);
226
+ const connection = payload?.data?.node?.items;
227
+ const nodes = connection?.nodes ?? [];
228
+ items.push(...nodes);
229
+ const pageInfo = connection?.pageInfo ?? {};
230
+ if (!pageInfo.hasNextPage) break;
231
+ if (!pageInfo.endCursor) {
232
+ throw Object.assign(
233
+ new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
234
+ { code: "GH_API_ERROR" },
235
+ );
236
+ }
237
+ after = pageInfo.endCursor;
238
+ }
239
+ return items;
240
+ }
241
+
242
+ function statusOf(node) {
243
+ const fvs = node?.fieldValues?.nodes ?? [];
244
+ for (const fv of fvs) {
245
+ if (fv && fv.field && fv.field.name === "Status") return fv.name;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ // ── Exit code classification ────────────────────────────────────────────
251
+
252
+ function classifyExitCode(err) {
253
+ if (err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT" || err.code === "INVALID_ITEM" ||
254
+ err.code === "INVALID_COLUMN" || err.code === "INVALID_ARGS") return 1;
255
+ if (err.code === "PROJECT_NOT_FOUND" || err.code === "FIELD_NOT_FOUND" || err.code === "COLUMN_NOT_FOUND" ||
256
+ err.code === "ITEM_NOT_FOUND") return 3;
257
+ return 2;
258
+ }
259
+
260
+ // ── Main logic ──────────────────────────────────────────────────────────
261
+
262
+ async function main(args, { env = process.env, runChild } = {}) {
263
+ const child = runChild ?? _runChild;
264
+ const repo = validateRepo(args.repo);
265
+ const [owner, repoName] = repo.split("/");
266
+ const selector = resolveProjectSelector(args);
267
+ const itemRef = parseItemRef(args.item);
268
+ const toColumn = (args.toColumn ?? "").trim();
269
+ if (!toColumn) {
270
+ throw Object.assign(new Error("--to-column is required"), { code: "INVALID_COLUMN" });
271
+ }
272
+
273
+ // 1. Resolve owner.
274
+ // URI refs encode owner+kind directly; skip the API round-trip for owner resolution.
275
+ const projectOwner = selector.projectRef?.kind === "uri" ? selector.projectRef.owner : owner;
276
+ const ownerKind = selector.projectRef?.kind === "uri"
277
+ ? selector.projectRef.ownerKind
278
+ : (await resolveOwner(owner, env, child)).kind;
279
+
280
+ // 2. Resolve project
281
+ const projects = await listAllProjects(projectOwner, ownerKind, env, child);
282
+ const project = findProject(projects, selector, projectOwner);
283
+
284
+ // 3. Resolve Status field and target column
285
+ const fieldNodes = await listAllFields(project.id, env, child);
286
+ const statusField = fieldNodes.find((f) => f.name === "Status" && f.options);
287
+ if (!statusField) {
288
+ throw Object.assign(
289
+ new Error(`Status field not found in project "${project.title}" (number ${project.number})`),
290
+ { code: "FIELD_NOT_FOUND" },
291
+ );
292
+ }
293
+
294
+ const targetOption = statusField.options.find((o) => o.name === toColumn);
295
+ if (!targetOption) {
296
+ const available = statusField.options.map((o) => o.name).join(", ");
297
+ throw Object.assign(
298
+ new Error(`Column "${toColumn}" not found in Status field. Available: ${available}`),
299
+ { code: "COLUMN_NOT_FOUND" },
300
+ );
301
+ }
302
+
303
+ // 4. Find the item.
304
+ //
305
+ // Fetch the full board item list ONCE (paginated, position order) and resolve
306
+ // BOTH ref kinds against it. This reuses the proven pattern from
307
+ // reorder-queue-item / list-queue-items: a node-id ref matches by item.id, a
308
+ // number ref matches by content.number. Both are scoped to the requested repo
309
+ // so a cross-project ref fails closed with ITEM_NOT_FOUND. (The previous code
310
+ // used `ProjectV2.item` — a field that does not exist — for the node-id path,
311
+ // and a single non-paginated `items(first:10)` page for the number path, so it
312
+ // could not find items beyond the first page.)
313
+ const allItems = await fetchAllItems(project.id, env, child);
314
+
315
+ let match;
316
+ if (itemRef.kind === "id") {
317
+ match = allItems.find(
318
+ (it) => it.id === itemRef.value && it.content?.repository?.nameWithOwner === repo,
319
+ );
320
+ if (!match) {
321
+ throw Object.assign(
322
+ new Error(`Item "${itemRef.value}" not found in project "${project.title}" for repo "${repo}"`),
323
+ { code: "ITEM_NOT_FOUND" },
324
+ );
325
+ }
326
+ } else {
327
+ match = allItems.find(
328
+ (it) =>
329
+ it.content &&
330
+ it.content.repository?.nameWithOwner === repo &&
331
+ it.content.number === itemRef.value,
332
+ );
333
+ if (!match) {
334
+ throw Object.assign(
335
+ new Error(`Item #${itemRef.value} not found in project "${project.title}" for repo "${repo}"`),
336
+ { code: "ITEM_NOT_FOUND" },
337
+ );
338
+ }
339
+ }
340
+
341
+ const itemId = match.id;
342
+ const previousColumn = statusOf(match);
343
+ let issueNumber = null;
344
+ let prNumber = null;
345
+ if (match.content) {
346
+ if (match.content.__typename === "PullRequest") {
347
+ prNumber = match.content.number;
348
+ } else {
349
+ issueNumber = match.content.number;
350
+ }
351
+ }
352
+
353
+ // 5. No-op if already at target column
354
+ if (previousColumn === toColumn) {
355
+ return {
356
+ ok: true,
357
+ item: {
358
+ itemId,
359
+ issueNumber,
360
+ prNumber,
361
+ previousColumn,
362
+ newColumn: toColumn,
363
+ unchanged: true,
364
+ },
365
+ };
366
+ }
367
+
368
+ // 6. Update Status via mutation
369
+ const updatePayload = await ghGraphql(UPDATE_ITEM_FIELD, {
370
+ projectId: project.id,
371
+ itemId,
372
+ fieldId: statusField.id,
373
+ optionId: targetOption.id,
374
+ }, env, child);
375
+
376
+ const updated = updatePayload?.data?.updateProjectV2ItemFieldValue?.projectV2Item;
377
+ if (!updated) {
378
+ throw Object.assign(new Error("Failed to update item field value"), { code: "MUTATION_FAILED" });
379
+ }
380
+
381
+ return {
382
+ ok: true,
383
+ item: {
384
+ itemId,
385
+ issueNumber,
386
+ prNumber,
387
+ previousColumn,
388
+ newColumn: toColumn,
389
+ unchanged: false,
390
+ },
391
+ };
392
+ }
393
+
394
+ export { main, classifyExitCode };
@@ -0,0 +1,183 @@
1
+ import { readFileSync } from "node:fs";
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
+ // Shape of the node IDs this tooling actually receives from the Projects V2
35
+ // API (e.g. "PVT_…", "PVTI_…", "I_…", "PR_…"): a type prefix, "_", then a
36
+ // base64url-ish payload. Intentionally narrower than an arbitrary GraphQL
37
+ // global node ID (which is opaque and carries no guaranteed prefix_payload
38
+ // shape). The payload alphabet includes "-" and "_" (base64url), which a
39
+ // plain [A-Za-z0-9_] class rejects — the exact shape that broke real PVTI_
40
+ // ids containing a hyphen.
41
+ const NODE_ID_RE = /^[A-Za-z]+_[A-Za-z0-9_-]+$/;
42
+
43
+ // Parse a --project value into { kind:"id"|"number"|"uri", ... }. Throws
44
+ // INVALID_PROJECT on empty/malformed input (bare "0" is rejected too).
45
+ //
46
+ // Supported forms:
47
+ // <n> positive integer → { kind:"number", value:<n> }
48
+ // <NODE_ID> project node ID (prefix_payload shape) → { kind:"id", value:<NODE_ID> }
49
+ // https://github.com/users/<login>/projects/<n>
50
+ // https://github.com/orgs/<login>/projects/<n>
51
+ // board URI → { kind:"uri", number:<n>, owner:<login>, ownerKind:"user"|"org" }
52
+
53
+ // GitHub Projects V2 board URI pattern (user- or org-scoped boards).
54
+ const BOARD_URI_RE = /^https:\/\/github\.com\/(users|orgs)\/([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/projects\/(\d+)$/;
55
+
56
+ function parseProjectRef(raw) {
57
+ if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
58
+ throw Object.assign(new Error("--project is required"), { code: "INVALID_PROJECT" });
59
+ }
60
+ const trimmed = raw.trim();
61
+
62
+ // Board URI: https://github.com/users/<login>/projects/<n>
63
+ // https://github.com/orgs/<login>/projects/<n>
64
+ const uriMatch = BOARD_URI_RE.exec(trimmed);
65
+ if (uriMatch) {
66
+ const ownerKind = uriMatch[1] === "users" ? "user" : "org";
67
+ const owner = uriMatch[2];
68
+ const number = Number(uriMatch[3]);
69
+ if (number < 1) {
70
+ throw Object.assign(
71
+ new Error(`--project board URI must reference a positive project number, got "${raw}"`),
72
+ { code: "INVALID_PROJECT" },
73
+ );
74
+ }
75
+ return { kind: "uri", number, owner, ownerKind };
76
+ }
77
+
78
+ const asNum = Number(trimmed);
79
+ if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
80
+ return { kind: "number", value: asNum };
81
+ }
82
+ // Reject bare "0" — valid node ID character but not a meaningful project reference
83
+ if (trimmed === "0") {
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
+ if (NODE_ID_RE.test(trimmed)) {
90
+ return { kind: "id", value: trimmed };
91
+ }
92
+ throw Object.assign(
93
+ new Error(`--project must be a positive integer, a node ID, or a board URI, got "${raw}"`),
94
+ { code: "INVALID_PROJECT" },
95
+ );
96
+ }
97
+
98
+ // Parse an item ref into { kind:"id"|"number" }. Throws INVALID_ITEM on
99
+ // empty/malformed input (bare "0" is rejected too). Shared by every item-ref
100
+ // consumer (move-queue-item --item; reorder-queue-item --item/--after/
101
+ // positionals) so the accepted node-ID alphabet is defined once. `label`
102
+ // names the flag/argument in error messages.
103
+ //
104
+ // Supported forms:
105
+ // <n> positive integer → { kind:"number", value:<n> }
106
+ // <NODE_ID> item node ID → { kind:"id", value:<NODE_ID> }
107
+ function parseItemRef(raw, label = "--item") {
108
+ if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
109
+ throw Object.assign(new Error(`${label} is required`), { code: "INVALID_ITEM" });
110
+ }
111
+ const trimmed = raw.trim();
112
+ const asNum = Number(trimmed);
113
+ if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
114
+ return { kind: "number", value: asNum };
115
+ }
116
+ if (trimmed === "0") {
117
+ throw Object.assign(new Error(`${label} must be a positive integer or an item node ID, got "${raw}"`), { code: "INVALID_ITEM" });
118
+ }
119
+ if (NODE_ID_RE.test(trimmed)) {
120
+ return { kind: "id", value: trimmed };
121
+ }
122
+ throw Object.assign(new Error(`${label} must be a positive integer or an item node ID, got "${raw}"`), { code: "INVALID_ITEM" });
123
+ }
124
+
125
+ // Selector precedence: explicit --project ref wins; else resolve by board title
126
+ // from .devloops (passed as args.projectTitle by runCli). Fail closed if neither.
127
+ function resolveProjectSelector(args) {
128
+ const hasProjectRef = typeof args.project === "string" && args.project.trim().length > 0;
129
+ const projectRef = hasProjectRef ? parseProjectRef(args.project) : null;
130
+ const projectTitle = !hasProjectRef && typeof args.projectTitle === "string" && args.projectTitle.trim().length > 0
131
+ ? args.projectTitle.trim()
132
+ : null;
133
+ if (!projectRef && !projectTitle) {
134
+ throw Object.assign(
135
+ new Error("--project is required (or set queue.projectNumber / queue.boardTitle in .devloops)"),
136
+ { code: "INVALID_PROJECT" },
137
+ );
138
+ }
139
+ return { projectRef, projectTitle };
140
+ }
141
+
142
+ // Find the project in `projects` matching the resolved selector; throws
143
+ // PROJECT_NOT_FOUND (desc: "<id>" / number N / title "T" / URI number N under "<owner>").
144
+ function findProject(projects, { projectRef, projectTitle }, owner) {
145
+ let project;
146
+ if (projectRef) {
147
+ if (projectRef.kind === "id") {
148
+ project = projects.find((p) => p.id === projectRef.value);
149
+ } else if (projectRef.kind === "uri") {
150
+ project = projects.find((p) => p.number === projectRef.number);
151
+ } else {
152
+ project = projects.find((p) => p.number === projectRef.value);
153
+ }
154
+ } else {
155
+ project = projects.find((p) => p.title === projectTitle);
156
+ }
157
+ if (!project) {
158
+ const desc = projectRef
159
+ ? (projectRef.kind === "id"
160
+ ? `"${projectRef.value}"`
161
+ : projectRef.kind === "uri"
162
+ ? `URI number ${projectRef.number} under "${projectRef.owner}"`
163
+ : `number ${projectRef.value}`)
164
+ : `title "${projectTitle}"`;
165
+ throw Object.assign(
166
+ new Error(`Project ${desc} not found under owner "${owner}"`),
167
+ { code: "PROJECT_NOT_FOUND" },
168
+ );
169
+ }
170
+ return project;
171
+ }
172
+
173
+ // Apply .devloops board settings when --project was not passed. Precedence:
174
+ // explicit --project flag > queue.projectNumber/boardTitle. Mutates args.
175
+ function applyDevloopsBoard(args, cwd) {
176
+ if (args.project === undefined) {
177
+ const settings = resolveSettings(cwd);
178
+ if (settings?.project) args.project = String(settings.project);
179
+ else if (settings?.title) args.projectTitle = settings.title;
180
+ }
181
+ }
182
+
183
+ export { resolveSettings, parseProjectRef, parseItemRef, resolveProjectSelector, findProject, applyDevloopsBoard };