@dev-loops/core 0.2.7 → 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/config/config.mjs +82 -0
- package/src/config/extension-defaults.yaml +12 -0
- package/src/github/copilot-helpers.mjs +65 -0
- package/src/loop/gate-fanin.mjs +222 -0
- package/src/loop/pr-gate-coordination.mjs +183 -3
- package/src/loop/queue-board-sync.mjs +4 -1
- package/src/loop/queue-membership.mjs +145 -0
- package/src/loop/queue-state.mjs +56 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
|
|
6
6
|
"exports": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
|
|
29
29
|
"./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
|
|
30
30
|
"./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
|
|
31
|
+
"./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
|
|
31
32
|
"./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
|
|
32
33
|
"./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
|
|
33
34
|
"./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
|
|
@@ -36,7 +37,9 @@
|
|
|
36
37
|
"./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
|
|
37
38
|
"./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
|
|
38
39
|
"./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
|
|
40
|
+
"./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
|
|
39
41
|
"./loop/queue-driver": "./src/loop/queue-driver.mjs",
|
|
42
|
+
"./loop/queue-membership": "./src/loop/queue-membership.mjs",
|
|
40
43
|
"./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
|
|
41
44
|
"./loop/queue-state": "./src/loop/queue-state.mjs",
|
|
42
45
|
"./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
|
package/src/config/config.mjs
CHANGED
|
@@ -53,6 +53,22 @@ const GatesConfig = z.strictObject({
|
|
|
53
53
|
// `requireCi` is only behaviorally configurable for the draft gate.
|
|
54
54
|
// preApproval always requires CI even if config repeats `requireCi`.
|
|
55
55
|
preApproval: GateConfig.optional(),
|
|
56
|
+
// Fail-closed enforcement that a gate verdict was produced by the
|
|
57
|
+
// fan-out/fan-in review sub-loop (executionMode === "fanout_fanin" plus a
|
|
58
|
+
// durable findings-log ledger), not an inline single-agent run. Default
|
|
59
|
+
// true (opt-out): a clean gate verdict requires fan-out/fan-in evidence
|
|
60
|
+
// unless explicitly disabled. See docs/gate-review-sub-loop-contract.md.
|
|
61
|
+
requireFanoutEvidence: z.boolean().default(true),
|
|
62
|
+
// Cap on how many scoped `review` reviewers the gate fan-out spawns in
|
|
63
|
+
// parallel. When the resolved angle set exceeds this cap, the overflow runs
|
|
64
|
+
// in sequential batches and the degradation is recorded in the gate evidence.
|
|
65
|
+
maxFanoutReviewers: z.number().int().min(1).max(64).default(8),
|
|
66
|
+
// Post the consolidated gate fan-out findings as a visible, marker-tagged PR
|
|
67
|
+
// comment so they are auditable and Copilot/humans are aware of them. Default
|
|
68
|
+
// true (opt-out). The disposition ledger is written regardless; this flag only
|
|
69
|
+
// suppresses the PR comment when explicitly false. See
|
|
70
|
+
// docs/gate-review-sub-loop-contract.md.
|
|
71
|
+
postFindingsComments: z.boolean().default(true),
|
|
56
72
|
});
|
|
57
73
|
|
|
58
74
|
const AutonomyConfig = z.strictObject({
|
|
@@ -85,6 +101,7 @@ const QueueConfig = z.strictObject({
|
|
|
85
101
|
reDispatchMaxRetries: z.number().int().min(0).max(10).default(1),
|
|
86
102
|
projectNumber: z.number().int().positive().optional(),
|
|
87
103
|
boardTitle: z.string().trim().min(1).optional(),
|
|
104
|
+
archiveOlderThanDays: z.number().int().positive().optional(),
|
|
88
105
|
});
|
|
89
106
|
|
|
90
107
|
/** Internal path whitelist for internal-only PR detection — flat array of regex strings */
|
|
@@ -106,6 +123,9 @@ const FileGateConfig = GateConfig.partial();
|
|
|
106
123
|
const FileGatesConfig = z.strictObject({
|
|
107
124
|
draft: FileGateConfig.optional(),
|
|
108
125
|
preApproval: FileGateConfig.optional(),
|
|
126
|
+
requireFanoutEvidence: z.boolean().optional(),
|
|
127
|
+
maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
|
|
128
|
+
postFindingsComments: z.boolean().optional(),
|
|
109
129
|
});
|
|
110
130
|
|
|
111
131
|
// Partial persona entries for file-level config (allows omitting fields)
|
|
@@ -230,6 +250,8 @@ const BUILTIN_PERSONAS = Object.freeze({
|
|
|
230
250
|
"state-concurrency": { persona: "review", defaultModel: null },
|
|
231
251
|
"renderer-security": { persona: "review", defaultModel: null },
|
|
232
252
|
determinism: { persona: "review", defaultModel: null },
|
|
253
|
+
"acceptance-criteria": { persona: "review", defaultModel: null },
|
|
254
|
+
"ac-dod": { persona: "review", defaultModel: null },
|
|
233
255
|
});
|
|
234
256
|
|
|
235
257
|
const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
|
|
@@ -814,6 +836,66 @@ export function resolveGateConfig(config, gate) {
|
|
|
814
836
|
};
|
|
815
837
|
}
|
|
816
838
|
|
|
839
|
+
/**
|
|
840
|
+
* Resolve whether fan-out/fan-in review evidence is required for a gate verdict.
|
|
841
|
+
*
|
|
842
|
+
* Default-on (opt-out): enforcement is ON unless `gates.requireFanoutEvidence`
|
|
843
|
+
* is explicitly set to false. When ON, the pre-merge evidence check fails
|
|
844
|
+
* closed unless a required gate's recorded executionMode is "fanout_fanin" and
|
|
845
|
+
* a durable findings-log ledger exists for that gate + head SHA. Using a
|
|
846
|
+
* `!== false` test (rather than `=== true`) keeps the opt-out semantics robust
|
|
847
|
+
* for programmatically-built config objects that bypass schema defaulting. See
|
|
848
|
+
* docs/gate-review-sub-loop-contract.md.
|
|
849
|
+
*
|
|
850
|
+
* @param {DevLoopConfig} config
|
|
851
|
+
* @returns {boolean}
|
|
852
|
+
*/
|
|
853
|
+
export function resolveRequireFanoutEvidence(config) {
|
|
854
|
+
return config?.gates?.requireFanoutEvidence !== false;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Default parallel fan-out reviewer cap (mirrors GatesConfig.maxFanoutReviewers). */
|
|
858
|
+
export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Resolve the parallel fan-out reviewer cap for the gate sub-loop.
|
|
862
|
+
*
|
|
863
|
+
* Returns the configured `gates.maxFanoutReviewers` when it is an integer in
|
|
864
|
+
* the schema-bounded range 1..64; otherwise the built-in default (8). Clamping
|
|
865
|
+
* here (not just the Zod schema) keeps programmatically-constructed config
|
|
866
|
+
* objects that bypass schema validation within the same bound. The fan-out
|
|
867
|
+
* spawns at most this many scoped `review` reviewers in parallel; overflow runs
|
|
868
|
+
* sequentially.
|
|
869
|
+
*
|
|
870
|
+
* @param {DevLoopConfig} config
|
|
871
|
+
* @returns {number}
|
|
872
|
+
*/
|
|
873
|
+
export function resolveMaxFanoutReviewers(config) {
|
|
874
|
+
const raw = config?.gates?.maxFanoutReviewers;
|
|
875
|
+
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 64) {
|
|
876
|
+
return raw;
|
|
877
|
+
}
|
|
878
|
+
return DEFAULT_MAX_FANOUT_REVIEWERS;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Resolve whether the consolidated gate fan-out findings should be posted as a
|
|
883
|
+
* visible, marker-tagged PR comment.
|
|
884
|
+
*
|
|
885
|
+
* Returns true (post the comment) unless `gates.postFindingsComments` is
|
|
886
|
+
* explicitly set to false. Using a `!== false` test (rather than `=== true`)
|
|
887
|
+
* keeps the opt-out semantics robust for programmatically-built config objects
|
|
888
|
+
* that bypass schema defaulting. The disposition ledger is written regardless;
|
|
889
|
+
* this flag only suppresses the auditable PR comment. See
|
|
890
|
+
* docs/gate-review-sub-loop-contract.md.
|
|
891
|
+
*
|
|
892
|
+
* @param {DevLoopConfig} config
|
|
893
|
+
* @returns {boolean}
|
|
894
|
+
*/
|
|
895
|
+
export function resolveGatePostFindingsComments(config) {
|
|
896
|
+
return config?.gates?.postFindingsComments !== false;
|
|
897
|
+
}
|
|
898
|
+
|
|
817
899
|
/**
|
|
818
900
|
* Resolve local implementation light mode config.
|
|
819
901
|
*
|
|
@@ -382,6 +382,18 @@ personas:
|
|
|
382
382
|
Do not block on formatting preferences other than checkbox correctness.
|
|
383
383
|
defaultModel: null
|
|
384
384
|
|
|
385
|
+
acceptance-criteria:
|
|
386
|
+
persona: review
|
|
387
|
+
prompt: >-
|
|
388
|
+
Verify that each acceptance criterion and definition-of-done item from the
|
|
389
|
+
linked issue/PR is actually satisfied by the implementation — not merely
|
|
390
|
+
listed. For every criterion, cite the concrete code/test/behavior evidence
|
|
391
|
+
that meets it; flag any criterion that is unmet, only partially met, or
|
|
392
|
+
unverifiable from the diff as a blocking finding. Confirm definition-of-done
|
|
393
|
+
items (tests, docs, validation) are done and that declared non-goals are
|
|
394
|
+
respected (no scope creep).
|
|
395
|
+
defaultModel: null
|
|
396
|
+
|
|
385
397
|
pr-checklist-matrix:
|
|
386
398
|
persona: review
|
|
387
399
|
prompt: >-
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
10
10
|
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
11
11
|
const GATE_REVIEW_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
|
|
12
|
+
const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
|
|
12
13
|
|
|
13
14
|
export function isCopilotLogin(login) {
|
|
14
15
|
return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
|
|
@@ -63,6 +64,11 @@ function normalizeGateReviewHeadSha(value) {
|
|
|
63
64
|
return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
function normalizeGateExecutionMode(value) {
|
|
68
|
+
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
69
|
+
return GATE_EXECUTION_MODES.has(normalized) ? normalized : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
function parseGateReviewCommentFields(body) {
|
|
67
73
|
if (typeof body !== "string" || body.trim().length === 0) {
|
|
68
74
|
return null;
|
|
@@ -74,6 +80,8 @@ function parseGateReviewCommentFields(body) {
|
|
|
74
80
|
verdict: null,
|
|
75
81
|
findingsSummary: null,
|
|
76
82
|
nextAction: null,
|
|
83
|
+
executionMode: null,
|
|
84
|
+
inlineReason: null,
|
|
77
85
|
};
|
|
78
86
|
|
|
79
87
|
for (const rawLine of body.split(/\r?\n/u)) {
|
|
@@ -112,6 +120,24 @@ function parseGateReviewCommentFields(body) {
|
|
|
112
120
|
fields.nextAction = match[1].trim();
|
|
113
121
|
continue;
|
|
114
122
|
}
|
|
123
|
+
|
|
124
|
+
match = line.match(/^(?:[-*]\s*)?execution\s+mode\s*:\s*(.+)$/iu);
|
|
125
|
+
if (match) {
|
|
126
|
+
const rest = match[1].trim();
|
|
127
|
+
// Split on the first em-dash / en-dash / " - " separator to recover an
|
|
128
|
+
// optional inline reason: "inline_single_agent — <reason>".
|
|
129
|
+
const sepMatch = rest.match(/^(.*?)\s*(?:[—–]|\s-\s)\s*(.*)$/u);
|
|
130
|
+
const modeToken = sepMatch ? sepMatch[1].trim() : rest;
|
|
131
|
+
const reasonToken = sepMatch ? sepMatch[2].trim() : "";
|
|
132
|
+
fields.executionMode = normalizeGateExecutionMode(modeToken);
|
|
133
|
+
// Only record an inline reason for inline_single_agent. A trailing
|
|
134
|
+
// "— text" on a fanout_fanin (or invalid) mode line must not surface an
|
|
135
|
+
// inconsistent mode/reason pair, so leave inlineReason null otherwise.
|
|
136
|
+
if (reasonToken.length > 0 && fields.executionMode === "inline_single_agent") {
|
|
137
|
+
fields.inlineReason = reasonToken;
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
115
141
|
}
|
|
116
142
|
|
|
117
143
|
// Lenient fallback: detect gate name and head SHA anywhere in body
|
|
@@ -178,6 +204,8 @@ export function parseGateReviewCommentMarkerBody(body) {
|
|
|
178
204
|
verdict: fields.verdict,
|
|
179
205
|
findingsSummary: fields.findingsSummary,
|
|
180
206
|
nextAction: fields.nextAction,
|
|
207
|
+
executionMode: fields.executionMode,
|
|
208
|
+
inlineReason: fields.inlineReason,
|
|
181
209
|
contractComplete: Boolean(fields.verdict && fields.findingsSummary && fields.nextAction),
|
|
182
210
|
};
|
|
183
211
|
}
|
|
@@ -205,6 +233,8 @@ export function summarizeGateReviewComments(comments) {
|
|
|
205
233
|
verdict: parsed.verdict,
|
|
206
234
|
findingsSummary: parsed.findingsSummary,
|
|
207
235
|
nextAction: parsed.nextAction,
|
|
236
|
+
executionMode: parsed.executionMode ?? null,
|
|
237
|
+
inlineReason: parsed.inlineReason ?? null,
|
|
208
238
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
209
239
|
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
210
240
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
@@ -253,6 +283,8 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
253
283
|
verdict: parsed.verdict,
|
|
254
284
|
findingsSummary: parsed.findingsSummary,
|
|
255
285
|
nextAction: parsed.nextAction,
|
|
286
|
+
executionMode: parsed.executionMode ?? null,
|
|
287
|
+
inlineReason: parsed.inlineReason ?? null,
|
|
256
288
|
contractComplete: parsed.contractComplete,
|
|
257
289
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
258
290
|
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
@@ -274,6 +306,39 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
274
306
|
return summary;
|
|
275
307
|
}
|
|
276
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Resolve the draft-gate round-reset timestamp (ms) used to suppress stale Copilot
|
|
311
|
+
* review rounds from the count (#896 consistency).
|
|
312
|
+
*
|
|
313
|
+
* When the draft gate was re-passed clean on a DIFFERENT head than the current one,
|
|
314
|
+
* only Copilot reviews submitted after that re-pass should count toward the round
|
|
315
|
+
* cap. Returning the re-pass `updatedAt` (ms) lets {@link summarizeCopilotReviews}
|
|
316
|
+
* drop earlier rounds. Returns null when no reset applies (no clean draft gate, or
|
|
317
|
+
* the clean draft gate is already on the current head).
|
|
318
|
+
*
|
|
319
|
+
* Both detect-pr-gate-coordination-state and request-copilot-review must derive the
|
|
320
|
+
* reset identically, or the two scripts disagree on the completed round count and
|
|
321
|
+
* the cap (the inconsistency reported in #896). This is the single shared source.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} params
|
|
324
|
+
* @param {{ verdict?: string|null, headSha?: string|null, updatedAt?: string|null }|null} params.draftGate
|
|
325
|
+
* @param {string|null} params.currentHeadSha
|
|
326
|
+
* @returns {number|null} reset timestamp in ms, or null
|
|
327
|
+
*/
|
|
328
|
+
export function resolveDraftGateRoundResetMs({ draftGate, currentHeadSha } = {}) {
|
|
329
|
+
const draftGateHeadSha = typeof draftGate?.headSha === "string" ? draftGate.headSha : null;
|
|
330
|
+
const draftGateOnCurrentHead = typeof draftGateHeadSha === "string"
|
|
331
|
+
&& typeof currentHeadSha === "string"
|
|
332
|
+
&& currentHeadSha.startsWith(draftGateHeadSha);
|
|
333
|
+
if (draftGate?.verdict === "clean"
|
|
334
|
+
&& typeof draftGateHeadSha === "string"
|
|
335
|
+
&& !draftGateOnCurrentHead
|
|
336
|
+
&& typeof draftGate?.updatedAt === "string") {
|
|
337
|
+
return normalizeTimestamp(draftGate.updatedAt);
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
277
342
|
export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs } = {}) {
|
|
278
343
|
const allReviews = Array.isArray(reviews) ? reviews : [];
|
|
279
344
|
const copilotReviews = allReviews.filter((review) => isCopilotLogin(review?.author?.login));
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate-fanin.mjs — pure fan-in consolidation + cap/batch planning for the
|
|
3
|
+
* gate-review fork sub-loop (epic #867, Phase 3 / #878).
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: this module is PURE. It performs no I/O and never spawns agents.
|
|
6
|
+
* Spawning the per-angle scoped `review` subagents is an agent-orchestrated
|
|
7
|
+
* skill procedure (a node script cannot spawn Claude subagents). This module
|
|
8
|
+
* only consolidates the structured per-angle findings artifacts the fan-out
|
|
9
|
+
* produced, decides the gate verdict, plans the parallel/sequential batching of
|
|
10
|
+
* the fan-out, and maps consolidated findings into the `--findings` JSON shape
|
|
11
|
+
* understood by scripts/github/write-gate-findings-log.mjs.
|
|
12
|
+
*
|
|
13
|
+
* Per-angle review artifact shape (produced by the scoped `review` agent):
|
|
14
|
+
* {
|
|
15
|
+
* angle: string,
|
|
16
|
+
* verdict: "clean" | "findings_present",
|
|
17
|
+
* findings: [{ severity, file?, line?, summary, recommendation? }]
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Severity vocabulary (mirrors write-gate-findings-log.mjs):
|
|
21
|
+
* "must-fix" | "worth-fixing-now" | "defer"
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const VALID_SEVERITIES = new Set(["must-fix", "worth-fixing-now", "defer"]);
|
|
25
|
+
const VALID_VERDICTS = new Set(["clean", "findings_present"]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default cap on parallel fan-out reviewers when a caller does not supply one.
|
|
29
|
+
* Mirrors the config default (gates.maxFanoutReviewers).
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate a single per-angle review result. Returns an error string when the
|
|
35
|
+
* result is malformed, or null when it is well-formed.
|
|
36
|
+
*
|
|
37
|
+
* @param {unknown} result
|
|
38
|
+
* @returns {string|null}
|
|
39
|
+
*/
|
|
40
|
+
function validateAngleResult(result) {
|
|
41
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
42
|
+
return "angle result must be an object";
|
|
43
|
+
}
|
|
44
|
+
const r = /** @type {Record<string, unknown>} */ (result);
|
|
45
|
+
if (typeof r.angle !== "string" || r.angle.trim().length === 0) {
|
|
46
|
+
return "angle result is missing a non-empty 'angle'";
|
|
47
|
+
}
|
|
48
|
+
if (typeof r.verdict !== "string" || !VALID_VERDICTS.has(r.verdict)) {
|
|
49
|
+
return `angle '${r.angle}' has invalid verdict (expected clean|findings_present)`;
|
|
50
|
+
}
|
|
51
|
+
if (!Array.isArray(r.findings)) {
|
|
52
|
+
return `angle '${r.angle}' is missing a 'findings' array`;
|
|
53
|
+
}
|
|
54
|
+
for (const f of r.findings) {
|
|
55
|
+
if (!f || typeof f !== "object" || Array.isArray(f)) {
|
|
56
|
+
return `angle '${r.angle}' has a non-object finding`;
|
|
57
|
+
}
|
|
58
|
+
const finding = /** @type {Record<string, unknown>} */ (f);
|
|
59
|
+
if (typeof finding.severity !== "string" || !VALID_SEVERITIES.has(finding.severity)) {
|
|
60
|
+
return `angle '${r.angle}' has a finding with invalid severity (expected must-fix|worth-fixing-now|defer)`;
|
|
61
|
+
}
|
|
62
|
+
if (typeof finding.summary !== "string" || finding.summary.trim().length === 0) {
|
|
63
|
+
return `angle '${r.angle}' has a finding without a summary`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// findings_present must carry at least one finding; clean must carry none.
|
|
67
|
+
if (r.verdict === "findings_present" && r.findings.length === 0) {
|
|
68
|
+
return `angle '${r.angle}' reported findings_present but has no findings`;
|
|
69
|
+
}
|
|
70
|
+
if (r.verdict === "clean" && r.findings.length > 0) {
|
|
71
|
+
return `angle '${r.angle}' reported clean but carries findings`;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Consolidate the parallel per-angle review results into one gate verdict +
|
|
78
|
+
* a merged, flattened findings list. Pure.
|
|
79
|
+
*
|
|
80
|
+
* Verdict rules:
|
|
81
|
+
* - "blocked": any angle result is malformed/missing (the gate could not
|
|
82
|
+
* produce a trustworthy verdict).
|
|
83
|
+
* - "clean": all results valid AND no finding carries a severity present in
|
|
84
|
+
* `blockCleanOnFindingSeverities`.
|
|
85
|
+
* - "findings_present": all results valid AND at least one finding carries a
|
|
86
|
+
* blocking severity.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} input
|
|
89
|
+
* @param {Array<unknown>} input.angleResults — per-angle review artifacts
|
|
90
|
+
* @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["must-fix"])
|
|
91
|
+
* @returns {{
|
|
92
|
+
* verdict: "clean"|"findings_present"|"blocked",
|
|
93
|
+
* findings: Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>,
|
|
94
|
+
* counts: { angles: number, findings: number, blocking: number, bySeverity: Record<string, number> },
|
|
95
|
+
* malformed: Array<{ index: number, reason: string }>
|
|
96
|
+
* }}
|
|
97
|
+
*/
|
|
98
|
+
export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities } = {}) {
|
|
99
|
+
const results = Array.isArray(angleResults) ? angleResults : [];
|
|
100
|
+
const blocking = new Set(
|
|
101
|
+
Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
|
|
102
|
+
? blockCleanOnFindingSeverities
|
|
103
|
+
: ["must-fix"],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const malformed = [];
|
|
107
|
+
results.forEach((r, index) => {
|
|
108
|
+
const err = validateAngleResult(r);
|
|
109
|
+
if (err) malformed.push({ index, reason: err });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const bySeverity = { "must-fix": 0, "worth-fixing-now": 0, "defer": 0 };
|
|
113
|
+
/** @type {Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>} */
|
|
114
|
+
const findings = [];
|
|
115
|
+
let blockingCount = 0;
|
|
116
|
+
|
|
117
|
+
if (malformed.length === 0) {
|
|
118
|
+
for (const r of results) {
|
|
119
|
+
const angle = r.angle.trim();
|
|
120
|
+
for (const f of r.findings) {
|
|
121
|
+
const isBlocking = blocking.has(f.severity);
|
|
122
|
+
if (isBlocking) blockingCount += 1;
|
|
123
|
+
bySeverity[f.severity] += 1;
|
|
124
|
+
const entry = {
|
|
125
|
+
severity: f.severity,
|
|
126
|
+
angle,
|
|
127
|
+
summary: String(f.summary).trim(),
|
|
128
|
+
// Blocking findings default to accepted-for-fix; non-blocking default
|
|
129
|
+
// to deferred. The fix cycle / operator can override the disposition.
|
|
130
|
+
disposition: isBlocking ? "accepted-for-fix" : "deferred",
|
|
131
|
+
};
|
|
132
|
+
if (typeof f.file === "string" && f.file.trim().length > 0) entry.file = f.file.trim();
|
|
133
|
+
if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
|
|
134
|
+
if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
|
|
135
|
+
entry.recommendation = f.recommendation.trim();
|
|
136
|
+
}
|
|
137
|
+
findings.push(entry);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let verdict;
|
|
143
|
+
if (malformed.length > 0) {
|
|
144
|
+
verdict = "blocked";
|
|
145
|
+
} else if (blockingCount > 0) {
|
|
146
|
+
verdict = "findings_present";
|
|
147
|
+
} else {
|
|
148
|
+
verdict = "clean";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
verdict,
|
|
153
|
+
findings,
|
|
154
|
+
counts: {
|
|
155
|
+
angles: results.length,
|
|
156
|
+
findings: findings.length,
|
|
157
|
+
blocking: blockingCount,
|
|
158
|
+
bySeverity,
|
|
159
|
+
},
|
|
160
|
+
malformed,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Map consolidated findings into the `--findings` JSON shape consumed by
|
|
166
|
+
* scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
|
|
167
|
+
* disposition, optional files). Pure.
|
|
168
|
+
*
|
|
169
|
+
* @param {Array<{severity: string, angle: string, summary: string, file?: string, disposition?: string}>} findings
|
|
170
|
+
* @returns {Array<{severity: string, angle: string, summary: string, disposition?: string, files?: string[]}>}
|
|
171
|
+
*/
|
|
172
|
+
export function toFindingsLogShape(findings) {
|
|
173
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
174
|
+
return list.map((f) => {
|
|
175
|
+
const entry = {
|
|
176
|
+
severity: f.severity,
|
|
177
|
+
angle: f.angle,
|
|
178
|
+
summary: f.summary,
|
|
179
|
+
};
|
|
180
|
+
if (typeof f.disposition === "string" && f.disposition.trim().length > 0) {
|
|
181
|
+
entry.disposition = f.disposition.trim();
|
|
182
|
+
}
|
|
183
|
+
if (typeof f.file === "string" && f.file.trim().length > 0) {
|
|
184
|
+
entry.files = [f.file.trim()];
|
|
185
|
+
} else if (Array.isArray(f.files)) {
|
|
186
|
+
const files = f.files.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
|
|
187
|
+
if (files.length > 0) entry.files = files;
|
|
188
|
+
}
|
|
189
|
+
return entry;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Plan how a resolved angle set fans out across the reviewer cap. Pure.
|
|
195
|
+
*
|
|
196
|
+
* When `angles.length <= maxReviewers`, all reviewers run in a single parallel
|
|
197
|
+
* batch (no degradation). When it exceeds the cap, the overflow is split into
|
|
198
|
+
* sequential batches of at most `maxReviewers` each, and `degraded` is true so
|
|
199
|
+
* the skill can record the sequential degradation in the gate evidence.
|
|
200
|
+
*
|
|
201
|
+
* @param {string[]} angles
|
|
202
|
+
* @param {number} [maxReviewers] — default DEFAULT_MAX_FANOUT_REVIEWERS (8)
|
|
203
|
+
* @returns {{ batches: string[][], degraded: boolean }}
|
|
204
|
+
*/
|
|
205
|
+
export function planFanoutBatches(angles, maxReviewers = DEFAULT_MAX_FANOUT_REVIEWERS) {
|
|
206
|
+
const list = Array.isArray(angles)
|
|
207
|
+
? angles.filter((a) => typeof a === "string" && a.trim().length > 0).map((a) => a.trim())
|
|
208
|
+
: [];
|
|
209
|
+
const cap = Number.isInteger(maxReviewers) && maxReviewers > 0
|
|
210
|
+
? maxReviewers
|
|
211
|
+
: DEFAULT_MAX_FANOUT_REVIEWERS;
|
|
212
|
+
|
|
213
|
+
if (list.length === 0) {
|
|
214
|
+
return { batches: [], degraded: false };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const batches = [];
|
|
218
|
+
for (let i = 0; i < list.length; i += cap) {
|
|
219
|
+
batches.push(list.slice(i, i + cap));
|
|
220
|
+
}
|
|
221
|
+
return { batches, degraded: batches.length > 1 };
|
|
222
|
+
}
|
|
@@ -479,6 +479,11 @@ function buildResult({
|
|
|
479
479
|
* @param {number} params.copilotReviewRoundCount
|
|
480
480
|
* @param {number|null} params.maxCopilotRounds
|
|
481
481
|
* @param {boolean} params.sameHeadCleanConverged
|
|
482
|
+
* @param {boolean} [params.roundCapCleanFallback=false] - interpreter resolved the
|
|
483
|
+
* round-cap clean fallback (#896): rounds exhausted + clean threads + green CI on
|
|
484
|
+
* the current head, including a post-cap head Copilot has not (and will not)
|
|
485
|
+
* re-review. No further Copilot round is permitted, so the formal-request guard
|
|
486
|
+
* must not fire — the pre_approval_gate reviews the post-cap head (per #848).
|
|
482
487
|
* @param {string} params.gateBoundary - current gate boundary
|
|
483
488
|
* @returns {boolean}
|
|
484
489
|
*/
|
|
@@ -488,6 +493,7 @@ export function shouldGuardCopilotReviewRequest({
|
|
|
488
493
|
copilotReviewEverFormallyRequested = false,
|
|
489
494
|
maxCopilotRounds = null,
|
|
490
495
|
sameHeadCleanConverged = false,
|
|
496
|
+
roundCapCleanFallback = false,
|
|
491
497
|
gateBoundary,
|
|
492
498
|
}) {
|
|
493
499
|
const gateBoundariesRequiringCopilotFormalRequest = new Set([
|
|
@@ -513,12 +519,17 @@ export function shouldGuardCopilotReviewRequest({
|
|
|
513
519
|
if (copilotReviewEverFormallyRequested) {
|
|
514
520
|
return false;
|
|
515
521
|
}
|
|
516
|
-
// Round-cap clean fallback: exhausted rounds + clean converged
|
|
517
|
-
//
|
|
522
|
+
// Round-cap clean fallback: exhausted rounds + clean converged does not require
|
|
523
|
+
// a formal re-request. This covers two shapes of "clean at the cap":
|
|
524
|
+
// - sameHeadCleanConverged: the current head itself carries a clean Copilot review;
|
|
525
|
+
// - roundCapCleanFallback (#896): the head is clean (zero unresolved threads + green
|
|
526
|
+
// CI) but Copilot has NOT reviewed THIS head (e.g. a post-cap commit). No further
|
|
527
|
+
// Copilot round is permitted, so forcing a formal request would dead-end the loop;
|
|
528
|
+
// the pre_approval_gate reviews the post-cap head instead (per #848).
|
|
518
529
|
const roundCapReached = maxCopilotRounds !== null
|
|
519
530
|
&& typeof copilotReviewRoundCount === "number"
|
|
520
531
|
&& copilotReviewRoundCount >= maxCopilotRounds;
|
|
521
|
-
if (roundCapReached && sameHeadCleanConverged) {
|
|
532
|
+
if (roundCapReached && (sameHeadCleanConverged || roundCapCleanFallback)) {
|
|
522
533
|
return false;
|
|
523
534
|
}
|
|
524
535
|
return true;
|
|
@@ -1266,6 +1277,175 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1266
1277
|
});
|
|
1267
1278
|
}
|
|
1268
1279
|
|
|
1280
|
+
// Round-cap clean fallback (#896, #848): the Copilot review round cap is
|
|
1281
|
+
// exhausted and the current head is clean (zero unresolved threads + green CI)
|
|
1282
|
+
// — including a POST-CAP head Copilot has not (and will not) re-review, since
|
|
1283
|
+
// no further Copilot round is permitted. Re-requesting review is illegal here,
|
|
1284
|
+
// so this MUST NOT dead-end at READY_TO_REREQUEST_REVIEW. It routes to the
|
|
1285
|
+
// pre_approval_gate, which reviews the post-cap head itself (per #848). The CI
|
|
1286
|
+
// guards below still hold (failing / credibly-green CI blocks), and conflicts /
|
|
1287
|
+
// blocked states are handled earlier, so genuinely-blocked states still forbid
|
|
1288
|
+
// pre_approval. Mirrors LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
|
|
1289
|
+
if (effectiveLifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK) {
|
|
1290
|
+
if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
|
|
1291
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
1292
|
+
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1293
|
+
return buildResult({
|
|
1294
|
+
repo: input.repo ?? null,
|
|
1295
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
1296
|
+
currentHeadSha,
|
|
1297
|
+
lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
|
|
1298
|
+
loopDisposition: DISPOSITION.BLOCKED,
|
|
1299
|
+
gateBoundary: PR_CHECKPOINT.BLOCKED,
|
|
1300
|
+
draftGateAlreadySatisfied: true,
|
|
1301
|
+
draftGate,
|
|
1302
|
+
preApprovalGate,
|
|
1303
|
+
allowedNextActions,
|
|
1304
|
+
forbiddenActions,
|
|
1305
|
+
nextAction: PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
|
|
1306
|
+
reason: ciStatus === "crediblyGreen"
|
|
1307
|
+
? "The Copilot round cap is exhausted, but the current head has unconfirmed CI (credibly green), so gate progression remains blocked until CI is confirmed green."
|
|
1308
|
+
: "The Copilot round cap is exhausted, but the current head still has failing CI, so gate progression remains blocked until the failing checks are fixed and revalidated.",
|
|
1309
|
+
mergeStateStatus,
|
|
1310
|
+
conflictFiles,
|
|
1311
|
+
refinementArtifact,
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
if (ciStatus === "pending" || ciStatus === "none") {
|
|
1315
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
|
|
1316
|
+
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1317
|
+
return buildResult({
|
|
1318
|
+
repo: input.repo ?? null,
|
|
1319
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
1320
|
+
currentHeadSha,
|
|
1321
|
+
lifecycleState: STATE.WAITING_FOR_CI,
|
|
1322
|
+
loopDisposition: DISPOSITION.PENDING,
|
|
1323
|
+
gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
|
|
1324
|
+
draftGateAlreadySatisfied: true,
|
|
1325
|
+
draftGate,
|
|
1326
|
+
preApprovalGate,
|
|
1327
|
+
allowedNextActions,
|
|
1328
|
+
forbiddenActions,
|
|
1329
|
+
nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_CI,
|
|
1330
|
+
reason: "The Copilot round cap is exhausted, but the current head does not yet have green or credibly green CI, so `pre_approval_gate` remains illegal until CI settles.",
|
|
1331
|
+
mergeStateStatus,
|
|
1332
|
+
conflictFiles,
|
|
1333
|
+
refinementArtifact,
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
if (preApprovalGate.currentHeadClean) {
|
|
1337
|
+
const titleMarkers = findBlockingTitleMarkers(prTitle);
|
|
1338
|
+
if (titleMarkers.length > 0) {
|
|
1339
|
+
return buildTitleMarkerBlockedResult({
|
|
1340
|
+
input,
|
|
1341
|
+
currentHeadSha,
|
|
1342
|
+
draftGateAlreadySatisfied: true,
|
|
1343
|
+
draftGate,
|
|
1344
|
+
preApprovalGate,
|
|
1345
|
+
mergeStateStatus,
|
|
1346
|
+
conflictFiles,
|
|
1347
|
+
markers: titleMarkers,
|
|
1348
|
+
refinementArtifact,
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
if (requireRetrospectiveGate) {
|
|
1352
|
+
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1353
|
+
if (!retrospectiveGate.approved) {
|
|
1354
|
+
return buildRetrospectiveGatePendingResult({
|
|
1355
|
+
input,
|
|
1356
|
+
currentHeadSha,
|
|
1357
|
+
draftGateAlreadySatisfied: true,
|
|
1358
|
+
draftGate,
|
|
1359
|
+
preApprovalGate,
|
|
1360
|
+
mergeStateStatus,
|
|
1361
|
+
conflictFiles,
|
|
1362
|
+
reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
|
|
1363
|
+
refinementArtifact,
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Mirror LOW_SIGNAL_CONVERGED (#579): a clean current head with no clean
|
|
1369
|
+
// draft_gate evidence must reconcile the draft gate rather than jump to
|
|
1370
|
+
// final approval. This keeps the core handler consistent with the
|
|
1371
|
+
// detect-pr-gate-coordination-state #579 post-pass, which unconditionally
|
|
1372
|
+
// downgrades FINAL_APPROVAL_READY → DRAFT_GATE_NEEDED when
|
|
1373
|
+
// draftGate.cleanEvidenceExists is false (no ROUND_CAP_CLEAN_FALLBACK
|
|
1374
|
+
// exemption). Without this guard the final-approval-without-draft-gate
|
|
1375
|
+
// branch is dead through the real script and asserts behavior it never
|
|
1376
|
+
// produces.
|
|
1377
|
+
if (!draftGate.cleanEvidenceExists) {
|
|
1378
|
+
return buildDraftGateNeededForMergeResult({
|
|
1379
|
+
input,
|
|
1380
|
+
currentHeadSha,
|
|
1381
|
+
draftGate,
|
|
1382
|
+
preApprovalGate,
|
|
1383
|
+
mergeStateStatus,
|
|
1384
|
+
conflictFiles,
|
|
1385
|
+
underlyingReason: "Round-cap clean fallback has clean pre_approval_gate but no clean draft_gate evidence.",
|
|
1386
|
+
refinementArtifact,
|
|
1387
|
+
effectiveLifecycleState,
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Round-cap clean fallback with clean draft_gate evidence reaches final
|
|
1392
|
+
// approval when the current head also has clean pre_approval_gate evidence.
|
|
1393
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL]);
|
|
1394
|
+
pushUnique(forbiddenActions, [
|
|
1395
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
1396
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
1397
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
1398
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
1399
|
+
]);
|
|
1400
|
+
return buildResult({
|
|
1401
|
+
repo: input.repo ?? null,
|
|
1402
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
1403
|
+
currentHeadSha,
|
|
1404
|
+
lifecycleState: effectiveLifecycleState,
|
|
1405
|
+
loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
|
|
1406
|
+
gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
1407
|
+
draftGateAlreadySatisfied: true,
|
|
1408
|
+
draftGate,
|
|
1409
|
+
preApprovalGate,
|
|
1410
|
+
allowedNextActions,
|
|
1411
|
+
forbiddenActions,
|
|
1412
|
+
nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
1413
|
+
reason: `Round-cap clean fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${ciStatus === "crediblyGreen" ? "credibly green" : "green"} CI). The current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`,
|
|
1414
|
+
mergeStateStatus,
|
|
1415
|
+
conflictFiles,
|
|
1416
|
+
refinementArtifact,
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE]);
|
|
1420
|
+
pushUnique(forbiddenActions, [
|
|
1421
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
1422
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
1423
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
1424
|
+
PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
|
|
1425
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
1426
|
+
]);
|
|
1427
|
+
return buildResult({
|
|
1428
|
+
repo: input.repo ?? null,
|
|
1429
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
1430
|
+
currentHeadSha,
|
|
1431
|
+
lifecycleState: effectiveLifecycleState,
|
|
1432
|
+
loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
|
|
1433
|
+
gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
|
|
1434
|
+
draftGateAlreadySatisfied: true,
|
|
1435
|
+
draftGate,
|
|
1436
|
+
preApprovalGate,
|
|
1437
|
+
allowedNextActions,
|
|
1438
|
+
forbiddenActions,
|
|
1439
|
+
nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
1440
|
+
reason: `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads with ${ciStatus === "crediblyGreen" ? "credibly green" : "green"} CI, so \`pre_approval_gate\` fallback is now the next legal boundary (it reviews the current post-cap head; no further Copilot re-request is permitted).`,
|
|
1441
|
+
mergeStateStatus,
|
|
1442
|
+
conflictFiles,
|
|
1443
|
+
refinementArtifact,
|
|
1444
|
+
gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds }),
|
|
1445
|
+
copilotReviewRoundCount,
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1269
1449
|
if (effectiveLifecycleState === STATE.LOW_SIGNAL_CONVERGED) {
|
|
1270
1450
|
if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
|
|
1271
1451
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
@@ -370,7 +370,10 @@ export async function syncBoardStatus(
|
|
|
370
370
|
const moveItem = dependencies.moveQueueItem ?? moveQueueItemMain;
|
|
371
371
|
try {
|
|
372
372
|
const result = await moveItem(
|
|
373
|
-
|
|
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 },
|
|
374
377
|
{ env, runChild: dependencies.runChild },
|
|
375
378
|
);
|
|
376
379
|
return { ok: true, skipped: false, result };
|
|
@@ -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) {
|