@dev-loops/core 1.0.2-pre.0 → 1.0.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.
@@ -1,24 +1,10 @@
1
1
  /**
2
2
  * Deterministic mid-flight operator steering contract for active dev loops.
3
3
  *
4
- * This module provides:
5
- * - STEERING_KIND: stable steering kind constants
6
- * - STEERING_RESULT: acknowledgement/result constants
7
- * - SAFE_POINT_CATEGORY: safe-point classification constants
8
- * - normalizeSteeringEvent: validate and canonicalize a raw steering event
9
- * - normalizeSteeringState: load and validate persisted steering state
10
- * - createSteeringState: create a fresh steering state for a new run
11
- * - classifySafePoint: map a copilot loop state to a safe-point category
12
- * - submitSteering: process a steering event against current run state
13
- * - promoteQueuedSteering: apply queued steering when the loop reaches a safe point
14
- * - getEffectiveConstraints: get the current effective steering constraints
15
- * - resolveEffectiveLoopState: get loop interpretation augmented with active steering
16
- * - getSteeringStatus: get full inspection output for a run's steering state
17
- *
18
4
  * The proving target for this first implementation slice is the async Copilot
19
- * review/fix loop (copilot-loop-state.mjs). Safe-point rules are defined for
20
- * that loop's state set and the resolveEffectiveLoopState integration changes
21
- * loop behavior when stop_at_next_safe_gate steering is active.
5
+ * review/fix loop (copilot-loop-state.mjs): safe-point rules are defined for
6
+ * that loop's state set, and resolveEffectiveLoopState changes loop behavior
7
+ * when stop_at_next_safe_gate steering is active.
22
8
  */
23
9
 
24
10
  import { normalizeRepoSlug } from "../github/repo-slug.mjs";
@@ -74,46 +60,30 @@ const VALID_APPLY_MODES = new Set(["immediate", "next_safe_point"]);
74
60
  // ---------------------------------------------------------------------------
75
61
 
76
62
  /**
77
- * Map a copilot loop state to a safe-point category.
78
- *
79
- * Safe-point rules for the async Copilot review/fix loop:
80
- *
81
- * IMMEDIATE — between steps / idle / waiting on external state.
82
- * Steering can be applied right now without risk of splitting a mutation.
83
- * States: pr_ready_no_feedback, waiting_for_copilot_review,
84
- * waiting_for_ci, ready_to_rerequest_review
85
- *
86
- * NEXT_POINT — actively computing or in a non-interruptible mutation.
87
- * Applying steering now could produce a half-applied or inconsistent state.
88
- * Queue the event and promote it when the loop next reaches an IMMEDIATE state.
89
- * States: pr_draft, unresolved_feedback_present, already_fixed_needs_reply_resolve
90
- *
91
- * TERMINAL — run is done, irreversibly failed, or has no active run.
92
- * Steering is rejected; it would have no effect or could mask a real error.
93
- * States: no_pr, done, review_request_unavailable, blocked_needs_user_decision
63
+ * Map a copilot loop state to a safe-point category (async Copilot
64
+ * review/fix loop): IMMEDIATE states can apply steering right now; NEXT_POINT
65
+ * states are mid-mutation and queue the event for the next IMMEDIATE state;
66
+ * TERMINAL states reject steering (no active/recoverable run).
94
67
  *
95
68
  * @param {string} loopState - a copilot loop STATE value
96
69
  * @returns {"immediate"|"next_point"|"terminal"}
97
70
  */
98
71
  export function classifySafePoint(loopState) {
99
72
  switch (loopState) {
100
- // Between steps / idle
73
+ // Idle / waiting on external state.
101
74
  case STATE.PR_READY_NO_FEEDBACK:
102
75
  case STATE.READY_TO_REREQUEST_REVIEW:
103
- // Waiting on external state
104
76
  case STATE.WAITING_FOR_COPILOT_REVIEW:
105
77
  case STATE.WAITING_FOR_CI:
106
78
  return SAFE_POINT_CATEGORY.IMMEDIATE;
107
79
 
108
- // In a pre-ready state (not yet at a mutation gate)
80
+ // Actively computing / non-interruptible mutation.
109
81
  case STATE.PR_DRAFT:
110
- // Actively computing — about to apply fixes to resolve feedback
111
82
  case STATE.UNRESOLVED_FEEDBACK_PRESENT:
112
- // In the middle of a non-interruptible mutation (reply/resolve review threads)
113
83
  case STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE:
114
84
  return SAFE_POINT_CATEGORY.NEXT_POINT;
115
85
 
116
- // Terminal states: run is done, error, or has no active run
86
+ // Terminal: run done, errored, or no active run.
117
87
  case STATE.NO_PR:
118
88
  case STATE.DONE:
119
89
  case STATE.REVIEW_REQUEST_UNAVAILABLE:
@@ -418,20 +388,9 @@ function hasStopAtNextSafeGate(events) {
418
388
 
419
389
  /**
420
390
  * Process a steering event against current run state and produce an
421
- * acknowledgement/result plus updated steering state.
422
- *
423
- * This is the main entry point for operators submitting mid-flight corrections.
424
- *
425
- * Result semantics:
426
- * - applied_now: event is immediately effective; effectiveStack is updated.
427
- * - queued_for_safe_point: loop is in a non-safe state; event is queued and
428
- * will be promoted by promoteQueuedSteering when the loop reaches a safe point.
429
- * - rejected_unsafe_now: terminal loop state (done/unavailable/no_pr); steering
430
- * would have no effect or could mask a real issue.
431
- * - rejected_invalid_or_conflicting: event is malformed, has an out-of-order seq,
432
- * or exactly duplicates an existing hard_constraint.
433
- * - needs_human_decision: loop is in blocked_needs_user_decision; human must act
434
- * before automated steering can be safely applied.
391
+ * acknowledgement/result plus updated steering state. Main entry point for
392
+ * operators submitting mid-flight corrections; see {@link STEERING_RESULT}
393
+ * for the possible outcomes.
435
394
  *
436
395
  * @param {object} event - normalized steering event (from normalizeSteeringEvent)
437
396
  * @param {object} steeringState - current steering state (from normalizeSteeringState)
@@ -719,7 +678,7 @@ export function getEffectiveConstraints(steeringState) {
719
678
  * @param {object} snapshot - raw or normalized loop snapshot
720
679
  * @param {object} steeringState - current steering state for this run
721
680
  * @param {object} [refinementConfig] - interpreter refinement config; pass a config-derived
722
- * `resolveRefinement(config)` so the base interpretation honors gates.preApproval.requireCi:false (#1337).
681
+ * `resolveRefinement(config)` so the base interpretation honors gates.preApproval.requireCi:false.
723
682
  * @returns {{ state: string, allowedTransitions: string[], nextAction: string, steeringApplied: boolean, pendingStopAtNextSafeGate: boolean, terminalStopAtNextSafeGate: boolean, effectiveConstraints: object }}
724
683
  */
725
684
  export function resolveEffectiveLoopState(snapshot, steeringState, refinementConfig) {
@@ -760,19 +719,8 @@ export function resolveEffectiveLoopState(snapshot, steeringState, refinementCon
760
719
  // ---------------------------------------------------------------------------
761
720
 
762
721
  /**
763
- * Get full inspection output for a run's steering state.
764
- *
765
- * Returns:
766
- * - runId: the target run
767
- * - schemaVersion: for migration awareness
768
- * - eventCount: total events submitted
769
- * - queuedCount: events waiting for the next safe point
770
- * - effectiveStackCount: events currently in effect
771
- * - effectiveConstraints: structured view over the effective stack
772
- * - latestResult: most recent acknowledgement/result
773
- * - resultHistory: all historical acknowledgements
774
- * - history: all submitted events
775
- * - nextSeq: next expected sequence number
722
+ * Get full inspection output for a run's steering state (event counts,
723
+ * effective constraints, result history, and next expected seq).
776
724
  *
777
725
  * @param {object} steeringState
778
726
  * @returns {object}
@@ -35,12 +35,13 @@ export function isUnderWorktreePath(cwd) {
35
35
  * @returns {string | null} The main worktree path, or null if it cannot be parsed.
36
36
  */
37
37
  export function parseMainWorktreePath(worktreeListOutput) {
38
- const firstLine = worktreeListOutput.split("\n")[0].trim();
39
- if (!firstLine) return null;
40
- // Find the first hex SHA (7+ chars) preceded by whitespace; take everything before it as the path.
41
- const shaIdx = firstLine.search(/\s[0-9a-f]{7,64}\b/iu);
42
- if (shaIdx === -1) return null;
43
- return firstLine.slice(0, shaIdx).trim();
38
+ // The main worktree is the FIRST parseable entry — derived from the same
39
+ // parser `parseAllWorktreePaths` uses, so the two can never disagree about
40
+ // which path is the main checkout. Parsing only the raw first line would
41
+ // return null on a leading blank/SHA-less line while `parseAllWorktreePaths`
42
+ // still parses the real main line, nulling the main-checkout guard while the
43
+ // path stays admittable (the leading-blank-line fail-open).
44
+ return parseAllWorktreePaths(worktreeListOutput)[0] ?? null;
44
45
  }
45
46
 
46
47
  /**
@@ -115,10 +116,15 @@ export function isListedWorktree(cwd, worktreePaths) {
115
116
  /**
116
117
  * Resolve the root of the listed git worktree that contains `cwd`.
117
118
  *
118
- * Mirrors `isListedWorktree`'s matching (realpath-resolved, tmp/worktrees/-scoped,
119
- * exact-or-subdirectory) but returns the worktree ROOT instead of a boolean, so
120
- * callers can address files relative to the worktree's own subtree (`packages/`,
121
- * `node_modules/`) rather than the possibly-nested `cwd`.
119
+ * Returns the worktree ROOT (realpath-resolved, exact-or-subdirectory match)
120
+ * instead of a boolean, so callers can address files relative to the worktree's
121
+ * own subtree (`packages/`, `node_modules/`) rather than the possibly-nested
122
+ * `cwd`. Matches ANY listed worktree — not just `tmp/worktrees/`-scoped ones —
123
+ * so the core-isolation invariant is evaluable for a sibling/linked checkout
124
+ * that lives outside `tmp/worktrees/`; the caller decides admit/reject.
125
+ * When `cwd` sits under nested worktrees (a `tmp/worktrees/` child inside its
126
+ * parent checkout), the LONGEST matching root wins, so the innermost worktree's
127
+ * own subtree is addressed regardless of `git worktree list` order.
122
128
  *
123
129
  * @param {string} cwd - Absolute or relative path inside the worktree.
124
130
  * @param {string[]} worktreePaths - Array of paths from `parseAllWorktreePaths`.
@@ -128,16 +134,16 @@ export function resolveContainingWorktreeRoot(cwd, worktreePaths) {
128
134
  let resolvedCwd;
129
135
  try { resolvedCwd = realpathSync(cwd); } catch { resolvedCwd = cwd; }
130
136
  const normalizedCwd = resolvedCwd.replace(/\\/g, "/").replace(/\/+$/u, "");
137
+ let best = null;
131
138
  for (const p of worktreePaths) {
132
139
  let resolvedP;
133
140
  try { resolvedP = realpathSync(p); } catch { resolvedP = p; }
134
141
  const normalizedP = resolvedP.replace(/\\/g, "/").replace(/\/+$/u, "");
135
- if (!isUnderWorktreePath(normalizedP)) continue;
136
142
  if (normalizedCwd === normalizedP || normalizedCwd.startsWith(normalizedP + "/")) {
137
- return normalizedP;
143
+ if (best === null || normalizedP.length > best.length) best = normalizedP;
138
144
  }
139
145
  }
140
- return null;
146
+ return best;
141
147
  }
142
148
 
143
149
  /**
@@ -191,6 +197,67 @@ export function isWorktreeCoreIsolated(cwd, worktreePaths) {
191
197
  return linkReal === coreReal;
192
198
  }
193
199
 
200
+ /**
201
+ * Shared admit/reject decision for local-implementation worktree isolation.
202
+ *
203
+ * The single source of truth both enforcement sites route through
204
+ * (`pre-flight-gate.mjs` `checkWorktreeIsolation` and
205
+ * `resolve-dev-loop-startup.mjs`'s `local_implementation` block), so they
206
+ * cannot diverge. Each caller maps the returned `error`/`detail` to its own
207
+ * guidance/reason wording; this function owns only the decision.
208
+ *
209
+ * `tmp/worktrees/` stays the default and recommended location, but it is not
210
+ * the invariant. The real invariant is core isolation: a checkout's
211
+ * `node_modules/@dev-loops/core` resolves to its OWN `packages/core`.
212
+ * A checkout OUTSIDE `tmp/worktrees/` that is not the main checkout and
213
+ * satisfies that invariant is admitted rather than rejected on path prefix
214
+ * alone; one that does not satisfy it (its core link escapes its own
215
+ * `packages/core`) fails closed.
216
+ *
217
+ * Decision order:
218
+ * - outside `tmp/worktrees/` + main checkout -> reject `main_checkout_detected`
219
+ * - outside `tmp/worktrees/` + unresolvable worktree root -> reject `not_in_worktree` (fail closed, no vacuous admit)
220
+ * - outside `tmp/worktrees/` + core-isolated -> ADMIT (verified-isolation)
221
+ * - outside `tmp/worktrees/` + not isolated -> reject `not_in_worktree`
222
+ * - under `tmp/worktrees/` + not a real worktree -> reject `not_in_worktree`
223
+ * - under `tmp/worktrees/` + core link escapes -> reject `core_link_escapes`
224
+ * - otherwise -> ADMIT
225
+ *
226
+ * @param {object} params
227
+ * @param {string} params.cwd - Current working directory (absolute or relative).
228
+ * @param {string | null} params.mainWorktreePath - From `parseMainWorktreePath`.
229
+ * @param {string[]} params.allWorktreePaths - From `parseAllWorktreePaths`.
230
+ * @returns {{ ok: true } | { ok: false, error: string, detail: string }}
231
+ */
232
+ export function classifyWorktreeIsolation({ cwd, mainWorktreePath, allWorktreePaths }) {
233
+ if (!isUnderWorktreePath(cwd)) {
234
+ if (mainWorktreePath !== null && isMainCheckout(cwd, mainWorktreePath)) {
235
+ return { ok: false, error: "main_checkout_detected", detail: "main_checkout" };
236
+ }
237
+ // Outside tmp/worktrees and not the main checkout: assert the REAL
238
+ // core-isolation invariant instead of rejecting on path prefix alone.
239
+ // The invariant is only meaningful for a checkout that resolves to a real
240
+ // listed git worktree root; when the root cannot be resolved (an unlisted
241
+ // checkout, or an empty/unparseable `git worktree list` — which also nulls
242
+ // mainWorktreePath and skips the main-checkout guard above) the core-isolation
243
+ // check would vacuously return true, so fail closed rather than admit.
244
+ if (resolveContainingWorktreeRoot(cwd, allWorktreePaths) === null) {
245
+ return { ok: false, error: "not_in_worktree", detail: "outside_not_isolated" };
246
+ }
247
+ if (isWorktreeCoreIsolated(cwd, allWorktreePaths)) {
248
+ return { ok: true };
249
+ }
250
+ return { ok: false, error: "not_in_worktree", detail: "outside_not_isolated" };
251
+ }
252
+ if (!isListedWorktree(cwd, allWorktreePaths)) {
253
+ return { ok: false, error: "not_in_worktree", detail: "fake_worktree" };
254
+ }
255
+ if (!isWorktreeCoreIsolated(cwd, allWorktreePaths)) {
256
+ return { ok: false, error: "core_link_escapes", detail: "core_escapes" };
257
+ }
258
+ return { ok: true };
259
+ }
260
+
194
261
 
195
262
  /**
196
263
  * Realpath-normalize a path that MAY NOT EXIST yet.
@@ -1,77 +1,14 @@
1
1
  import { runChild as _runChild } from "../cli/primitives.mjs";
2
2
  import { resolveProjectSelector, findProject } from "./resolve-project.mjs";
3
- import { ghGraphql, resolveOwner } from "../github/gh.mjs";
3
+ import { resolveOwner } from "../github/gh.mjs";
4
+ import { validateProjectsRepo, discoverProjects, listProjectFields, paginateNodes, extractStatus } from "./projects-access.mjs";
4
5
 
5
6
  // ── Validation ───────────────────────────────────────────────────────────
6
7
 
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(
17
- new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`),
18
- { code: "INVALID_REPO" },
19
- );
20
- }
21
- const slashIdx = repo.indexOf("/");
22
- if (slashIdx === -1) {
23
- throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
24
- }
25
- const owner = repo.slice(0, slashIdx);
26
- const name = repo.slice(slashIdx + 1);
27
- if (!owner || !name || !OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
28
- throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
29
- }
30
- return repo;
31
- }
8
+ const validateRepo = validateProjectsRepo;
32
9
 
33
10
  // ── GraphQL fragments ────────────────────────────────────────────────────
34
11
 
35
- const LIST_USER_PROJECTS = [
36
- "query($login:String!, $after:String) {",
37
- " user(login:$login) {",
38
- " projectsV2(first:50, after:$after) {",
39
- " pageInfo { hasNextPage endCursor }",
40
- " nodes { id number title url }",
41
- " }",
42
- " }",
43
- "}"
44
- ].join("\n");
45
-
46
- const LIST_ORG_PROJECTS = [
47
- "query($login:String!, $after:String) {",
48
- " organization(login:$login) {",
49
- " projectsV2(first:50, after:$after) {",
50
- " pageInfo { hasNextPage endCursor }",
51
- " nodes { id number title url }",
52
- " }",
53
- " }",
54
- "}"
55
- ].join("\n");
56
-
57
- const GET_PROJECT_FIELDS = [
58
- "query($projectId:ID!, $after:String) {",
59
- " node(id:$projectId) {",
60
- " ... on ProjectV2 {",
61
- " fields(first:50, after:$after) {",
62
- " pageInfo { hasNextPage endCursor }",
63
- " nodes {",
64
- " ... on ProjectV2SingleSelectField {",
65
- " id name",
66
- " options { id name }",
67
- " }",
68
- " }",
69
- " }",
70
- " }",
71
- " }",
72
- "}"
73
- ].join("\n");
74
-
75
12
  const GET_PROJECT_ITEMS = [
76
13
  "query($projectId:ID!, $after:String) {",
77
14
  " node(id:$projectId) {",
@@ -99,82 +36,22 @@ const GET_PROJECT_ITEMS = [
99
36
  "}"
100
37
  ].join("\n");
101
38
 
102
- // ── Paginated project listing ────────────────────────────────────────────
103
-
104
- async function listAllProjects(login, kind, env, runChild) {
105
- const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
106
- const projects = [];
107
- let after = null;
108
- while (true) {
109
- const vars = { login };
110
- if (after) vars.after = after;
111
- const payload = await ghGraphql(query, vars, env, runChild);
112
- const connection = kind === "org"
113
- ? payload?.data?.organization?.projectsV2
114
- : payload?.data?.user?.projectsV2;
115
- const nodes = connection?.nodes ?? [];
116
- projects.push(...nodes);
117
- const pageInfo = connection?.pageInfo ?? {};
118
- if (!pageInfo.hasNextPage) break;
119
- if (!pageInfo.endCursor) {
120
- throw Object.assign(
121
- new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
122
- { code: "GH_API_ERROR" },
123
- );
124
- }
125
- after = pageInfo.endCursor;
126
- }
127
- return projects;
128
- }
129
-
130
- // ── Paginated field listing ──────────────────────────────────────────────
39
+ // ── Paginated project + field listing (shared via projects-access) ────────
131
40
 
132
- async function listAllFields(projectId, env, runChild) {
133
- const fields = [];
134
- let after = null;
135
- while (true) {
136
- const vars = { projectId };
137
- if (after) vars.after = after;
138
- const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
139
- const connection = payload?.data?.node?.fields;
140
- const nodes = connection?.nodes ?? [];
141
- fields.push(...nodes);
142
- const pageInfo = connection?.pageInfo ?? {};
143
- if (!pageInfo.hasNextPage) break;
144
- if (!pageInfo.endCursor) {
145
- throw Object.assign(
146
- new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
147
- { code: "GH_API_ERROR" },
148
- );
149
- }
150
- after = pageInfo.endCursor;
151
- }
152
- return fields;
153
- }
41
+ const listAllProjects = discoverProjects;
42
+ const listAllFields = listProjectFields;
154
43
 
155
44
  // ── Paginated item listing ───────────────────────────────────────────────
156
45
 
157
- async function listAllItems(projectId, env, runChild) {
158
- const items = [];
159
- let after = null;
160
- while (true) {
161
- const vars = { projectId };
162
- if (after) vars.after = after;
163
- const payload = await ghGraphql(GET_PROJECT_ITEMS, vars, env, runChild);
164
- const connection = payload?.data?.node?.items;
165
- const nodes = connection?.nodes ?? [];
166
- items.push(...nodes);
167
- const pageInfo = connection?.pageInfo ?? {};
168
- if (!pageInfo.hasNextPage) break;
169
- if (!pageInfo.endCursor) {
170
- throw Object.assign(
171
- new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
172
- { code: "GH_API_ERROR" },
173
- );
174
- }
175
- after = pageInfo.endCursor;
176
- }
177
- return items;
46
+ function listAllItems(projectId, env, runChild) {
47
+ return paginateNodes({
48
+ query: GET_PROJECT_ITEMS,
49
+ variables: { projectId },
50
+ selectConnection: (payload) => payload?.data?.node?.items,
51
+ env,
52
+ runChild,
53
+ entity: "items",
54
+ });
178
55
  }
179
56
 
180
57
  // ── Exit code classification ────────────────────────────────────────────
@@ -260,14 +137,7 @@ async function main(args, { env = process.env, runChild } = {}) {
260
137
  if (!content) continue;
261
138
 
262
139
  // Determine status from field values
263
- let status = null;
264
- const fieldValues = item.fieldValues?.nodes ?? [];
265
- for (const fv of fieldValues) {
266
- if (fv && fv.field && fv.field.name === "Status") {
267
- status = fv.name;
268
- break;
269
- }
270
- }
140
+ const status = extractStatus(item);
271
141
 
272
142
  // Filter by column
273
143
  if (args.column && status !== args.column) continue;
@@ -3,74 +3,14 @@ import { runPickupRefinementGate } from "../loop/issue-refinement-artifact.mjs";
3
3
  import { loadStateColumnMap, LOGICAL_COLUMN } from "../loop/queue-board-sync.mjs";
4
4
  import { resolveProjectSelector, findProject, parseItemRef } from "./resolve-project.mjs";
5
5
  import { ghGraphql, resolveOwner } from "../github/gh.mjs";
6
+ import { validateProjectsRepo, discoverProjects, listProjectFields, paginateNodes, extractStatus } from "./projects-access.mjs";
6
7
 
7
8
  // ── Validation ───────────────────────────────────────────────────────────
8
9
 
9
- const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
10
- const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
11
-
12
- function validateRepo(repo) {
13
- if (!repo || typeof repo !== "string") {
14
- throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO" });
15
- }
16
- const trimmed = repo.trim();
17
- if (trimmed !== repo) {
18
- throw Object.assign(new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`), { code: "INVALID_REPO" });
19
- }
20
- const slashIdx = repo.indexOf("/");
21
- if (slashIdx === -1) {
22
- throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
23
- }
24
- const owner = repo.slice(0, slashIdx);
25
- const name = repo.slice(slashIdx + 1);
26
- if (!owner || !name || !OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
27
- throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
28
- }
29
- return repo;
30
- }
10
+ const validateRepo = validateProjectsRepo;
31
11
 
32
12
  // ── GraphQL fragments ────────────────────────────────────────────────────
33
13
 
34
- const LIST_USER_PROJECTS = [
35
- "query($login:String!, $after:String) {",
36
- " user(login:$login) {",
37
- " projectsV2(first:50, after:$after) {",
38
- " pageInfo { hasNextPage endCursor }",
39
- " nodes { id number title url }",
40
- " }",
41
- " }",
42
- "}"
43
- ].join("\n");
44
-
45
- const LIST_ORG_PROJECTS = [
46
- "query($login:String!, $after:String) {",
47
- " organization(login:$login) {",
48
- " projectsV2(first:50, after:$after) {",
49
- " pageInfo { hasNextPage endCursor }",
50
- " nodes { id number title url }",
51
- " }",
52
- " }",
53
- "}"
54
- ].join("\n");
55
-
56
- const GET_PROJECT_FIELDS = [
57
- "query($projectId:ID!, $after:String) {",
58
- " node(id:$projectId) {",
59
- " ... on ProjectV2 {",
60
- " fields(first:50, after:$after) {",
61
- " pageInfo { hasNextPage endCursor }",
62
- " nodes {",
63
- " ... on ProjectV2SingleSelectField {",
64
- " id name",
65
- " options { id name }",
66
- " }",
67
- " }",
68
- " }",
69
- " }",
70
- " }",
71
- "}"
72
- ].join("\n");
73
-
74
14
  const GET_PROJECT_ITEMS_BY_CONTENT = [
75
15
  "query($projectId:ID!, $after:String) {",
76
16
  " node(id:$projectId) {",
@@ -108,91 +48,25 @@ const UPDATE_ITEM_FIELD = [
108
48
  "}"
109
49
  ].join("\n");
110
50
 
111
- // ── Paginated project listing ────────────────────────────────────────────
112
-
113
- async function listAllProjects(login, kind, env, runChild) {
114
- const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
115
- const projects = [];
116
- let after = null;
117
- while (true) {
118
- const vars = { login };
119
- if (after) vars.after = after;
120
- const payload = await ghGraphql(query, vars, env, runChild);
121
- const connection = kind === "org"
122
- ? payload?.data?.organization?.projectsV2
123
- : payload?.data?.user?.projectsV2;
124
- const nodes = connection?.nodes ?? [];
125
- projects.push(...nodes);
126
- const pageInfo = connection?.pageInfo ?? {};
127
- if (!pageInfo.hasNextPage) break;
128
- if (!pageInfo.endCursor) {
129
- throw Object.assign(
130
- new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
131
- { code: "GH_API_ERROR" },
132
- );
133
- }
134
- after = pageInfo.endCursor;
135
- }
136
- return projects;
137
- }
51
+ // ── Paginated project + field listing (shared via projects-access) ────────
138
52
 
139
- // ── Paginated field listing ──────────────────────────────────────────────
140
-
141
- async function listAllFields(projectId, env, runChild) {
142
- const fields = [];
143
- let after = null;
144
- while (true) {
145
- const vars = { projectId };
146
- if (after) vars.after = after;
147
- const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
148
- const connection = payload?.data?.node?.fields;
149
- const nodes = connection?.nodes ?? [];
150
- fields.push(...nodes);
151
- const pageInfo = connection?.pageInfo ?? {};
152
- if (!pageInfo.hasNextPage) break;
153
- if (!pageInfo.endCursor) {
154
- throw Object.assign(
155
- new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
156
- { code: "GH_API_ERROR" },
157
- );
158
- }
159
- after = pageInfo.endCursor;
160
- }
161
- return fields;
162
- }
53
+ const listAllProjects = discoverProjects;
54
+ const listAllFields = listProjectFields;
163
55
 
164
56
  // ── Paginated item listing (position order) ──────────────────────────────
165
57
 
166
- async function fetchAllItems(projectId, env, runChild) {
167
- const items = [];
168
- let after = null;
169
- while (true) {
170
- const vars = { projectId };
171
- if (after) vars.after = after;
172
- const payload = await ghGraphql(GET_PROJECT_ITEMS_BY_CONTENT, vars, env, runChild);
173
- const connection = payload?.data?.node?.items;
174
- const nodes = connection?.nodes ?? [];
175
- items.push(...nodes);
176
- const pageInfo = connection?.pageInfo ?? {};
177
- if (!pageInfo.hasNextPage) break;
178
- if (!pageInfo.endCursor) {
179
- throw Object.assign(
180
- new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
181
- { code: "GH_API_ERROR" },
182
- );
183
- }
184
- after = pageInfo.endCursor;
185
- }
186
- return items;
58
+ function fetchAllItems(projectId, env, runChild) {
59
+ return paginateNodes({
60
+ query: GET_PROJECT_ITEMS_BY_CONTENT,
61
+ variables: { projectId },
62
+ selectConnection: (payload) => payload?.data?.node?.items,
63
+ env,
64
+ runChild,
65
+ entity: "items",
66
+ });
187
67
  }
188
68
 
189
- function statusOf(node) {
190
- const fvs = node?.fieldValues?.nodes ?? [];
191
- for (const fv of fvs) {
192
- if (fv && fv.field && fv.field.name === "Status") return fv.name;
193
- }
194
- return null;
195
- }
69
+ const statusOf = extractStatus;
196
70
 
197
71
  // ── Exit code classification ────────────────────────────────────────────
198
72