@dev-loops/core 1.0.1 → 1.0.2-slim.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/package.json +5 -2
- package/src/analysis/diff-analyzer.mjs +85 -137
- package/src/claude/asset-generation.mjs +7 -7
- package/src/claude/hook-decisions.mjs +167 -50
- package/src/config/config.mjs +388 -787
- package/src/config/extension-defaults.yaml +14 -9
- package/src/github/comment-id-guard.mjs +39 -1
- package/src/github/copilot-helpers.mjs +90 -158
- package/src/github/gh.mjs +49 -0
- package/src/loop/bash-command-classify.mjs +34 -49
- package/src/loop/commit-msg-guard.mjs +1 -1
- package/src/loop/conductor-routing.mjs +15 -23
- package/src/loop/copilot-loop-state.mjs +46 -94
- package/src/loop/gate-carry-forward.mjs +46 -22
- package/src/loop/gate-evidence-reconcile.mjs +75 -0
- package/src/loop/gate-fanin.mjs +266 -435
- package/src/loop/handoff-envelope.mjs +21 -21
- package/src/loop/issue-refinement-artifact.mjs +449 -284
- package/src/loop/lifecycle-state.mjs +10 -21
- package/src/loop/pr-gate-coordination.mjs +49 -49
- package/src/loop/queue-board-sync.mjs +16 -82
- package/src/loop/review-dispatch-plan.mjs +61 -122
- package/src/loop/review-lineage.mjs +19 -44
- package/src/loop/spec-authority.mjs +729 -0
- package/src/loop/steering.mjs +16 -68
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/worktree-guard.mjs +55 -0
- package/src/projects/list-queue-items.mjs +16 -175
- package/src/projects/move-queue-item.mjs +16 -171
- package/src/projects/projects-access.mjs +202 -0
- package/src/security/secret-scan.mjs +13 -1
package/src/loop/steering.mjs
CHANGED
|
@@ -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)
|
|
20
|
-
* that loop's state set and
|
|
21
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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}
|
|
@@ -39,6 +39,7 @@ export const REGISTERED_ARTIFACT_PATHS = Object.freeze([
|
|
|
39
39
|
"docs/presentations/introducing-dev-loops.html",
|
|
40
40
|
"docs/presentations/dev-loops-deep-dive.html",
|
|
41
41
|
"docs/presentations/how-dev-loops-decided-itself.html",
|
|
42
|
+
"docs/presentations/state-graph-surface.html",
|
|
42
43
|
"docs/articles/introducing-dev-loops.html",
|
|
43
44
|
"docs/articles/dev-loops-deep-dive.html",
|
|
44
45
|
"docs/articles/how-dev-loops-decided-itself.html",
|
|
@@ -192,6 +192,61 @@ export function isWorktreeCoreIsolated(cwd, worktreePaths) {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Realpath-normalize a path that MAY NOT EXIST yet.
|
|
197
|
+
*
|
|
198
|
+
* `realpathSync` throws ENOENT on a nonexistent leaf — which is exactly a `Write`
|
|
199
|
+
* creating a NEW file. Resolving only the roots (worktree/main) while leaving the
|
|
200
|
+
* target un-normalized makes an under-a-symlinked-ancestor comparison asymmetric,
|
|
201
|
+
* so a wrong-checkout new-file write could be misclassified. This resolves the
|
|
202
|
+
* realpath of the target's NEAREST EXISTING ancestor and rejoins the nonexistent
|
|
203
|
+
* tail, so the returned path shares the same symlink-resolved prefix the roots do.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} p - Absolute or relative path (possibly not yet existing).
|
|
206
|
+
* @returns {string} A realpath-normalized absolute path (forward-slash, no trailing slash).
|
|
207
|
+
*/
|
|
208
|
+
export function realpathNearestExisting(p) {
|
|
209
|
+
const abs = path.resolve(p);
|
|
210
|
+
let dir = abs;
|
|
211
|
+
const tail = [];
|
|
212
|
+
// Walk up to the nearest existing ancestor.
|
|
213
|
+
for (;;) {
|
|
214
|
+
try {
|
|
215
|
+
const real = realpathSync(dir);
|
|
216
|
+
const joined = tail.length ? path.join(real, ...tail) : real;
|
|
217
|
+
return joined.replace(/\\/g, "/").replace(/\/+$/u, "");
|
|
218
|
+
} catch {
|
|
219
|
+
const parent = path.dirname(dir);
|
|
220
|
+
if (parent === dir) {
|
|
221
|
+
// Reached the filesystem root without an existing ancestor — fall back
|
|
222
|
+
// to the literal resolved path (nothing to realpath against).
|
|
223
|
+
return abs.replace(/\\/g, "/").replace(/\/+$/u, "");
|
|
224
|
+
}
|
|
225
|
+
tail.unshift(path.basename(dir));
|
|
226
|
+
dir = parent;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Map a `git check-ignore -q` outcome to whether a MAIN-checkout target must be
|
|
233
|
+
* treated as a guarded (non-gitignored) file, failing SAFE on an unresolvable
|
|
234
|
+
* outcome.
|
|
235
|
+
*
|
|
236
|
+
* `git check-ignore -q` exits 0 when the path IS gitignored and 1 when it is NOT.
|
|
237
|
+
* Any other exit (git error, missing binary) is UNRESOLVABLE: the wrong-checkout
|
|
238
|
+
* guard must not silently allow a write it cannot classify, so an unresolvable
|
|
239
|
+
* outcome is treated as guarded (deny) rather than ignored (allow).
|
|
240
|
+
*
|
|
241
|
+
* @param {number|null|undefined} checkIgnoreStatus - The `git check-ignore -q` exit status (null when it could not run).
|
|
242
|
+
* @returns {boolean} true when the target should be guarded (not-ignored, or unresolvable → fail-safe); false only when it is confirmed gitignored (exit 0).
|
|
243
|
+
*/
|
|
244
|
+
export function resolveTrackedFromCheckIgnore(checkIgnoreStatus) {
|
|
245
|
+
if (checkIgnoreStatus === 0) return false; // confirmed gitignored — not guarded
|
|
246
|
+
if (checkIgnoreStatus === 1) return true; // confirmed not-ignored — guarded
|
|
247
|
+
return true; // unresolvable — fail safe (AC4)
|
|
248
|
+
}
|
|
249
|
+
|
|
195
250
|
|
|
196
251
|
// ---------------------------------------------------------------------------
|
|
197
252
|
// Subagent availability
|
|
@@ -1,89 +1,14 @@
|
|
|
1
1
|
import { runChild as _runChild } from "../cli/primitives.mjs";
|
|
2
2
|
import { resolveProjectSelector, findProject } from "./resolve-project.mjs";
|
|
3
|
-
import {
|
|
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
|
|
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 GET_USER_ID = [
|
|
36
|
-
"query($login:String!) {",
|
|
37
|
-
" user(login:$login) { id }",
|
|
38
|
-
"}"
|
|
39
|
-
].join("\n");
|
|
40
|
-
|
|
41
|
-
const GET_ORG_ID = [
|
|
42
|
-
"query($login:String!) {",
|
|
43
|
-
" organization(login:$login) { id }",
|
|
44
|
-
"}"
|
|
45
|
-
].join("\n");
|
|
46
|
-
|
|
47
|
-
const LIST_USER_PROJECTS = [
|
|
48
|
-
"query($login:String!, $after:String) {",
|
|
49
|
-
" user(login:$login) {",
|
|
50
|
-
" projectsV2(first:50, after:$after) {",
|
|
51
|
-
" pageInfo { hasNextPage endCursor }",
|
|
52
|
-
" nodes { id number title url }",
|
|
53
|
-
" }",
|
|
54
|
-
" }",
|
|
55
|
-
"}"
|
|
56
|
-
].join("\n");
|
|
57
|
-
|
|
58
|
-
const LIST_ORG_PROJECTS = [
|
|
59
|
-
"query($login:String!, $after:String) {",
|
|
60
|
-
" organization(login:$login) {",
|
|
61
|
-
" projectsV2(first:50, after:$after) {",
|
|
62
|
-
" pageInfo { hasNextPage endCursor }",
|
|
63
|
-
" nodes { id number title url }",
|
|
64
|
-
" }",
|
|
65
|
-
" }",
|
|
66
|
-
"}"
|
|
67
|
-
].join("\n");
|
|
68
|
-
|
|
69
|
-
const GET_PROJECT_FIELDS = [
|
|
70
|
-
"query($projectId:ID!, $after:String) {",
|
|
71
|
-
" node(id:$projectId) {",
|
|
72
|
-
" ... on ProjectV2 {",
|
|
73
|
-
" fields(first:50, after:$after) {",
|
|
74
|
-
" pageInfo { hasNextPage endCursor }",
|
|
75
|
-
" nodes {",
|
|
76
|
-
" ... on ProjectV2SingleSelectField {",
|
|
77
|
-
" id name",
|
|
78
|
-
" options { id name }",
|
|
79
|
-
" }",
|
|
80
|
-
" }",
|
|
81
|
-
" }",
|
|
82
|
-
" }",
|
|
83
|
-
" }",
|
|
84
|
-
"}"
|
|
85
|
-
].join("\n");
|
|
86
|
-
|
|
87
12
|
const GET_PROJECT_ITEMS = [
|
|
88
13
|
"query($projectId:ID!, $after:String) {",
|
|
89
14
|
" node(id:$projectId) {",
|
|
@@ -111,99 +36,22 @@ const GET_PROJECT_ITEMS = [
|
|
|
111
36
|
"}"
|
|
112
37
|
].join("\n");
|
|
113
38
|
|
|
114
|
-
// ──
|
|
115
|
-
|
|
116
|
-
async function resolveOwner(login, env, runChild) {
|
|
117
|
-
const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
|
|
118
|
-
if (userPayload?.data?.user?.id) {
|
|
119
|
-
return { id: userPayload.data.user.id, kind: "user" };
|
|
120
|
-
}
|
|
121
|
-
const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
|
|
122
|
-
if (orgPayload?.data?.organization?.id) {
|
|
123
|
-
return { id: orgPayload.data.organization.id, kind: "org" };
|
|
124
|
-
}
|
|
125
|
-
throw Object.assign(
|
|
126
|
-
new Error(`Could not resolve owner ID for "${login}"`),
|
|
127
|
-
{ code: "NO_USER_ID" },
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ── Paginated project listing ────────────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
async function listAllProjects(login, kind, env, runChild) {
|
|
134
|
-
const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
|
|
135
|
-
const projects = [];
|
|
136
|
-
let after = null;
|
|
137
|
-
while (true) {
|
|
138
|
-
const vars = { login };
|
|
139
|
-
if (after) vars.after = after;
|
|
140
|
-
const payload = await ghGraphql(query, vars, env, runChild);
|
|
141
|
-
const connection = kind === "org"
|
|
142
|
-
? payload?.data?.organization?.projectsV2
|
|
143
|
-
: payload?.data?.user?.projectsV2;
|
|
144
|
-
const nodes = connection?.nodes ?? [];
|
|
145
|
-
projects.push(...nodes);
|
|
146
|
-
const pageInfo = connection?.pageInfo ?? {};
|
|
147
|
-
if (!pageInfo.hasNextPage) break;
|
|
148
|
-
if (!pageInfo.endCursor) {
|
|
149
|
-
throw Object.assign(
|
|
150
|
-
new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
|
|
151
|
-
{ code: "GH_API_ERROR" },
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
after = pageInfo.endCursor;
|
|
155
|
-
}
|
|
156
|
-
return projects;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ── Paginated field listing ──────────────────────────────────────────────
|
|
39
|
+
// ── Paginated project + field listing (shared via projects-access) ────────
|
|
160
40
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
let after = null;
|
|
164
|
-
while (true) {
|
|
165
|
-
const vars = { projectId };
|
|
166
|
-
if (after) vars.after = after;
|
|
167
|
-
const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
|
|
168
|
-
const connection = payload?.data?.node?.fields;
|
|
169
|
-
const nodes = connection?.nodes ?? [];
|
|
170
|
-
fields.push(...nodes);
|
|
171
|
-
const pageInfo = connection?.pageInfo ?? {};
|
|
172
|
-
if (!pageInfo.hasNextPage) break;
|
|
173
|
-
if (!pageInfo.endCursor) {
|
|
174
|
-
throw Object.assign(
|
|
175
|
-
new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
|
|
176
|
-
{ code: "GH_API_ERROR" },
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
after = pageInfo.endCursor;
|
|
180
|
-
}
|
|
181
|
-
return fields;
|
|
182
|
-
}
|
|
41
|
+
const listAllProjects = discoverProjects;
|
|
42
|
+
const listAllFields = listProjectFields;
|
|
183
43
|
|
|
184
44
|
// ── Paginated item listing ───────────────────────────────────────────────
|
|
185
45
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
items.push(...nodes);
|
|
196
|
-
const pageInfo = connection?.pageInfo ?? {};
|
|
197
|
-
if (!pageInfo.hasNextPage) break;
|
|
198
|
-
if (!pageInfo.endCursor) {
|
|
199
|
-
throw Object.assign(
|
|
200
|
-
new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
|
|
201
|
-
{ code: "GH_API_ERROR" },
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
after = pageInfo.endCursor;
|
|
205
|
-
}
|
|
206
|
-
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
|
+
});
|
|
207
55
|
}
|
|
208
56
|
|
|
209
57
|
// ── Exit code classification ────────────────────────────────────────────
|
|
@@ -289,14 +137,7 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
289
137
|
if (!content) continue;
|
|
290
138
|
|
|
291
139
|
// Determine status from field values
|
|
292
|
-
|
|
293
|
-
const fieldValues = item.fieldValues?.nodes ?? [];
|
|
294
|
-
for (const fv of fieldValues) {
|
|
295
|
-
if (fv && fv.field && fv.field.name === "Status") {
|
|
296
|
-
status = fv.name;
|
|
297
|
-
break;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
140
|
+
const status = extractStatus(item);
|
|
300
141
|
|
|
301
142
|
// Filter by column
|
|
302
143
|
if (args.column && status !== args.column) continue;
|
|
@@ -2,87 +2,15 @@ import { runChild as _runChild } from "../cli/primitives.mjs";
|
|
|
2
2
|
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
|
-
import { ghGraphql } from "../github/gh.mjs";
|
|
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
|
|
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 GET_USER_ID = [
|
|
35
|
-
"query($login:String!) {",
|
|
36
|
-
" user(login:$login) { id }",
|
|
37
|
-
"}"
|
|
38
|
-
].join("\n");
|
|
39
|
-
|
|
40
|
-
const GET_ORG_ID = [
|
|
41
|
-
"query($login:String!) {",
|
|
42
|
-
" organization(login:$login) { id }",
|
|
43
|
-
"}"
|
|
44
|
-
].join("\n");
|
|
45
|
-
|
|
46
|
-
const LIST_USER_PROJECTS = [
|
|
47
|
-
"query($login:String!, $after:String) {",
|
|
48
|
-
" user(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 LIST_ORG_PROJECTS = [
|
|
58
|
-
"query($login:String!, $after:String) {",
|
|
59
|
-
" organization(login:$login) {",
|
|
60
|
-
" projectsV2(first:50, after:$after) {",
|
|
61
|
-
" pageInfo { hasNextPage endCursor }",
|
|
62
|
-
" nodes { id number title url }",
|
|
63
|
-
" }",
|
|
64
|
-
" }",
|
|
65
|
-
"}"
|
|
66
|
-
].join("\n");
|
|
67
|
-
|
|
68
|
-
const GET_PROJECT_FIELDS = [
|
|
69
|
-
"query($projectId:ID!, $after:String) {",
|
|
70
|
-
" node(id:$projectId) {",
|
|
71
|
-
" ... on ProjectV2 {",
|
|
72
|
-
" fields(first:50, after:$after) {",
|
|
73
|
-
" pageInfo { hasNextPage endCursor }",
|
|
74
|
-
" nodes {",
|
|
75
|
-
" ... on ProjectV2SingleSelectField {",
|
|
76
|
-
" id name",
|
|
77
|
-
" options { id name }",
|
|
78
|
-
" }",
|
|
79
|
-
" }",
|
|
80
|
-
" }",
|
|
81
|
-
" }",
|
|
82
|
-
" }",
|
|
83
|
-
"}"
|
|
84
|
-
].join("\n");
|
|
85
|
-
|
|
86
14
|
const GET_PROJECT_ITEMS_BY_CONTENT = [
|
|
87
15
|
"query($projectId:ID!, $after:String) {",
|
|
88
16
|
" node(id:$projectId) {",
|
|
@@ -120,108 +48,25 @@ const UPDATE_ITEM_FIELD = [
|
|
|
120
48
|
"}"
|
|
121
49
|
].join("\n");
|
|
122
50
|
|
|
123
|
-
// ──
|
|
51
|
+
// ── Paginated project + field listing (shared via projects-access) ────────
|
|
124
52
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (userPayload?.data?.user?.id) {
|
|
128
|
-
return { id: userPayload.data.user.id, kind: "user" };
|
|
129
|
-
}
|
|
130
|
-
const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
|
|
131
|
-
if (orgPayload?.data?.organization?.id) {
|
|
132
|
-
return { id: orgPayload.data.organization.id, kind: "org" };
|
|
133
|
-
}
|
|
134
|
-
throw Object.assign(
|
|
135
|
-
new Error(`Could not resolve owner ID for "${login}"`),
|
|
136
|
-
{ code: "NO_USER_ID" },
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ── Paginated project listing ────────────────────────────────────────────
|
|
141
|
-
|
|
142
|
-
async function listAllProjects(login, kind, env, runChild) {
|
|
143
|
-
const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
|
|
144
|
-
const projects = [];
|
|
145
|
-
let after = null;
|
|
146
|
-
while (true) {
|
|
147
|
-
const vars = { login };
|
|
148
|
-
if (after) vars.after = after;
|
|
149
|
-
const payload = await ghGraphql(query, vars, env, runChild);
|
|
150
|
-
const connection = kind === "org"
|
|
151
|
-
? payload?.data?.organization?.projectsV2
|
|
152
|
-
: payload?.data?.user?.projectsV2;
|
|
153
|
-
const nodes = connection?.nodes ?? [];
|
|
154
|
-
projects.push(...nodes);
|
|
155
|
-
const pageInfo = connection?.pageInfo ?? {};
|
|
156
|
-
if (!pageInfo.hasNextPage) break;
|
|
157
|
-
if (!pageInfo.endCursor) {
|
|
158
|
-
throw Object.assign(
|
|
159
|
-
new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
|
|
160
|
-
{ code: "GH_API_ERROR" },
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
after = pageInfo.endCursor;
|
|
164
|
-
}
|
|
165
|
-
return projects;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ── Paginated field listing ──────────────────────────────────────────────
|
|
169
|
-
|
|
170
|
-
async function listAllFields(projectId, env, runChild) {
|
|
171
|
-
const fields = [];
|
|
172
|
-
let after = null;
|
|
173
|
-
while (true) {
|
|
174
|
-
const vars = { projectId };
|
|
175
|
-
if (after) vars.after = after;
|
|
176
|
-
const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
|
|
177
|
-
const connection = payload?.data?.node?.fields;
|
|
178
|
-
const nodes = connection?.nodes ?? [];
|
|
179
|
-
fields.push(...nodes);
|
|
180
|
-
const pageInfo = connection?.pageInfo ?? {};
|
|
181
|
-
if (!pageInfo.hasNextPage) break;
|
|
182
|
-
if (!pageInfo.endCursor) {
|
|
183
|
-
throw Object.assign(
|
|
184
|
-
new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
|
|
185
|
-
{ code: "GH_API_ERROR" },
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
after = pageInfo.endCursor;
|
|
189
|
-
}
|
|
190
|
-
return fields;
|
|
191
|
-
}
|
|
53
|
+
const listAllProjects = discoverProjects;
|
|
54
|
+
const listAllFields = listProjectFields;
|
|
192
55
|
|
|
193
56
|
// ── Paginated item listing (position order) ──────────────────────────────
|
|
194
57
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
items.push(...nodes);
|
|
205
|
-
const pageInfo = connection?.pageInfo ?? {};
|
|
206
|
-
if (!pageInfo.hasNextPage) break;
|
|
207
|
-
if (!pageInfo.endCursor) {
|
|
208
|
-
throw Object.assign(
|
|
209
|
-
new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
|
|
210
|
-
{ code: "GH_API_ERROR" },
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
after = pageInfo.endCursor;
|
|
214
|
-
}
|
|
215
|
-
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
|
+
});
|
|
216
67
|
}
|
|
217
68
|
|
|
218
|
-
|
|
219
|
-
const fvs = node?.fieldValues?.nodes ?? [];
|
|
220
|
-
for (const fv of fvs) {
|
|
221
|
-
if (fv && fv.field && fv.field.name === "Status") return fv.name;
|
|
222
|
-
}
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
69
|
+
const statusOf = extractStatus;
|
|
225
70
|
|
|
226
71
|
// ── Exit code classification ────────────────────────────────────────────
|
|
227
72
|
|