@dev-loops/core 1.0.4-pre.0 → 1.0.4-pre.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.
@@ -7,7 +7,7 @@
7
7
  * synthesis enter ONLY as a bounded input consumed at the `await_answers`
8
8
  * state (and reflected in the `synthesized` snapshot flag), never as hidden
9
9
  * orchestration inside a deterministic coordinator script (keeps
10
- * OPS-NO-INLINE-INTERPRETER, #1224, clean).
10
+ * OPS-NO-INLINE-INTERPRETER clean).
11
11
  *
12
12
  * Mirrors the shape of `reviewer-loop-state.mjs` / `copilot-loop-state.mjs`:
13
13
  * a frozen STATE vocabulary, a frozen TRANSITIONS adjacency table, a
@@ -17,6 +17,15 @@
17
17
  * Honest handoff: when a gap is genuinely unanswerable (only-`inferred`, no
18
18
  * citation), the machine reaches `needs_human_handoff` naming the question
19
19
  * rather than fabricating an answer to force convergence.
20
+ *
21
+ * Zero-gap provenance (see ADR 0084, which amends ADR 0029): a zero-open-gap
22
+ * `detect_gaps` pass resolves to `grill_clean` only for a `plan` surface
23
+ * (shape-only, no comment surface) or when a `🔬 Grill / refinement results`
24
+ * comment is already recorded on the target; otherwise it stays at
25
+ * `detect_gaps` so the semantic pass still runs and records its own
26
+ * provenance, including a zero-gap outcome. `detectIssueRefinementArtifact`
27
+ * stays the sole shape/completeness predicate; provenance is a separate
28
+ * recorded fact, not a second refinedness detector.
20
29
  */
21
30
 
22
31
  import { trimmedOrNull } from "./normalize.mjs";
@@ -36,7 +45,10 @@ export const GRILL_STATE = Object.freeze({
36
45
  // re_grill, with re_grill either re-entering detect_gaps (a new answerable gap
37
46
  // surfaced) or terminating at grill_clean (fixed point). Any I/O/parse failure
38
47
  // fails closed to blocked_needs_user_decision; any unresolved (uncitable) gap
39
- // terminates honestly at needs_human_handoff.
48
+ // terminates honestly at needs_human_handoff. A zero-open-gap detect_gaps pass
49
+ // terminates at grill_clean only with recorded provenance (plan surface, or a
50
+ // posted results comment); otherwise it stays at detect_gaps for the owed
51
+ // semantic pass (ADR 0084).
40
52
  export const GRILL_TRANSITIONS = Object.freeze({
41
53
  [GRILL_STATE.LOAD_TARGET]: [
42
54
  GRILL_STATE.DETECT_GAPS,
@@ -81,12 +93,76 @@ const GRILL_NEXT_ACTIONS = Object.freeze({
81
93
 
82
94
  const VALID_SURFACES = new Set(["issue", "pr", "plan"]);
83
95
 
96
+ // The exact comment title provenance is keyed on (GRILL-SUBLOOP-RATIONALE-COMMENT).
97
+ const RESULTS_COMMENT_TITLE = "🔬 Grill / refinement results";
98
+ // The exact first-line heading a results comment must carry -- SKILL.md Step 4
99
+ // and the output artifact contract require exactly "## " (one hash pair, one
100
+ // space), never a bare title or a different heading level.
101
+ const RESULTS_COMMENT_HEADING = `## ${RESULTS_COMMENT_TITLE}`;
102
+ // A results comment's recorded bypass line: "bypass: operator-authorized by <handle>",
103
+ // with an optional leading @ before the handle and a case-insensitive "bypass:" key.
104
+ const BYPASS_LINE_RE = /^bypass: operator-authorized by @?([A-Za-z0-9][A-Za-z0-9-]{0,38})\s*$/i;
105
+
84
106
  function normalizeCount(value) {
85
107
  return typeof value === "number" && Number.isFinite(value) && value > 0
86
108
  ? Math.floor(value)
87
109
  : 0;
88
110
  }
89
111
 
112
+ /**
113
+ * Detect recorded grill provenance from a target's comments: a durable
114
+ * `🔬 Grill / refinement results` comment, optionally carrying an
115
+ * `bypass: operator-authorized by <handle>` line. The ephemeral
116
+ * `tmp/issues/issue-<n>/grill/` transcript is never a comment, so it never
117
+ * counts here.
118
+ *
119
+ * @param {Array<string|{body?: string}>} comments
120
+ * @returns {{provenanceRecorded: boolean, bypass: boolean, bypassBy: string|null}}
121
+ */
122
+ export function detectGrillProvenance(comments) {
123
+ if (!Array.isArray(comments)) {
124
+ return { provenanceRecorded: false, bypass: false, bypassBy: null };
125
+ }
126
+
127
+ let provenanceRecorded = false;
128
+ let bypass = false;
129
+ let bypassBy = null;
130
+
131
+ for (const comment of comments) {
132
+ const body = typeof comment === "string"
133
+ ? comment
134
+ : (comment && typeof comment.body === "string" ? comment.body : null);
135
+ if (body === null) continue;
136
+
137
+ const lines = body.split(/\r?\n/);
138
+ // A comment counts as a results comment only when its FIRST non-empty
139
+ // line, trimmed, is EXACTLY the "## " heading -- this rejects a bare
140
+ // title, a different heading level (`###`), a missing space (`##🔬`),
141
+ // the title merely quoted in a code fence, or the title appearing later
142
+ // in an unrelated reply.
143
+ const firstNonEmpty = lines.find((line) => line.trim().length > 0);
144
+ const isResultsComment = firstNonEmpty !== undefined
145
+ && firstNonEmpty.trim() === RESULTS_COMMENT_HEADING;
146
+ if (!isResultsComment) continue;
147
+
148
+ provenanceRecorded = true;
149
+ if (!bypass) {
150
+ // Take the first bypass-line match across all results comments; a
151
+ // later comment's bypass line never overwrites an earlier one.
152
+ for (const line of lines) {
153
+ const match = line.trim().match(BYPASS_LINE_RE);
154
+ if (match) {
155
+ bypass = true;
156
+ bypassBy = match[1];
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ return { provenanceRecorded, bypass, bypassBy };
164
+ }
165
+
90
166
  /**
91
167
  * Canonicalize a raw grill snapshot into a deterministic shape.
92
168
  *
@@ -119,19 +195,32 @@ export function normalizeGrillSnapshot(raw) {
119
195
  // post-synthesis re-grill fixed-point signals
120
196
  reGrillRan: Boolean(raw.reGrillRan),
121
197
  reGrillFixedPoint: Boolean(raw.reGrillFixedPoint),
198
+
199
+ // recorded provenance: a posted `🔬 Grill / refinement results` comment
200
+ // (see detectGrillProvenance), and whether it carries a recorded bypass line.
201
+ // A bypass line only ever means anything alongside a recorded comment, so
202
+ // provenanceBypass is forced false when provenanceRecorded is false --
203
+ // never a standalone shortcut to grill_clean.
204
+ provenanceRecorded: Boolean(raw.provenanceRecorded),
205
+ provenanceBypass: Boolean(raw.provenanceRecorded) && Boolean(raw.provenanceBypass),
122
206
  };
123
207
  }
124
208
 
209
+ const PROVENANCE_MISSING_NEXT_ACTION =
210
+ "Run the semantic gap pass on the loaded spec, then post the \"🔬 Grill / refinement results\" comment recording the outcome — including a zero-gap pass, which states that no gaps were found";
211
+
125
212
  /**
126
213
  * Deterministically interpret the current refinement-grill state.
127
214
  *
128
215
  * @param {object} snapshot
129
- * @returns {{state: string, allowedTransitions: string[], nextAction: string}}
216
+ * @returns {{state: string, allowedTransitions: string[], nextAction: string, reason: string|null, bypass: boolean}}
130
217
  */
131
218
  export function interpretRefinementGrillState(snapshot) {
132
219
  const s = normalizeGrillSnapshot(snapshot);
133
220
 
134
221
  let state;
222
+ let reason = null;
223
+ let bypass = false;
135
224
 
136
225
  if (s.loadFailed) {
137
226
  // Fail closed on any load/parse failure, from any point in the loop.
@@ -155,17 +244,40 @@ export function interpretRefinementGrillState(snapshot) {
155
244
  // Bounded answer input present -> apply synthesis.
156
245
  state = GRILL_STATE.SYNTHESIZE;
157
246
  } else if (s.detectRan) {
158
- // Detection ran with no unresolved and no pending answers:
159
- // open gaps -> await answers; zero gaps -> clean fixed point
160
- // (also the already-refined, zero-iteration path).
161
- state = s.openGapCount > 0 ? GRILL_STATE.AWAIT_ANSWERS : GRILL_STATE.GRILL_CLEAN;
247
+ if (s.openGapCount > 0) {
248
+ // Detection ran; answerable gaps still open -> await answers.
249
+ state = GRILL_STATE.AWAIT_ANSWERS;
250
+ } else if (s.surface === "plan") {
251
+ // Local plan files have no comment surface: shape-only, zero-iteration clean.
252
+ state = GRILL_STATE.GRILL_CLEAN;
253
+ reason = "plan_shape_only";
254
+ } else if (s.provenanceBypass) {
255
+ // A recorded bypass line still counts as recorded provenance.
256
+ state = GRILL_STATE.GRILL_CLEAN;
257
+ reason = "provenance_bypass_recorded";
258
+ bypass = true;
259
+ } else if (s.provenanceRecorded) {
260
+ state = GRILL_STATE.GRILL_CLEAN;
261
+ reason = "provenance_recorded";
262
+ } else {
263
+ // Zero open gaps but no recorded provenance: the semantic pass is still
264
+ // owed (ADR 0084) — stay at detect_gaps rather than short-circuiting.
265
+ state = GRILL_STATE.DETECT_GAPS;
266
+ reason = "provenance_missing";
267
+ }
162
268
  } else {
163
269
  state = GRILL_STATE.DETECT_GAPS;
164
270
  }
165
271
 
272
+ const nextAction = reason === "provenance_missing"
273
+ ? PROVENANCE_MISSING_NEXT_ACTION
274
+ : GRILL_NEXT_ACTIONS[state];
275
+
166
276
  return {
167
277
  state,
168
278
  allowedTransitions: [...GRILL_TRANSITIONS[state]],
169
- nextAction: GRILL_NEXT_ACTIONS[state],
279
+ nextAction,
280
+ reason,
281
+ bypass,
170
282
  };
171
283
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * review-operation
3
+ *
4
+ * The ONE operation-scoped authority for the legal dispatchable-angle pool and
5
+ * the reviewer role it authorizes. write-gate-context.mjs's standalone-review
6
+ * angle union and the `dev-loops gate resolve-role` CLI both consume this
7
+ * module instead of reconstructing membership/eligibility rules of their own.
8
+ * The draft/pre-approval/spike dispatch paths keep calling
9
+ * `resolveGateAngleContract` directly for their pool, which this module wraps
10
+ * (see `resolveOperationAnglePool` below) rather than duplicating.
11
+ *
12
+ * `resolveOperationAnglePool` owns only the legal CANDIDATE catalog for an
13
+ * operation. Diff-/tier-/PR-fact-driven SELECTION from that catalog (which
14
+ * angles actually run this round) and any later spec-of-record pruning stay
15
+ * downstream dispatch/planning concerns (see write-gate-context.mjs's
16
+ * `resolveReviewGateAngles`, which imports this module for its union instead
17
+ * of recomputing it).
18
+ *
19
+ * `resolveOperationReviewerRole` is the authoritative exit-code boundary the
20
+ * `dev-loops gate resolve-role` CLI consumes for standalone/defensive role
21
+ * resolution: `ok` is fail-closed true only when the merged config loaded
22
+ * with no errors AND the requested angle is a legal member of the named
23
+ * operation's pool. `status` is a DIAGNOSTIC field only (never a second
24
+ * reviewer decision surface); see skills/docs/gate-review-comment-contract.md
25
+ * and skills/docs/gate-review-sub-loop-contract.md.
26
+ */
27
+ import { resolveGateAngleContract, resolveGateAngles, resolveReviewerRole, resolveRoleModel } from "../config/config.mjs";
28
+ import { GATE_CONFIG_KEY } from "./gate-fanin.mjs";
29
+
30
+ /** The closed review-operation vocabulary. Standalone `review` is only ever selected by name, never inferred. */
31
+ export const REVIEW_OPERATIONS = Object.freeze(["draft_gate", "pre_approval_gate", "review", "spike"]);
32
+
33
+ // GATE_CONFIG_KEY (gate-fanin.mjs) has no `spike` entry — other code relies on
34
+ // its absence of review/spike — so this operation-scoped table adds it locally
35
+ // instead of widening the shared one.
36
+ const OPERATION_GATE_KEY = Object.freeze({ ...GATE_CONFIG_KEY, spike: "spike" });
37
+
38
+ /**
39
+ * Resolve the legal candidate angle pool for a review operation from the
40
+ * fully merged config:
41
+ * - draft_gate / pre_approval_gate / spike: the effective `gates.<key>`
42
+ * pool (`resolveGateAngleContract(config, key).pool` — mandatory/static/
43
+ * additive rules applied, disabled/excluded angles removed).
44
+ * - review: the de-duplicated, order-stable union of the static draft and
45
+ * pre-approval angle sets (`resolveGateAngles`, NOT the additive/tier
46
+ * pool) — standalone review's existing dedicated semantics; it gains no
47
+ * diff-tier or additive selection here.
48
+ * An unrecognized operation throws (arg-identity error, not a config-layer
49
+ * concern; callers validate `--gate` before ever reaching config load).
50
+ * @param {import("../config/config.mjs").DevLoopConfig} config
51
+ * @param {"draft_gate"|"pre_approval_gate"|"review"|"spike"} operation
52
+ * @returns {string[]}
53
+ */
54
+ export function resolveOperationAnglePool(config, operation) {
55
+ if (!REVIEW_OPERATIONS.includes(operation)) {
56
+ throw new Error(`Unknown review operation: ${JSON.stringify(operation)} (expected one of ${REVIEW_OPERATIONS.join(", ")})`);
57
+ }
58
+ if (operation === "review") {
59
+ return [...new Set([
60
+ ...(resolveGateAngles(config, "draft") ?? []),
61
+ ...(resolveGateAngles(config, "preApproval") ?? []),
62
+ ])];
63
+ }
64
+ return resolveGateAngleContract(config, OPERATION_GATE_KEY[operation]).pool ?? [];
65
+ }
66
+
67
+ /**
68
+ * @typedef {object} OperationReviewerRoleResult
69
+ * @property {boolean} ok - Fail-closed reviewer safety boundary: `configErrors.length === 0 && pool.includes(angle)`.
70
+ * @property {"draft_gate"|"pre_approval_gate"|"review"|"spike"} operation
71
+ * @property {string} angle
72
+ * @property {"claude"|"pi"} harness
73
+ * @property {string} persona
74
+ * @property {string|null} prompt
75
+ * @property {string|null} model - Authoritative merged model tier (`resolveRoleModel(..., { kind: "angle" })`).
76
+ * @property {boolean} fallback
77
+ * @property {"config-error"|"non-member"|"fallback"|"prompt-missing"|"resolved"} status - Diagnostic only; never a reviewer decision branch.
78
+ * @property {string[]} warnings
79
+ * @property {Array<unknown>} configErrors
80
+ */
81
+
82
+ /**
83
+ * Resolve one angle's reviewer role for a review operation, authorized
84
+ * against `resolveOperationAnglePool`. This is the shared authority a CLI
85
+ * (`gate resolve-role`), dispatch/planning, or defensive validation all call —
86
+ * none of them may reimplement membership/union/additive/disabled/spike
87
+ * classification locally.
88
+ * @param {{ config: import("../config/config.mjs").DevLoopConfig, errors?: Array<unknown> }} loadResult - the `{ config, errors }` shape `loadDevLoopConfig` returns.
89
+ * @param {{ operation: "draft_gate"|"pre_approval_gate"|"review"|"spike", angle: string, harness: "claude"|"pi" }} params
90
+ * @returns {OperationReviewerRoleResult}
91
+ */
92
+ export function resolveOperationReviewerRole(loadResult, { operation, angle, harness }) {
93
+ const config = loadResult?.config;
94
+ const configErrors = Array.isArray(loadResult?.errors) ? loadResult.errors : [];
95
+ const configErrorPresent = configErrors.length > 0;
96
+ // An unrecognized operation is a closed-vocabulary argument failure, not a
97
+ // config-layer concern; it must throw unconditionally, even when a config
98
+ // error is also on record — never degraded to a misleading config-error
99
+ // result below. Validate before the try so this throw is never swallowed.
100
+ if (!REVIEW_OPERATIONS.includes(operation)) {
101
+ throw new Error(`Unknown review operation: ${JSON.stringify(operation)} (expected one of ${REVIEW_OPERATIONS.join(", ")})`);
102
+ }
103
+ let pool;
104
+ try {
105
+ pool = resolveOperationAnglePool(config, operation);
106
+ } catch (error) {
107
+ // A config that already failed merged schema validation (non-empty
108
+ // `errors` from loadDevLoopConfig) can still reach here with a gate shape
109
+ // resolveGateConfig itself rejects (e.g. an invalid
110
+ // `gates.<gate>.blockCleanOnFindingSeverities`). That is a config-layer
111
+ // failure the caller's own config-error fail-closed status already
112
+ // covers, so degrade to an empty pool instead of an unhandled exception
113
+ // reaching the reviewer boundary. With no config error on record, this is
114
+ // a real schema-invalid gate shape a caller must see: rethrow.
115
+ if (!configErrorPresent) throw error;
116
+ pool = [];
117
+ }
118
+ const member = pool.includes(angle);
119
+ const ok = !configErrorPresent && member;
120
+
121
+ const role = resolveReviewerRole(config, angle);
122
+ const model = resolveRoleModel(config, { role: angle, harness, kind: "angle" });
123
+
124
+ const warnings = [];
125
+ let status;
126
+ if (configErrorPresent) {
127
+ status = "config-error";
128
+ warnings.push(
129
+ `${configErrors.length} config-layer error(s); the resolved role may be a shipped default and must not be trusted.`,
130
+ );
131
+ } else if (!member) {
132
+ status = "non-member";
133
+ warnings.push(`angle '${angle}' is not a member of the '${operation}' operation's legal candidate pool.`);
134
+ } else if (role.fallback) {
135
+ status = "fallback";
136
+ warnings.push(
137
+ `angle '${angle}' is authorized for '${operation}' but has no dedicated persona/prompt entry; the default-reviewer persona is returned.`,
138
+ );
139
+ } else if (typeof role.prompt !== "string" || role.prompt.trim() === "") {
140
+ status = "prompt-missing";
141
+ warnings.push(
142
+ `angle '${angle}' resolved persona '${role.persona}' but its focus prompt is null/empty; review with no angle-specific focus instruction.`,
143
+ );
144
+ } else {
145
+ status = "resolved";
146
+ }
147
+
148
+ return {
149
+ ok,
150
+ operation,
151
+ angle,
152
+ harness,
153
+ persona: role.persona,
154
+ prompt: role.prompt,
155
+ model,
156
+ fallback: role.fallback,
157
+ status,
158
+ warnings,
159
+ configErrors,
160
+ };
161
+ }
@@ -498,6 +498,27 @@ export function validateSpecAuthorityVerdict(verdict, { findingsCount, criterion
498
498
  };
499
499
  }
500
500
 
501
+ /**
502
+ * Dispose every spec-authority `finding_conflicts` finding `reject`, whatever
503
+ * its relevance disposition. Mutates the judge-enriched findings in place; the
504
+ * judge pass and the durable findings-log writer share it so the ledger's
505
+ * `judgeDisposition` matches what the judge pass enforces.
506
+ *
507
+ * @param {object[]} findings — judge-enriched findings, indexed as the verdict
508
+ * @param {Iterable<number>} conflictIndices — the `finding_conflicts` indexes
509
+ * @returns {object[]} the same `findings` array
510
+ */
511
+ export function rejectFindingConflicts(findings, conflictIndices) {
512
+ const rejected = new Set(conflictIndices);
513
+ for (const [i, f] of findings.entries()) {
514
+ if (rejected.has(i) && f.judgeDisposition !== "reject") {
515
+ f.judgeRationale = `spec-authority finding_conflicts: rejected against the spec (was relevance-${f.judgeDisposition}) — ${f.judgeRationale ?? ""}`.trim();
516
+ f.judgeDisposition = "reject";
517
+ }
518
+ }
519
+ return findings;
520
+ }
521
+
501
522
  /**
502
523
  * Resolve which prior criterion approvals survive a revision change. This is the
503
524
  * one authority for both invalidation rules:
@@ -3,7 +3,7 @@ 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
+ import { validateProjectsRepo, discoverProjects, listProjectFields, extractStatus, resolveProjectItem } from "./projects-access.mjs";
7
7
 
8
8
  // ── Validation ───────────────────────────────────────────────────────────
9
9
 
@@ -11,33 +11,6 @@ const validateRepo = validateProjectsRepo;
11
11
 
12
12
  // ── GraphQL fragments ────────────────────────────────────────────────────
13
13
 
14
- const GET_PROJECT_ITEMS_BY_CONTENT = [
15
- "query($projectId:ID!, $after:String) {",
16
- " node(id:$projectId) {",
17
- " ... on ProjectV2 {",
18
- " items(first:100, after:$after, orderBy:{field:POSITION, direction:ASC}) {",
19
- " pageInfo { hasNextPage endCursor }",
20
- " nodes {",
21
- " id",
22
- " fieldValues(first:20) {",
23
- " nodes {",
24
- " ... on ProjectV2ItemFieldSingleSelectValue {",
25
- " field { ... on ProjectV2SingleSelectField { id name } }",
26
- " name",
27
- " }",
28
- " }",
29
- " }",
30
- " content {",
31
- " ... on Issue { __typename number repository { nameWithOwner } }",
32
- " ... on PullRequest { __typename number repository { nameWithOwner } }",
33
- " }",
34
- " }",
35
- " }",
36
- " }",
37
- " }",
38
- "}"
39
- ].join("\n");
40
-
41
14
  const UPDATE_ITEM_FIELD = [
42
15
  "mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) {",
43
16
  " updateProjectV2ItemFieldValue(input:{projectId:$projectId, itemId:$itemId, fieldId:$fieldId, value:{singleSelectOptionId:$optionId}}) {",
@@ -53,19 +26,6 @@ const UPDATE_ITEM_FIELD = [
53
26
  const listAllProjects = discoverProjects;
54
27
  const listAllFields = listProjectFields;
55
28
 
56
- // ── Paginated item listing (position order) ──────────────────────────────
57
-
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
- });
67
- }
68
-
69
29
  const statusOf = extractStatus;
70
30
 
71
31
  // ── Exit code classification ────────────────────────────────────────────
@@ -84,7 +44,7 @@ function classifyExitCode(err) {
84
44
  async function main(args, { env = process.env, runChild, cwd = null } = {}) {
85
45
  const child = runChild ?? _runChild;
86
46
  const repo = validateRepo(args.repo);
87
- const [owner, repoName] = repo.split("/");
47
+ const [owner] = repo.split("/");
88
48
  const selector = resolveProjectSelector(args);
89
49
  const itemRef = parseItemRef(args.item);
90
50
  const toColumn = (args.toColumn ?? "").trim();
@@ -122,43 +82,9 @@ async function main(args, { env = process.env, runChild, cwd = null } = {}) {
122
82
  );
123
83
  }
124
84
 
125
- // 4. Find the item.
126
- //
127
- // Fetch the full board item list ONCE (paginated, position order) and resolve
128
- // BOTH ref kinds against it. This reuses the proven pattern from
129
- // reorder-queue-item / list-queue-items: a node-id ref matches by item.id, a
130
- // number ref matches by content.number. Both are scoped to the requested repo
131
- // so a cross-project ref fails closed with ITEM_NOT_FOUND. (The previous code
132
- // used `ProjectV2.item` — a field that does not exist — for the node-id path,
133
- // and a single non-paginated `items(first:10)` page for the number path, so it
134
- // could not find items beyond the first page.)
135
- const allItems = await fetchAllItems(project.id, env, child);
136
-
137
- let match;
138
- if (itemRef.kind === "id") {
139
- match = allItems.find(
140
- (it) => it.id === itemRef.value && it.content?.repository?.nameWithOwner === repo,
141
- );
142
- if (!match) {
143
- throw Object.assign(
144
- new Error(`Item "${itemRef.value}" not found in project "${project.title}" for repo "${repo}"`),
145
- { code: "ITEM_NOT_FOUND" },
146
- );
147
- }
148
- } else {
149
- match = allItems.find(
150
- (it) =>
151
- it.content &&
152
- it.content.repository?.nameWithOwner === repo &&
153
- it.content.number === itemRef.value,
154
- );
155
- if (!match) {
156
- throw Object.assign(
157
- new Error(`Item #${itemRef.value} not found in project "${project.title}" for repo "${repo}"`),
158
- { code: "ITEM_NOT_FOUND" },
159
- );
160
- }
161
- }
85
+ // 4. Find the item from the issue side or by node, never from the board
86
+ // listing: `ProjectV2.items` can lag behind GitHub and omit new items.
87
+ const match = await resolveProjectItem({ projectId: project.id, projectTitle: project.title, repo, itemRef, env, runChild: child });
162
88
 
163
89
  const itemId = match.id;
164
90
  const previousColumn = statusOf(match);
@@ -200,3 +200,127 @@ export function extractStatus(node) {
200
200
  }
201
201
  return null;
202
202
  }
203
+
204
+ // ── Single item resolution ─────────────────────────────────────────────────
205
+
206
+ // Item projection shared by both lookups. It matches the board listing node
207
+ // shape (id, fieldValues, content) so `extractStatus` works on the result.
208
+ const ITEM_FIELDS = [
209
+ "id isArchived project { id }",
210
+ "fieldValues(first:20) {",
211
+ " nodes {",
212
+ " ... on ProjectV2ItemFieldSingleSelectValue {",
213
+ " field { ... on ProjectV2SingleSelectField { id name } }",
214
+ " name",
215
+ " }",
216
+ " }",
217
+ "}",
218
+ "content {",
219
+ " ... on Issue { __typename number repository { nameWithOwner } }",
220
+ " ... on PullRequest { __typename number repository { nameWithOwner } }",
221
+ "}",
222
+ ].join("\n");
223
+
224
+ const GET_ITEMS_BY_CONTENT_NUMBER = [
225
+ "query($owner:String!, $name:String!, $number:Int!, $after:String) {",
226
+ " repository(owner:$owner, name:$name) {",
227
+ " issueOrPullRequest(number:$number) {",
228
+ ` ... on Issue { projectItems(first:100, after:$after, includeArchived:false) { pageInfo { hasNextPage endCursor } nodes { ${ITEM_FIELDS} } } }`,
229
+ ` ... on PullRequest { projectItems(first:100, after:$after, includeArchived:false) { pageInfo { hasNextPage endCursor } nodes { ${ITEM_FIELDS} } } }`,
230
+ " }",
231
+ " }",
232
+ "}",
233
+ ].join("\n");
234
+
235
+ const GET_ITEM_BY_ID = [
236
+ "query($id:ID!) {",
237
+ ` node(id:$id) { ... on ProjectV2Item { ${ITEM_FIELDS} } }`,
238
+ "}",
239
+ ].join("\n");
240
+
241
+ function itemNotFound(message) {
242
+ return Object.assign(new Error(message), { code: "ITEM_NOT_FOUND" });
243
+ }
244
+
245
+ /**
246
+ * Throw GRAPHQL_ERROR for any GraphQL error in `payload` other than NOT_FOUND.
247
+ * Use it on a `ghGraphql(..., { allowErrors: true })` payload where NOT_FOUND
248
+ * means "no such entity" and every other error must keep its message.
249
+ */
250
+ export function assertOnlyNotFoundErrors(payload) {
251
+ const other = (payload?.errors ?? []).filter((e) => e?.type !== "NOT_FOUND");
252
+ if (other.length > 0) {
253
+ throw Object.assign(
254
+ new Error(`GraphQL errors: ${other.map((e) => e.message).join("; ")}`),
255
+ { code: "GRAPHQL_ERROR" },
256
+ );
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Resolve one project item without the whole-board `ProjectV2.items` listing,
262
+ * which can lag behind GitHub by hours and omit newly added items.
263
+ *
264
+ * - number ref: read the issue or PR's own `projectItems` and pick the
265
+ * unarchived item on `projectId`.
266
+ * - id ref: look the item node up directly, then verify that it belongs to
267
+ * `projectId` and that its content is in `repo`.
268
+ *
269
+ * Every miss or mismatch fails closed with code ITEM_NOT_FOUND. Returns a node
270
+ * in the listing shape: `{ id, isArchived, project, fieldValues, content }`.
271
+ *
272
+ * @param {object} opts
273
+ * @param {string} opts.projectId
274
+ * @param {string} [opts.projectTitle] shown in ITEM_NOT_FOUND messages when present
275
+ * @param {string} opts.repo validated `owner/name`
276
+ * @param {{kind:"number"|"id", value:number|string}} opts.itemRef from parseItemRef
277
+ * @param {object} opts.env
278
+ * @param {Function} opts.runChild
279
+ */
280
+ export async function resolveProjectItem({ projectId, projectTitle, repo, itemRef, env, runChild }) {
281
+ const projectLabel = projectTitle ?? projectId;
282
+ if (itemRef.kind === "number") {
283
+ const [owner, name] = repo.split("/");
284
+ let after = null;
285
+ while (true) {
286
+ const vars = { owner, name, number: itemRef.value };
287
+ if (after) vars.after = after;
288
+ const payload = await ghGraphql(GET_ITEMS_BY_CONTENT_NUMBER, vars, env, runChild, { allowErrors: true });
289
+ const connection = payload?.data?.repository?.issueOrPullRequest?.projectItems;
290
+ const match = (connection?.nodes ?? []).find((n) => n && n.project?.id === projectId && !n.isArchived);
291
+ // A partial response can carry errors for other boards the token cannot
292
+ // read (e.g. FORBIDDEN). A match on the configured board wins; the error
293
+ // is raised only when no page holds a match.
294
+ if (match) return match;
295
+ const pageInfo = connection?.pageInfo ?? {};
296
+ if (!pageInfo.hasNextPage) {
297
+ assertOnlyNotFoundErrors(payload);
298
+ throw itemNotFound(`Item #${itemRef.value} not found in project "${projectLabel}" for repo "${repo}"`);
299
+ }
300
+ if (!pageInfo.endCursor) {
301
+ throw Object.assign(
302
+ new Error("Invalid projectItems payload: hasNextPage is true but endCursor is missing"),
303
+ { code: "GH_API_ERROR" },
304
+ );
305
+ }
306
+ after = pageInfo.endCursor;
307
+ }
308
+ }
309
+
310
+ const payload = await ghGraphql(GET_ITEM_BY_ID, { id: itemRef.value }, env, runChild, { allowErrors: true });
311
+ assertOnlyNotFoundErrors(payload);
312
+ const node = payload?.data?.node;
313
+ if (!node?.id || node.isArchived) {
314
+ throw itemNotFound(`Item "${itemRef.value}" not found in project "${projectLabel}" for repo "${repo}"`);
315
+ }
316
+ if (node.project?.id !== projectId) {
317
+ throw itemNotFound(
318
+ `Item "${itemRef.value}" belongs to project "${node.project?.id ?? "(unknown)"}", not "${projectId}"`,
319
+ );
320
+ }
321
+ const itemRepo = node.content?.repository?.nameWithOwner ?? null;
322
+ if (itemRepo !== repo) {
323
+ throw itemNotFound(`Item "${itemRef.value}" is for repo "${itemRepo ?? "(none)"}", not "${repo}"`);
324
+ }
325
+ return node;
326
+ }