@dev-loops/core 0.2.6 → 0.3.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 +4 -1
- package/src/bash-exit-one.mjs +26 -8
- package/src/claude/asset-generation.mjs +22 -6
- package/src/cli/primitives.mjs +84 -0
- package/src/config/config.mjs +82 -0
- package/src/config/extension-defaults.yaml +12 -0
- package/src/github/copilot-helpers.mjs +65 -0
- package/src/github/review-threads.mjs +8 -26
- package/src/loop/copilot-loop-state.mjs +35 -21
- package/src/loop/gate-fanin.mjs +222 -0
- package/src/loop/phase-files.mjs +11 -35
- package/src/loop/pr-gate-coordination.mjs +183 -3
- package/src/loop/queue-board-sync.mjs +182 -2
- package/src/loop/queue-driver.mjs +47 -12
- package/src/loop/queue-membership.mjs +145 -0
- package/src/loop/queue-state.mjs +56 -1
|
@@ -6,6 +6,107 @@ import { main as moveQueueItemMain } from "../../../../scripts/projects/move-que
|
|
|
6
6
|
|
|
7
7
|
const DEFAULT_NON_SUCCESS_COLUMN = "Backlog";
|
|
8
8
|
|
|
9
|
+
// ── State → board column mapping (AC1, AC3, AC5) ─────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// The mapping is intentionally stateless: it is a pure function of the loop
|
|
12
|
+
// state. Because of that, a reverted loop state (e.g. a merged PR reopened, or
|
|
13
|
+
// a ready PR demoted back to draft) maps backward to the earlier column for
|
|
14
|
+
// free (AC5) — there is no persisted "furthest reached" column to unwind.
|
|
15
|
+
|
|
16
|
+
/** Logical board columns. Display names are config-driven (AC3). */
|
|
17
|
+
export const LOGICAL_COLUMN = Object.freeze({
|
|
18
|
+
NEXT_UP: "next_up",
|
|
19
|
+
IN_PROGRESS: "in_progress",
|
|
20
|
+
READY_FOR_REVIEW: "ready_for_review",
|
|
21
|
+
DONE: "done",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Allow-list of recognized logical column tokens (for config validation). */
|
|
25
|
+
const KNOWN_LOGICAL_COLUMNS = new Set(Object.values(LOGICAL_COLUMN));
|
|
26
|
+
|
|
27
|
+
/** Keys that must never be copied from untrusted config (prototype pollution). */
|
|
28
|
+
const DANGEROUS_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
29
|
+
|
|
30
|
+
/** Default display name for each logical column (AC1 values). */
|
|
31
|
+
export const DEFAULT_STATE_COLUMN_NAMES = Object.freeze({
|
|
32
|
+
[LOGICAL_COLUMN.NEXT_UP]: "Next Up",
|
|
33
|
+
[LOGICAL_COLUMN.IN_PROGRESS]: "In Progress",
|
|
34
|
+
// Ready for Review is opt-in: by default it resolves to In Progress so that
|
|
35
|
+
// final_approval_ready keeps "In Progress" unless a board configures it.
|
|
36
|
+
[LOGICAL_COLUMN.READY_FOR_REVIEW]: "In Progress",
|
|
37
|
+
[LOGICAL_COLUMN.DONE]: "Done",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default loop-state → logical-column map. Covers both the lifecycle states
|
|
42
|
+
* (lifecycle-state.mjs) and the inner Copilot loop states (copilot-loop-state.mjs),
|
|
43
|
+
* plus the conceptual names used by issue #793. Unknown states fall back to
|
|
44
|
+
* IN_PROGRESS (a safe, visible "work is happening" column) rather than throwing.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_STATE_LOGICAL_MAP = Object.freeze({
|
|
47
|
+
// Next Up — work not yet actively in flight
|
|
48
|
+
issue_opened: LOGICAL_COLUMN.NEXT_UP,
|
|
49
|
+
issue_intake: LOGICAL_COLUMN.NEXT_UP,
|
|
50
|
+
refinement: LOGICAL_COLUMN.NEXT_UP,
|
|
51
|
+
no_pr: LOGICAL_COLUMN.NEXT_UP,
|
|
52
|
+
pr_draft: LOGICAL_COLUMN.NEXT_UP,
|
|
53
|
+
|
|
54
|
+
// In Progress — active implementation / review / feedback resolution
|
|
55
|
+
implementation: LOGICAL_COLUMN.IN_PROGRESS,
|
|
56
|
+
// Tolerated alias for `implementation` (conceptual name from issue #793);
|
|
57
|
+
// the queue driver passes the real `implementation` lifecycle state.
|
|
58
|
+
local_implementation_active: LOGICAL_COLUMN.IN_PROGRESS,
|
|
59
|
+
draft_gate: LOGICAL_COLUMN.IN_PROGRESS,
|
|
60
|
+
pr_ready_no_feedback: LOGICAL_COLUMN.IN_PROGRESS,
|
|
61
|
+
feedback_resolution: LOGICAL_COLUMN.IN_PROGRESS,
|
|
62
|
+
copilot_review: LOGICAL_COLUMN.IN_PROGRESS,
|
|
63
|
+
waiting_for_copilot_review: LOGICAL_COLUMN.IN_PROGRESS,
|
|
64
|
+
ready_to_rerequest_review: LOGICAL_COLUMN.IN_PROGRESS,
|
|
65
|
+
unresolved_feedback_present: LOGICAL_COLUMN.IN_PROGRESS,
|
|
66
|
+
already_fixed_needs_reply_resolve: LOGICAL_COLUMN.IN_PROGRESS,
|
|
67
|
+
waiting_for_ci: LOGICAL_COLUMN.IN_PROGRESS,
|
|
68
|
+
review_request_unavailable: LOGICAL_COLUMN.IN_PROGRESS,
|
|
69
|
+
round_cap_reached: LOGICAL_COLUMN.IN_PROGRESS,
|
|
70
|
+
round_cap_clean_fallback: LOGICAL_COLUMN.IN_PROGRESS,
|
|
71
|
+
internal_tooling_direct_gate: LOGICAL_COLUMN.IN_PROGRESS,
|
|
72
|
+
low_signal_converged: LOGICAL_COLUMN.IN_PROGRESS,
|
|
73
|
+
blocked_needs_user_decision: LOGICAL_COLUMN.IN_PROGRESS,
|
|
74
|
+
|
|
75
|
+
// Ready for Review — final approval gate. Resolves to In Progress unless a
|
|
76
|
+
// board configures a distinct "Ready for Review" column name (AC1).
|
|
77
|
+
pre_approval_gate: LOGICAL_COLUMN.READY_FOR_REVIEW,
|
|
78
|
+
final_approval_ready: LOGICAL_COLUMN.READY_FOR_REVIEW,
|
|
79
|
+
|
|
80
|
+
// Done — terminal (lifecycle MERGE = "merge", queue terminal = "done")
|
|
81
|
+
merge: LOGICAL_COLUMN.DONE,
|
|
82
|
+
done: LOGICAL_COLUMN.DONE,
|
|
83
|
+
// Tolerated aliases (conceptual names from issue #793).
|
|
84
|
+
merged: LOGICAL_COLUMN.DONE,
|
|
85
|
+
issue_closed: LOGICAL_COLUMN.DONE,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** Safe default logical column for any state we do not explicitly map. */
|
|
89
|
+
const DEFAULT_LOGICAL_COLUMN = LOGICAL_COLUMN.IN_PROGRESS;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Pure mapping: loop state → board column display name.
|
|
93
|
+
*
|
|
94
|
+
* @param {string|null|undefined} loopState - a lifecycle or inner loop state
|
|
95
|
+
* name. `null`, `undefined`, and any unrecognized value fall through to the
|
|
96
|
+
* safe default logical column (IN_PROGRESS).
|
|
97
|
+
* @param {{stateColumnMap?:Object, columnNames?:Object}} [mapping]
|
|
98
|
+
* Optional overrides. `stateColumnMap` overrides state→logical-column;
|
|
99
|
+
* `columnNames` overrides logical-column→display-name. Both fall back to
|
|
100
|
+
* the AC1 defaults.
|
|
101
|
+
* @returns {string} the target board column display name.
|
|
102
|
+
*/
|
|
103
|
+
export function boardColumnForLoopState(loopState, mapping = {}) {
|
|
104
|
+
const stateMap = { ...DEFAULT_STATE_LOGICAL_MAP, ...(mapping.stateColumnMap ?? {}) };
|
|
105
|
+
const columnNames = { ...DEFAULT_STATE_COLUMN_NAMES, ...(mapping.columnNames ?? {}) };
|
|
106
|
+
const logical = stateMap[loopState] ?? DEFAULT_LOGICAL_COLUMN;
|
|
107
|
+
return columnNames[logical] ?? columnNames[DEFAULT_LOGICAL_COLUMN];
|
|
108
|
+
}
|
|
109
|
+
|
|
9
110
|
// ── Local config loader ─────────────────────────────────────────────────
|
|
10
111
|
|
|
11
112
|
function readDevloopsSettings(repoRoot) {
|
|
@@ -46,6 +147,62 @@ export function loadBoardConfig(repoRoot) {
|
|
|
46
147
|
return { enabled: false };
|
|
47
148
|
}
|
|
48
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Load the config-driven state→column mapping from `.devloops` `queue` (AC3).
|
|
152
|
+
*
|
|
153
|
+
* Reads two optional config keys, both gated behind the same opt-in `queue`
|
|
154
|
+
* section as `loadBoardConfig` (AC2/AC6):
|
|
155
|
+
* - `queue.statusColumns` — logical-column → display-name overrides
|
|
156
|
+
* (keys: next_up, in_progress, ready_for_review, done)
|
|
157
|
+
* - `queue.stateColumnMap` — loop-state → logical-column overrides
|
|
158
|
+
*
|
|
159
|
+
* Returns a `{ stateColumnMap, columnNames }` shape consumable by
|
|
160
|
+
* `boardColumnForLoopState`. Missing config yields the AC1 defaults.
|
|
161
|
+
*
|
|
162
|
+
* Hardened against untrusted `.devloops` input:
|
|
163
|
+
* - `statusColumns` keys are allow-listed to the known logical columns;
|
|
164
|
+
* unrecognized keys are ignored.
|
|
165
|
+
* - `stateColumnMap` entries whose value is not a known logical column are
|
|
166
|
+
* ignored.
|
|
167
|
+
* - Dangerous keys (`__proto__`, `prototype`, `constructor`) are skipped and
|
|
168
|
+
* results are built on null-prototype objects, so a malicious config key
|
|
169
|
+
* cannot pollute Object.prototype.
|
|
170
|
+
*/
|
|
171
|
+
export function loadStateColumnMap(repoRoot) {
|
|
172
|
+
const { settings: queue } = readDevloopsSettings(repoRoot);
|
|
173
|
+
// Null-prototype objects: untrusted keys can never reach Object.prototype.
|
|
174
|
+
const columnNames = Object.assign(Object.create(null), DEFAULT_STATE_COLUMN_NAMES);
|
|
175
|
+
const stateColumnMap = Object.create(null);
|
|
176
|
+
|
|
177
|
+
const statusColumns = queue?.statusColumns;
|
|
178
|
+
if (statusColumns && typeof statusColumns === "object") {
|
|
179
|
+
for (const logical of Object.keys(statusColumns)) {
|
|
180
|
+
if (DANGEROUS_KEYS.has(logical)) continue;
|
|
181
|
+
// Allow-list: only recognized logical columns may be renamed.
|
|
182
|
+
if (!KNOWN_LOGICAL_COLUMNS.has(logical)) continue;
|
|
183
|
+
const name = statusColumns[logical];
|
|
184
|
+
if (typeof name === "string" && name.trim().length > 0) {
|
|
185
|
+
columnNames[logical] = name.trim();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const stateMap = queue?.stateColumnMap;
|
|
191
|
+
if (stateMap && typeof stateMap === "object") {
|
|
192
|
+
for (const state of Object.keys(stateMap)) {
|
|
193
|
+
if (DANGEROUS_KEYS.has(state)) continue;
|
|
194
|
+
const logical = stateMap[state];
|
|
195
|
+
// Ignore values that are not a recognized logical column.
|
|
196
|
+
if (typeof logical !== "string") continue;
|
|
197
|
+
const trimmed = logical.trim();
|
|
198
|
+
if (!KNOWN_LOGICAL_COLUMNS.has(trimmed)) continue;
|
|
199
|
+
stateColumnMap[state] = trimmed;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { columnNames, stateColumnMap };
|
|
204
|
+
}
|
|
205
|
+
|
|
49
206
|
// ── Minimal project lookup (read-only, no create/repair) ────────────────
|
|
50
207
|
|
|
51
208
|
const GET_USER_ID = [
|
|
@@ -187,6 +344,14 @@ export async function syncBoardStatus(
|
|
|
187
344
|
env = process.env,
|
|
188
345
|
dependencies = {},
|
|
189
346
|
) {
|
|
347
|
+
// AC4: the not-on-board / fail-open path is a logged no-op. Default to
|
|
348
|
+
// console.error so it logs in real runs; tests inject their own stub. The
|
|
349
|
+
// log fires at most once per syncBoardStatus call (single catch, no internal
|
|
350
|
+
// retry), so it cannot spam.
|
|
351
|
+
const log = typeof dependencies.log === "function"
|
|
352
|
+
? dependencies.log
|
|
353
|
+
: (msg) => console.error(msg);
|
|
354
|
+
|
|
190
355
|
const config = loadBoardConfig(repoRoot);
|
|
191
356
|
if (!config.enabled) {
|
|
192
357
|
return { ok: true, skipped: true, reason: config.reason ?? "board not configured" };
|
|
@@ -205,12 +370,27 @@ export async function syncBoardStatus(
|
|
|
205
370
|
const moveItem = dependencies.moveQueueItem ?? moveQueueItemMain;
|
|
206
371
|
try {
|
|
207
372
|
const result = await moveItem(
|
|
208
|
-
|
|
373
|
+
// move-queue-item validates project + item as string refs (CLI contract);
|
|
374
|
+
// resolveProjectNumber yields a number and itemNumber is numeric, so
|
|
375
|
+
// stringify both.
|
|
376
|
+
{ repo, project: String(projectNumber), item: String(itemNumber), toColumn: targetColumn },
|
|
209
377
|
{ env, runChild: dependencies.runChild },
|
|
210
378
|
);
|
|
211
379
|
return { ok: true, skipped: false, result };
|
|
212
380
|
} catch (err) {
|
|
213
|
-
|
|
381
|
+
// Fail-open: a board hiccup (rate limit, missing column, item not on board)
|
|
382
|
+
// must never break the loop.
|
|
383
|
+
const reason = err?.message ?? "board sync failed";
|
|
384
|
+
// AC4: the explicit "item not on board" case is a clean, logged no-op.
|
|
385
|
+
// Other fail-open failures (rate limit, missing column, etc.) get a
|
|
386
|
+
// distinct, distinguishable message so they are not conflated with AC4.
|
|
387
|
+
const notOnBoard = err?.code === "ITEM_NOT_FOUND" || err?.code === "ITEM_NOT_ON_BOARD";
|
|
388
|
+
if (notOnBoard) {
|
|
389
|
+
log(`[board-sync] no-op: item ${itemNumber} is not on the board (${reason})`);
|
|
390
|
+
} else {
|
|
391
|
+
log(`[board-sync] sync failed (fail-open) for item ${itemNumber} → "${targetColumn}": ${reason}`);
|
|
392
|
+
}
|
|
393
|
+
return { ok: true, skipped: true, reason };
|
|
214
394
|
}
|
|
215
395
|
}
|
|
216
396
|
|
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
RECOVERABLE_FAILURES,
|
|
14
14
|
appendBugIssue,
|
|
15
15
|
} from "./queue-state.mjs";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
syncBoardStatus,
|
|
18
|
+
nonSuccessBoardColumn,
|
|
19
|
+
boardColumnForLoopState,
|
|
20
|
+
loadStateColumnMap,
|
|
21
|
+
} from "./queue-board-sync.mjs";
|
|
17
22
|
import { resolveNextUpOrder } from "./queue-board-ordering.mjs";
|
|
18
23
|
|
|
19
24
|
export const DEFAULT_QUEUE_DRIVER_OPTIONS = {
|
|
@@ -52,6 +57,19 @@ export async function runQueue(repoRoot, repo, options = {}) {
|
|
|
52
57
|
const opts = { ...DEFAULT_QUEUE_DRIVER_OPTIONS, ...options };
|
|
53
58
|
const queue = await readQueue(repoRoot);
|
|
54
59
|
|
|
60
|
+
// Config-driven loop-state → board-column mapping (#793, AC1/AC3). Loaded
|
|
61
|
+
// once per run; resolves logical columns to configured display names, with
|
|
62
|
+
// the AC1 defaults when no `queue.statusColumns`/`queue.stateColumnMap` is set.
|
|
63
|
+
const stateColumnMap = loadStateColumnMap(repoRoot);
|
|
64
|
+
const columnFor = (loopState) => boardColumnForLoopState(loopState, stateColumnMap);
|
|
65
|
+
|
|
66
|
+
// Per-item dedup: a single run may resolve consecutive loop states to the
|
|
67
|
+
// same display column (e.g. implementation → final_approval_ready both
|
|
68
|
+
// default to "In Progress"). Skip the redundant board write/API call when the
|
|
69
|
+
// target column is unchanged for that item; a genuinely different column
|
|
70
|
+
// (e.g. a configured "Ready for Review") still syncs. (#793 round-1 #1)
|
|
71
|
+
const lastSyncedColumn = new Map();
|
|
72
|
+
|
|
55
73
|
// Optional board-aware ordering: fetch Next Up order before processing.
|
|
56
74
|
// Fail-open: if the board is unreachable, orderHint stays empty and the
|
|
57
75
|
// driver falls back to the existing queue order.
|
|
@@ -95,15 +113,26 @@ export async function runQueue(repoRoot, repo, options = {}) {
|
|
|
95
113
|
boardSync.push(r);
|
|
96
114
|
return r;
|
|
97
115
|
};
|
|
116
|
+
// Sync a target to a column, short-circuiting (no API call) when the column
|
|
117
|
+
// is unchanged from the last sync for the same item in this run.
|
|
118
|
+
const syncColumn = async (target, column) => {
|
|
119
|
+
if (lastSyncedColumn.get(target) === column) {
|
|
120
|
+
return recordBoardSync(Promise.resolve({
|
|
121
|
+
ok: true, skipped: true, reason: "column unchanged",
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
const r = await recordBoardSync(syncBoardStatus(
|
|
125
|
+
repo, repoRoot, target, column, opts.env ?? process.env, boardSyncDeps,
|
|
126
|
+
));
|
|
127
|
+
// Only remember the column when the move actually landed, so a fail-open
|
|
128
|
+
// skip does not suppress a later retry to the same column.
|
|
129
|
+
if (r.ok && r.skipped !== true) lastSyncedColumn.set(target, column);
|
|
130
|
+
return r;
|
|
131
|
+
};
|
|
98
132
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
entry.target,
|
|
103
|
-
"In Progress",
|
|
104
|
-
opts.env ?? process.env,
|
|
105
|
-
boardSyncDeps,
|
|
106
|
-
));
|
|
133
|
+
// Entry has been picked up and is actively running: implementation phase
|
|
134
|
+
// (real lifecycle state, lifecycle-state.mjs LIFECYCLE_STATE.IMPLEMENTATION).
|
|
135
|
+
await syncColumn(entry.target, columnFor("implementation"));
|
|
107
136
|
|
|
108
137
|
try {
|
|
109
138
|
const entryResult = opts.runEntry
|
|
@@ -117,12 +146,18 @@ export async function runQueue(repoRoot, repo, options = {}) {
|
|
|
117
146
|
if (opts.mergeAuthorized) {
|
|
118
147
|
await doTransition(entry, "merging", queue, repoRoot, opts);
|
|
119
148
|
await doTransition(entry, "done", queue, repoRoot, opts, { retrospectiveWritten: true });
|
|
120
|
-
await
|
|
149
|
+
await syncColumn(entry.target, columnFor("done"));
|
|
150
|
+
} else {
|
|
151
|
+
// PR is up with gates passing but merge is not authorized: the work
|
|
152
|
+
// is awaiting final approval/merge. Map to the final-approval column
|
|
153
|
+
// (configured "Ready for Review" if present, else "In Progress").
|
|
154
|
+
// Deduped: when this resolves to the same column already synced for
|
|
155
|
+
// this item, no extra board write/API call is made.
|
|
156
|
+
await syncColumn(entry.target, columnFor("final_approval_ready"));
|
|
121
157
|
}
|
|
122
|
-
// else: stays at gates_passing for future merge run
|
|
123
158
|
} else {
|
|
124
159
|
await doTransition(entry, "done", queue, repoRoot, opts);
|
|
125
|
-
await
|
|
160
|
+
await syncColumn(entry.target, columnFor("done"));
|
|
126
161
|
}
|
|
127
162
|
results.push({ target: entry.target, ok: true, entry: snapshotEntry(entry), boardSync });
|
|
128
163
|
} else {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue membership reconciliation (issue #864).
|
|
3
|
+
*
|
|
4
|
+
* A configured GitHub Projects board is the authoritative source of queue
|
|
5
|
+
* MEMBERSHIP (which issues to work) and ordering — not just status. Before a
|
|
6
|
+
* queue run, this module folds the board's "Next Up" items into the local
|
|
7
|
+
* `.pi/dev-loop-queue.json` entries so the board drives membership, and reports
|
|
8
|
+
* a clear, non-misleading emptiness verdict.
|
|
9
|
+
*
|
|
10
|
+
* This is the testable orchestration seam used by `scripts/loop/run-queue.mjs`.
|
|
11
|
+
* Board config loading, Next Up resolution, and queue persistence are all
|
|
12
|
+
* injectable so the policy can be exercised without GitHub or the filesystem.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { loadBoardConfig } from "./queue-board-sync.mjs";
|
|
16
|
+
import { resolveNextUpOrder } from "./queue-board-ordering.mjs";
|
|
17
|
+
import { reconcileEntriesFromBoard, writeQueue, pendingEntries } from "./queue-state.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reconcile a configured board's Next Up membership into the queue, then
|
|
21
|
+
* classify emptiness.
|
|
22
|
+
*
|
|
23
|
+
* Behavior:
|
|
24
|
+
* - Board NOT configured: no reconcile. Emptiness is judged purely on the
|
|
25
|
+
* local queue (preserving the legacy "Queue is empty" behavior).
|
|
26
|
+
* - Board configured: resolve Next Up targets and reconcile them in. If the
|
|
27
|
+
* resolver fails-open (returns no targets) the board membership simply
|
|
28
|
+
* contributes nothing; we never crash. Newly added targets are persisted
|
|
29
|
+
* via `writeQueue`.
|
|
30
|
+
*
|
|
31
|
+
* Emptiness verdict (`emptiness`):
|
|
32
|
+
* - `null` — there is pending work; the caller should run the queue.
|
|
33
|
+
* - "queue_empty" — board not configured and the local queue is empty
|
|
34
|
+
* (legacy "Queue is empty" message).
|
|
35
|
+
* - "board_empty" — board IS configured, Next Up resolved successfully
|
|
36
|
+
* (reason == null) but is genuinely empty, and there is
|
|
37
|
+
* no pending local work. Distinct from the misleading
|
|
38
|
+
* generic empty case.
|
|
39
|
+
* - "board_unavailable" — board IS configured but Next Up resolution failed
|
|
40
|
+
* (fail-open: empty order WITH a non-null reason) and
|
|
41
|
+
* the local queue had no pending work to fall back to.
|
|
42
|
+
* This must NOT be reported as "board_empty" — the
|
|
43
|
+
* board may well have items we simply could not read.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} repoRoot
|
|
46
|
+
* @param {string} repo - "owner/name"
|
|
47
|
+
* @param {{version:number, entries:Array}} queue
|
|
48
|
+
* @param {object} [deps]
|
|
49
|
+
* @param {(repoRoot:string)=>{enabled:boolean}} [deps.loadBoardConfig]
|
|
50
|
+
* @param {(repo:string, repoRoot:string, env:object, d:object)=>Promise<{ok:boolean, order:number[], reason:?string}>} [deps.resolveNextUpOrder]
|
|
51
|
+
* @param {(repoRoot:string, queue:object)=>Promise<void>} [deps.writeQueue]
|
|
52
|
+
* @param {(msg:string)=>void} [deps.log]
|
|
53
|
+
* @returns {Promise<{queue:object, added:number[], boardConfigured:boolean, emptiness:(null|"queue_empty"|"board_empty"|"board_unavailable"), reason:(string|null)}>}
|
|
54
|
+
*/
|
|
55
|
+
export async function reconcileBoardMembership(repoRoot, repo, queue, deps = {}) {
|
|
56
|
+
const loadConfig = deps.loadBoardConfig ?? loadBoardConfig;
|
|
57
|
+
const resolveOrder = deps.resolveNextUpOrder ?? resolveNextUpOrder;
|
|
58
|
+
const persist = deps.writeQueue ?? writeQueue;
|
|
59
|
+
const log = typeof deps.log === "function" ? deps.log : (msg) => console.error(msg);
|
|
60
|
+
|
|
61
|
+
let boardConfigured = false;
|
|
62
|
+
try {
|
|
63
|
+
const config = loadConfig(repoRoot);
|
|
64
|
+
boardConfigured = Boolean(config?.enabled);
|
|
65
|
+
// loadBoardConfig does not throw on read/parse failures; it reports them
|
|
66
|
+
// via `{ enabled: false, reason: ... }`. Surface that reason so a genuine
|
|
67
|
+
// config read/parse error is visible instead of being silently treated as
|
|
68
|
+
// "board not configured". The ordinary "board not configured" case carries
|
|
69
|
+
// no reason and must stay quiet.
|
|
70
|
+
if (!boardConfigured && config?.reason) {
|
|
71
|
+
log(`[queue-membership] board config unavailable (fail-open): ${config.reason}`);
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Config read errors must not crash the queue; treat as unconfigured.
|
|
75
|
+
log(`[queue-membership] board config read failed (fail-open): ${err?.message ?? err}`);
|
|
76
|
+
boardConfigured = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let added = [];
|
|
80
|
+
let reason = null;
|
|
81
|
+
// True when the board is configured but Next Up could not be resolved: the
|
|
82
|
+
// resolver is fail-open, so this surfaces as an empty order WITH a non-null
|
|
83
|
+
// reason (or a thrown error caught below). We must not confuse this with a
|
|
84
|
+
// genuinely empty Next Up (empty order AND reason == null).
|
|
85
|
+
let resolutionFailed = false;
|
|
86
|
+
|
|
87
|
+
if (boardConfigured) {
|
|
88
|
+
let nextUp = [];
|
|
89
|
+
try {
|
|
90
|
+
// env/board dependencies use resolveNextUpOrder's own defaults
|
|
91
|
+
// (process.env / {}); the production caller never overrides them, and
|
|
92
|
+
// tests inject a stubbed resolveNextUpOrder.
|
|
93
|
+
const result = await resolveOrder(repo, repoRoot, process.env, {});
|
|
94
|
+
reason = result?.reason ?? null;
|
|
95
|
+
nextUp = Array.isArray(result?.order) ? result.order : [];
|
|
96
|
+
// Fail-open contract: an empty order paired with a non-null reason means
|
|
97
|
+
// resolution failed (API error, board lookup failure, etc.) — NOT that the
|
|
98
|
+
// board's Next Up is genuinely empty.
|
|
99
|
+
if (nextUp.length === 0 && reason != null) {
|
|
100
|
+
resolutionFailed = true;
|
|
101
|
+
log(`[queue-membership] Next Up resolution failed (fail-open), using local queue: ${reason}`);
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// resolveNextUpOrder is itself fail-open, but guard anyway: a board
|
|
105
|
+
// resolution error falls back to the local queue without crashing.
|
|
106
|
+
reason = err?.message ?? "board resolution failed";
|
|
107
|
+
resolutionFailed = true;
|
|
108
|
+
log(`[queue-membership] Next Up resolution failed (fail-open), using local queue: ${reason}`);
|
|
109
|
+
nextUp = [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (nextUp.length > 0) {
|
|
113
|
+
const outcome = reconcileEntriesFromBoard(queue, nextUp);
|
|
114
|
+
added = outcome.added;
|
|
115
|
+
if (added.length > 0) {
|
|
116
|
+
log(`[queue-membership] added ${added.length} entr${added.length === 1 ? "y" : "ies"} from board Next Up: ${added.join(", ")}`);
|
|
117
|
+
try {
|
|
118
|
+
await persist(repoRoot, queue);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// A write failure should not crash the run; entries still drive this
|
|
121
|
+
// in-memory run, they just are not persisted for the next invocation.
|
|
122
|
+
log(`[queue-membership] failed to persist reconciled queue (continuing in-memory): ${err?.message ?? err}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pending = pendingEntries(queue);
|
|
129
|
+
let emptiness = null;
|
|
130
|
+
if (pending.length === 0) {
|
|
131
|
+
if (!boardConfigured) {
|
|
132
|
+
emptiness = "queue_empty";
|
|
133
|
+
} else if (resolutionFailed) {
|
|
134
|
+
// Board configured but we could not read Next Up and the local queue is
|
|
135
|
+
// empty: the board may have items we simply failed to fetch. Report a
|
|
136
|
+
// distinct verdict instead of the misleading "board_empty".
|
|
137
|
+
emptiness = "board_unavailable";
|
|
138
|
+
} else {
|
|
139
|
+
// Board configured, Next Up resolved cleanly (reason == null) and empty.
|
|
140
|
+
emptiness = "board_empty";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { queue, added, boardConfigured, emptiness, reason };
|
|
145
|
+
}
|
package/src/loop/queue-state.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export async function writeQueue(repoRoot, queue) {
|
|
|
81
81
|
export function createEntry(target, kind, dependsOn = []) {
|
|
82
82
|
return {
|
|
83
83
|
target,
|
|
84
|
-
kind, // "issue" | "pr"
|
|
84
|
+
kind, // "issue" | "pr" | "board" (board = issue-or-PR, resolved by number at run time)
|
|
85
85
|
status: "queued",
|
|
86
86
|
dependsOn: Array.isArray(dependsOn) ? dependsOn : [],
|
|
87
87
|
pr: null,
|
|
@@ -211,6 +211,61 @@ export function pendingEntries(queue) {
|
|
|
211
211
|
);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
// ── Board membership reconciliation ──────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reconcile board-driven membership into the local queue (issue #864).
|
|
218
|
+
*
|
|
219
|
+
* A configured GitHub Projects board is the authoritative source of queue
|
|
220
|
+
* MEMBERSHIP (which issues should be worked), not just ordering/status. This
|
|
221
|
+
* pure helper folds the board's "Next Up" targets into the queue: for each
|
|
222
|
+
* target not already present (by `findEntry`), it appends a fresh queued
|
|
223
|
+
* `createEntry(target, "board")`. Existing entries — and their status, order,
|
|
224
|
+
* and metadata — are preserved untouched, and targets already present are
|
|
225
|
+
* skipped (dedup).
|
|
226
|
+
*
|
|
227
|
+
* Entry kind is `"board"` rather than `"issue"`: the board's Next Up can hold
|
|
228
|
+
* issues OR PRs, and `resolveNextUpOrder` collapses each item to a bare number
|
|
229
|
+
* (`issueNumber ?? prNumber`), discarding the artifact kind. The queue driver
|
|
230
|
+
* never branches on `entry.kind`; it dispatches purely by `entry.target` and
|
|
231
|
+
* the per-entry startup resolver resolves the actual artifact (issue or PR) by
|
|
232
|
+
* number at run time. So a neutral `"board"` kind correctly records the
|
|
233
|
+
* provenance without falsely asserting "issue" for PR-backed board items.
|
|
234
|
+
*
|
|
235
|
+
* Pure: performs no I/O. Mutates and returns the passed `queue` object (its
|
|
236
|
+
* `entries` array is appended to in place) plus the list of newly added
|
|
237
|
+
* targets so the caller can log/report.
|
|
238
|
+
*
|
|
239
|
+
* @param {{version:number, entries:Array}} queue - the current queue.
|
|
240
|
+
* @param {Array<number>} nextUpTargets - board "Next Up" issue/PR numbers.
|
|
241
|
+
* @returns {{queue:{version:number, entries:Array}, added:number[]}}
|
|
242
|
+
*/
|
|
243
|
+
export function reconcileEntriesFromBoard(queue, nextUpTargets) {
|
|
244
|
+
const added = [];
|
|
245
|
+
if (!queue || !Array.isArray(queue.entries)) {
|
|
246
|
+
return { queue, added };
|
|
247
|
+
}
|
|
248
|
+
if (!Array.isArray(nextUpTargets) || nextUpTargets.length === 0) {
|
|
249
|
+
return { queue, added };
|
|
250
|
+
}
|
|
251
|
+
for (const target of nextUpTargets) {
|
|
252
|
+
// Guard against malformed/duplicate board input: only positive-integer
|
|
253
|
+
// targets, and never add the same target twice (even if it repeats in the
|
|
254
|
+
// board list and was not yet in the queue at the start of this loop).
|
|
255
|
+
//
|
|
256
|
+
// The integer guard is critical for NaN: a NaN target never dedups via
|
|
257
|
+
// findEntry (NaN !== NaN), so accepting it would re-append a fresh NaN
|
|
258
|
+
// entry on every reconcile. Infinity, non-integers, negatives, and 0 are
|
|
259
|
+
// never valid issue/PR numbers either, so reject anything that is not a
|
|
260
|
+
// positive integer.
|
|
261
|
+
if (!Number.isInteger(target) || target <= 0) continue;
|
|
262
|
+
if (findEntry(queue, target)) continue;
|
|
263
|
+
queue.entries.push(createEntry(target, "board"));
|
|
264
|
+
added.push(target);
|
|
265
|
+
}
|
|
266
|
+
return { queue, added };
|
|
267
|
+
}
|
|
268
|
+
|
|
214
269
|
// ── Bug injection ────────────────────────────────────────────────────
|
|
215
270
|
|
|
216
271
|
export function appendBugIssue(queue, issueNumber, dependsOn = null) {
|