@dev-loops/core 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,35 @@
1
- import { loadBoardConfig, resolveProjectNumber } from "./queue-board-sync.mjs";
2
- import { main as listQueueItemsMain } from "../../../../scripts/projects/list-queue-items.mjs";
1
+ import { loadBoardConfig, resolveProjectNumber, loadStateColumnMap, LOGICAL_COLUMN } from "./queue-board-sync.mjs";
2
+ import { main as listQueueItemsMain } from "../projects/list-queue-items.mjs";
3
3
 
4
+ // Canonical fail-closed Next Up tokens — the SINGLE source of truth so the reason
5
+ // codes and the empty-queue message stay byte-identical across every layer that
6
+ // detects them (queue-driver, run-queue, resolve-active-board-item). These strings
7
+ // drifted twice before centralization (#1091); import them, never re-inline them.
8
+ export const REASON_NEXT_UP_EMPTY = "next-up-empty";
9
+ export const REASON_BOARD_QUERY_ERROR = "board-query-error";
10
+ export const REASON_NEXT_UP_TARGET_MISSING_LOCALLY = "next-up-target-missing-locally";
11
+ export const EMPTY_NEXT_UP_MESSAGE = "queue empty — prioritize Backlog items into Next Up";
12
+
13
+ /**
14
+ * Resolve the board's "Next Up" pickup order (issue #1091).
15
+ *
16
+ * Next Up is the NORMATIVE, fail-closed pickup source. This resolver reports
17
+ * enough to let the driver distinguish three cases cleanly (it never silently
18
+ * collapses them):
19
+ *
20
+ * - Board NOT configured → `{ ok:true, configured:false, order:[] }`. The
21
+ * Next Up concept does not exist; the driver keeps its legacy local order.
22
+ * - Board configured, Next Up query SUCCEEDS → `{ ok:true, configured:true,
23
+ * order:[…], reason:null }`. `order` may be empty (a genuinely empty Next
24
+ * Up → the driver fails closed / idles, it MUST NOT fall back to Backlog).
25
+ * - Board configured, query ERRORS (unreachable / project unresolvable / API
26
+ * failure) → `{ ok:false, configured:true, order:[], reason:<msg> }`. The
27
+ * driver surfaces the error and stops; it MUST NOT fall back to Backlog.
28
+ *
29
+ * `order` and `reason` are always present so the fail-open membership layer
30
+ * (queue-membership.mjs), which predates the `ok`/`configured` fields, keeps
31
+ * working unchanged (it reads `order`/`reason` only).
32
+ */
4
33
  export async function resolveNextUpOrder(
5
34
  repo,
6
35
  repoRoot,
@@ -9,19 +38,32 @@ export async function resolveNextUpOrder(
9
38
  ) {
10
39
  const config = loadBoardConfig(repoRoot);
11
40
  if (!config.enabled) {
12
- return { ok: true, order: [], reason: config.reason ?? "board not configured" };
41
+ return { ok: true, configured: false, order: [], reason: config.reason ?? "board not configured" };
13
42
  }
14
43
 
15
44
  let projectNumber;
16
45
  try {
17
46
  projectNumber = await resolveProjectNumber(repo, config, env, dependencies.runChild);
18
47
  } catch (err) {
19
- return { ok: true, order: [], reason: err.message ?? "board lookup failed" };
48
+ // Board IS configured but we cannot resolve/reach it: this is a query ERROR,
49
+ // not an empty Next Up. Fail closed at the driver, never Backlog fallback.
50
+ return { ok: false, configured: true, order: [], reason: err.message ?? "board lookup failed" };
20
51
  }
21
52
  if (!projectNumber) {
22
- return { ok: true, order: [], reason: "could not resolve board project" };
53
+ return { ok: false, configured: true, order: [], reason: "could not resolve board project" };
23
54
  }
24
55
 
56
+ // Resolve the logical next_up column through the SAME statusColumns mapping
57
+ // board-sync uses (#1098), so a renamed Next Up column (e.g. "Todo") is
58
+ // queried by its configured display name instead of the literal default.
59
+ // No config-error guard here: loadBoardConfig above already short-circuits any
60
+ // `.devloops` read/parse error to `enabled:false` (early return at the top of
61
+ // this function), so a malformed config never reaches this point. The
62
+ // fail-closed-on-config-error guard lives on the direct-read pickup path
63
+ // (resolve-active-board-item), which does NOT go through loadBoardConfig.
64
+ const { columnNames } = loadStateColumnMap(repoRoot);
65
+ const nextUpColumn = columnNames[LOGICAL_COLUMN.NEXT_UP];
66
+
25
67
  const listItems = dependencies.listQueueItems ?? listQueueItemsMain;
26
68
  try {
27
69
  const result = await listItems(
@@ -29,14 +71,16 @@ export async function resolveNextUpOrder(
29
71
  // resolveProjectNumber yields a number, so stringify it. Passing the raw
30
72
  // number trips parseProjectRef's `typeof raw !== "string"` guard, which
31
73
  // surfaces as a misleading "--project is required" (#901).
32
- { repo, project: String(projectNumber), column: "Next Up" },
74
+ { repo, project: String(projectNumber), column: nextUpColumn },
33
75
  { env, runChild: dependencies.runChild },
34
76
  );
35
77
  const order = (result?.items ?? [])
36
78
  .map((it) => it.issueNumber ?? it.prNumber)
37
79
  .filter((n) => typeof n === "number");
38
- return { ok: true, order, reason: null };
80
+ // Successful query — order may be empty (genuinely empty Next Up).
81
+ return { ok: true, configured: true, order, reason: null };
39
82
  } catch (err) {
40
- return { ok: true, order: [], reason: err.message ?? "Next Up query failed" };
83
+ // Query ERROR — surface it; the driver stops and never falls back.
84
+ return { ok: false, configured: true, order: [], reason: err.message ?? "Next Up query failed" };
41
85
  }
42
86
  }
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  import { runChild as coreRunChild } from "../cli/primitives.mjs";
5
- import { main as moveQueueItemMain } from "../../../../scripts/projects/move-queue-item.mjs";
5
+ import { main as moveQueueItemMain } from "../projects/move-queue-item.mjs";
6
6
 
7
7
  const DEFAULT_NON_SUCCESS_COLUMN = "Backlog";
8
8
 
@@ -107,6 +107,61 @@ export function boardColumnForLoopState(loopState, mapping = {}) {
107
107
  return columnNames[logical] ?? columnNames[DEFAULT_LOGICAL_COLUMN];
108
108
  }
109
109
 
110
+ /**
111
+ * Derive the board's target LOGICAL column for a queue item from live GitHub
112
+ * facts (#1069). Returns LOGICAL_COLUMN.DONE, LOGICAL_COLUMN.IN_PROGRESS, or
113
+ * null when the item should be left where it is (Backlog/Next Up untouched).
114
+ *
115
+ * facts: {
116
+ * itemKind: "issue" | "pr",
117
+ * issueState: "OPEN" | "CLOSED" | null, // for issue items
118
+ * prState: "OPEN" | "CLOSED" | "MERGED" | null, // item PR, or the issue's linked PR
119
+ * prIsDraft: boolean | null,
120
+ * }
121
+ */
122
+ export function deriveReconcileColumn(facts = {}) {
123
+ const { itemKind, issueState, prState, prIsDraft } = facts;
124
+ // Merged PR (item is a PR, or issue's linked PR merged) => Done.
125
+ if (prState === "MERGED") return LOGICAL_COLUMN.DONE;
126
+ if (itemKind === "issue" && issueState === "CLOSED") return LOGICAL_COLUMN.DONE;
127
+ // Open, ready (non-draft) PR => In Progress.
128
+ if (prState === "OPEN" && prIsDraft === false) return LOGICAL_COLUMN.IN_PROGRESS;
129
+ // Otherwise leave the item untouched (Backlog / Next Up ordering preserved).
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * Pure reconcile planner (#1069). Given listed board items, a map of live facts
135
+ * keyed by the item's stable GraphQL node id (`item.itemId`), and the resolved
136
+ * column display names, return the set of moves needed to converge the board and
137
+ * a count of items left unchanged. Idempotent: when every item already sits in
138
+ * its derived column, the moves array is empty.
139
+ *
140
+ * Keying by the stable `itemId` (not the bare issue/PR number) keeps reconcile
141
+ * deterministic on a multi-repo GitHub Projects board, where two items can share
142
+ * a number (repo-A PR #5 vs repo-B issue #5) — number-keying would collide and
143
+ * make moves order-dependent.
144
+ *
145
+ * items: [{ itemId, issueNumber, prNumber, status, ... }] (from list-queue-items)
146
+ * factsByItemId: Map<itemId, factsObject> (facts as consumed by deriveReconcileColumn)
147
+ * columnNames: { in_progress, done, ... } (LOGICAL_COLUMN -> display name)
148
+ */
149
+ export function planReconcile(items = [], factsByItemId = new Map(), columnNames = {}) {
150
+ const moves = [];
151
+ let unchanged = 0;
152
+ for (const item of items) {
153
+ const facts = factsByItemId.get(item.itemId);
154
+ const logical = facts ? deriveReconcileColumn(facts) : null;
155
+ if (logical == null) { unchanged += 1; continue; }
156
+ const target = columnNames[logical];
157
+ if (!target || item.status === target) { unchanged += 1; continue; }
158
+ // `number` is kept only for reporting; the move is applied by node id.
159
+ const number = item.prNumber != null ? item.prNumber : item.issueNumber;
160
+ moves.push({ itemId: item.itemId, number, from: item.status ?? null, to: target });
161
+ }
162
+ return { moves, unchanged };
163
+ }
164
+
110
165
  // ── Local config loader ─────────────────────────────────────────────────
111
166
 
112
167
  function readDevloopsSettings(repoRoot) {
@@ -169,7 +224,7 @@ export function loadBoardConfig(repoRoot) {
169
224
  * cannot pollute Object.prototype.
170
225
  */
171
226
  export function loadStateColumnMap(repoRoot) {
172
- const { settings: queue } = readDevloopsSettings(repoRoot);
227
+ const { settings: queue, error } = readDevloopsSettings(repoRoot);
173
228
  // Null-prototype objects: untrusted keys can never reach Object.prototype.
174
229
  const columnNames = Object.assign(Object.create(null), DEFAULT_STATE_COLUMN_NAMES);
175
230
  const stateColumnMap = Object.create(null);
@@ -200,7 +255,11 @@ export function loadStateColumnMap(repoRoot) {
200
255
  }
201
256
  }
202
257
 
203
- return { columnNames, stateColumnMap };
258
+ // Surface a non-ENOENT read/parse error (mirrors loadBoardConfig). Callers on
259
+ // the fail-closed next_up pickup path MUST honor it rather than silently
260
+ // querying the default literal column against a stale/renamed board (#1098).
261
+ // Existing `.columnNames`-only callers ignore this field and behave unchanged.
262
+ return { columnNames, stateColumnMap, error: error ?? null };
204
263
  }
205
264
 
206
265
  // ── Minimal project lookup (read-only, no create/repair) ────────────────
@@ -9,6 +9,7 @@ import {
9
9
  transitionEntry,
10
10
  snapshotEntry,
11
11
  nextReadyEntry,
12
+ findEntry,
12
13
  allDone,
13
14
  RECOVERABLE_FAILURES,
14
15
  appendBugIssue,
@@ -19,7 +20,13 @@ import {
19
20
  boardColumnForLoopState,
20
21
  loadStateColumnMap,
21
22
  } from "./queue-board-sync.mjs";
22
- import { resolveNextUpOrder } from "./queue-board-ordering.mjs";
23
+ import {
24
+ resolveNextUpOrder,
25
+ REASON_NEXT_UP_EMPTY,
26
+ REASON_BOARD_QUERY_ERROR,
27
+ REASON_NEXT_UP_TARGET_MISSING_LOCALLY,
28
+ EMPTY_NEXT_UP_MESSAGE,
29
+ } from "./queue-board-ordering.mjs";
23
30
 
24
31
  export const DEFAULT_QUEUE_DRIVER_OPTIONS = {
25
32
  mergeAuthorized: false,
@@ -92,23 +99,88 @@ export async function runQueue(repoRoot, repo, options = {}) {
92
99
  // (e.g. a configured "Ready for Review") still syncs. (#793 round-1 #1)
93
100
  const lastSyncedColumn = new Map();
94
101
 
95
- // Optional board-aware ordering: fetch Next Up order before processing.
96
- // Fail-open: if the board is unreachable, orderHint stays empty and the
97
- // driver falls back to the existing queue order.
98
- const ordering = opts.useBoardOrdering !== false && !allDone(queue)
102
+ // Next Up is the NORMATIVE, fail-closed pickup source (#1091). When a board is
103
+ // configured, the driver picks ONLY entries whose target is in Next Up, by
104
+ // POSITION ascending; entries absent from Next Up are never auto-picked. It
105
+ // NEVER falls back to Backlog or to the non-board local queue order.
106
+ //
107
+ // Single-issue/PR runs do not reach this gating at all — they run via the
108
+ // dev-loop routing path, not the queue driver — so an explicit --issue/--pr
109
+ // target is inherently unaffected by Next Up.
110
+ const ordering = !allDone(queue)
99
111
  ? await resolveNextUpOrder(repo, repoRoot, opts.env ?? process.env, opts.queueBoardSyncDependencies ?? {})
100
- : { ok: true, order: [], reason: "board ordering disabled or queue idle" };
101
- const orderHint = ordering.ok ? ordering.order : [];
112
+ : { ok: true, configured: false, order: [], reason: "queue idle" };
113
+
114
+ // (b) Board-query ERROR → surface it and stop. Do NOT fall back to Backlog
115
+ // or local order (fail-closed). Distinct from an empty Next Up below.
116
+ if (ordering.ok === false) {
117
+ return {
118
+ ok: false,
119
+ stopped: true,
120
+ reason: REASON_BOARD_QUERY_ERROR,
121
+ message: `Next Up query failed (${ordering.reason}); refusing to fall back to Backlog/local order`,
122
+ error: ordering.reason ?? "board query failed",
123
+ results: [],
124
+ queue,
125
+ ordering,
126
+ };
127
+ }
128
+
129
+ // Board-gated only when a board is configured.
130
+ const boardGated = ordering.configured === true;
131
+ const orderHint = ordering.order;
132
+ const allowedTargets = boardGated ? new Set(orderHint) : null;
133
+
134
+ // (a) Empty Next Up (successful query, zero items) → fail CLOSED: idle/stop
135
+ // with an actionable, machine-readable outcome. Never pull from Backlog.
136
+ if (boardGated && orderHint.length === 0) {
137
+ return {
138
+ ok: true,
139
+ idle: true,
140
+ reason: REASON_NEXT_UP_EMPTY,
141
+ message: EMPTY_NEXT_UP_MESSAGE,
142
+ results: [],
143
+ queue,
144
+ ordering,
145
+ };
146
+ }
147
+
148
+ // (a2) Next Up resolved one or more targets that have NO matching local queue
149
+ // entry (membership reconcile not run/persisted, or the board changed between
150
+ // reconcile and this query). Filtering them out would return a silent empty
151
+ // idle while real Next Up work goes undispatched — so fail CLOSED with an
152
+ // actionable stop instead. Distinct from the genuine empty-Next-Up idle above.
153
+ // Never pull from Backlog. (#1091)
154
+ if (boardGated) {
155
+ const missingTargets = orderHint.filter((t) => !findEntry(queue, t));
156
+ if (missingTargets.length > 0) {
157
+ return {
158
+ ok: false,
159
+ stopped: true,
160
+ reason: REASON_NEXT_UP_TARGET_MISSING_LOCALLY,
161
+ missingTargets,
162
+ message:
163
+ "Next Up contains items with no local queue entry — run membership reconcile / re-add them",
164
+ results: [],
165
+ queue,
166
+ ordering,
167
+ };
168
+ }
169
+ }
102
170
 
103
171
  let autoFiledCount = 0;
104
172
  const results = [];
105
173
  let incomplete = false;
106
174
 
107
175
  while (!allDone(queue)) {
108
- const entry = nextReadyEntry(queue, opts.reDispatchMaxRetries, orderHint);
176
+ const entry = nextReadyEntry(queue, opts.reDispatchMaxRetries, orderHint, allowedTargets);
109
177
  if (!entry) {
178
+ // When board-gated, entries absent from Next Up are intentionally NOT
179
+ // picked (and are not "blocked by deps") — only unfinished Next Up members
180
+ // count toward an incomplete verdict.
110
181
  const remaining = queue.entries.filter(
111
182
  (e) => e.status !== "done" && e.status !== "blocked" && e.status !== "failed"
183
+ && (!allowedTargets || allowedTargets.has(e.target))
112
184
  );
113
185
  if (remaining.length > 0) {
114
186
  incomplete = true;
@@ -183,9 +183,20 @@ function applyOrderHint(ordered, orderHint) {
183
183
  return [...inHint, ...rest];
184
184
  }
185
185
 
186
- export function nextReadyEntry(queue, maxRetries = 1, orderHint = []) {
186
+ /**
187
+ * Pick the next ready entry.
188
+ *
189
+ * @param {object} queue
190
+ * @param {number} maxRetries
191
+ * @param {number[]} orderHint - preferred order (targets sorted to the front).
192
+ * @param {Set<number>|null} allowedTargets - when non-null, ONLY entries whose
193
+ * target is in this set are eligible. Used for board-gated (Next Up)
194
+ * selection (#1091): entries absent from Next Up are never auto-picked.
195
+ */
196
+ export function nextReadyEntry(queue, maxRetries = 1, orderHint = [], allowedTargets = null) {
187
197
  const ordered = topologicalOrder(queue.entries);
188
- const sorted = applyOrderHint(ordered, orderHint);
198
+ const restricted = allowedTargets ? ordered.filter((e) => allowedTargets.has(e.target)) : ordered;
199
+ const sorted = applyOrderHint(restricted, orderHint);
189
200
  for (const entry of sorted) {
190
201
  if (entry.status === "queued" && entryDependenciesSatisfied(queue, entry)) {
191
202
  return entry;
@@ -18,40 +18,55 @@ export const REVIEWER_STATE = Object.freeze({
18
18
  BLOCKED_NEEDS_USER_DECISION: "blocked_needs_user_decision",
19
19
  });
20
20
 
21
+ // The reviewSubmissionStatus guard applies only after the {!prExists, prMerged, prClosed,
22
+ // prDraft} pre-gates, then fires ahead of the state-specific branches: failed ->
23
+ // blocked_needs_user_decision, submitted -> submitted_review. The table declares every such pair.
21
24
  export const REVIEWER_TRANSITIONS = Object.freeze({
22
- [REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST]: [REVIEWER_STATE.REVIEW_REQUESTED],
25
+ [REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST]: [
26
+ REVIEWER_STATE.REVIEW_REQUESTED,
27
+ REVIEWER_STATE.SUBMITTED_REVIEW,
28
+ REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
29
+ ],
23
30
  [REVIEWER_STATE.REVIEW_REQUESTED]: [
24
31
  REVIEWER_STATE.DETERMINE_REVIEW_PLAN,
32
+ REVIEWER_STATE.SUBMITTED_REVIEW,
25
33
  REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
26
34
  ],
27
35
  [REVIEWER_STATE.DETERMINE_REVIEW_PLAN]: [
28
36
  REVIEWER_STATE.REVIEWS_RUNNING,
37
+ REVIEWER_STATE.SUBMITTED_REVIEW,
29
38
  REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
30
39
  ],
31
40
  [REVIEWER_STATE.REVIEWS_RUNNING]: [
32
41
  REVIEWER_STATE.MERGE_RESULTS,
42
+ REVIEWER_STATE.SUBMITTED_REVIEW,
33
43
  REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
34
44
  ],
35
45
  [REVIEWER_STATE.MERGE_RESULTS]: [
36
46
  REVIEWER_STATE.DRAFT_REVIEW_READY,
47
+ REVIEWER_STATE.SUBMITTED_REVIEW,
37
48
  REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
38
49
  ],
39
50
  [REVIEWER_STATE.DRAFT_REVIEW_READY]: [
40
51
  REVIEWER_STATE.DRAFT_REVIEW_POSTED,
52
+ REVIEWER_STATE.SUBMITTED_REVIEW,
41
53
  REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
42
54
  ],
43
55
  [REVIEWER_STATE.DRAFT_REVIEW_POSTED]: [
44
56
  REVIEWER_STATE.WAITING_FOR_USER_SUBMIT,
45
57
  REVIEWER_STATE.REVIEW_INVALIDATED,
46
58
  REVIEWER_STATE.SUBMITTED_REVIEW,
59
+ REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
47
60
  ],
48
61
  [REVIEWER_STATE.WAITING_FOR_USER_SUBMIT]: [
49
62
  REVIEWER_STATE.SUBMITTED_REVIEW,
50
63
  REVIEWER_STATE.REVIEW_INVALIDATED,
64
+ REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
51
65
  ],
52
66
  [REVIEWER_STATE.SUBMITTED_REVIEW]: [
53
67
  REVIEWER_STATE.REVIEW_REQUESTED,
54
68
  REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST,
69
+ REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
55
70
  ],
56
71
  [REVIEWER_STATE.WAITING_FOR_AUTHOR_FOLLOWUP]: [
57
72
  REVIEWER_STATE.SUBMITTED_REVIEW,
@@ -62,7 +77,10 @@ export const REVIEWER_TRANSITIONS = Object.freeze({
62
77
  REVIEWER_STATE.REVIEW_REQUESTED,
63
78
  REVIEWER_STATE.SUBMITTED_REVIEW,
64
79
  ],
65
- [REVIEWER_STATE.REVIEW_INVALIDATED]: [REVIEWER_STATE.REVIEW_REQUESTED],
80
+ [REVIEWER_STATE.REVIEW_INVALIDATED]: [
81
+ REVIEWER_STATE.REVIEW_REQUESTED,
82
+ REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
83
+ ],
66
84
  [REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION]: [],
67
85
  });
68
86