@dev-loops/core 1.0.4-pre.0 → 1.0.4-pre.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,7 @@
20
20
  * precondition, and the aggregate naming.
21
21
  */
22
22
 
23
- import { isCopilotLogin, classifyCopilotReviewBodyDisposition, COPILOT_DISPOSITION, SUBMITTED_REVIEW_STATES } from "../github/copilot-helpers.mjs";
23
+ import { isCopilotLogin, classifyCopilotReviewBodyDisposition, COPILOT_DISPOSITION, SUBMITTED_REVIEW_STATES, extractReviewCommitSha } from "../github/copilot-helpers.mjs";
24
24
  import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
25
25
  import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
26
26
  import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
@@ -84,6 +84,15 @@ function reviewCommit(entry) {
84
84
  return null;
85
85
  }
86
86
 
87
+ // Copilot-only login extraction for evaluateCopilotConvergence: accepts the
88
+ // `gh pr view` GraphQL shape (author.login) in addition to the REST shape
89
+ // (user.login | login) reviewLogin reads. Scoped to the Copilot-convergence
90
+ // eval so verifyFreshHumanApproval's human-approval matching is unchanged.
91
+ function copilotConvergenceReviewLogin(entry) {
92
+ if (typeof entry?.author?.login === "string" && entry.author.login.length > 0) return entry.author.login;
93
+ return reviewLogin(entry);
94
+ }
95
+
87
96
  /**
88
97
  * Verify a fresh, agent-unforgeable, head-pinned human approval by `approvedBy`.
89
98
  *
@@ -153,13 +162,25 @@ export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews =
153
162
  };
154
163
  }
155
164
 
165
+ export const COPILOT_CONVERGENCE_STATE = Object.freeze({
166
+ CURRENT_HEAD_CLEAN: "current_head_clean",
167
+ CURRENT_HEAD_FINDINGS: "current_head_findings",
168
+ NO_CURRENT_HEAD_REVIEW: "no_current_head_review",
169
+ });
170
+
171
+ /** ADR 0012 end states (amended by ADR 0090) that may satisfy convergence without a current-head review. */
172
+ export const COPILOT_ABSENT_REVIEW_DISPOSITION = Object.freeze({
173
+ CONVERGED_ONCE: "converged_once",
174
+ ROUND_CAP_CLEAN_FALLBACK: "round_cap_clean_fallback",
175
+ DOCS_ONLY_SUPPRESSION: "docs_only_suppression",
176
+ COPILOT_GATE_DISABLED: "copilot_gate_disabled",
177
+ });
178
+ const SANCTIONED_ABSENT_REVIEW_DISPOSITIONS = new Set(Object.values(COPILOT_ABSENT_REVIEW_DISPOSITION));
179
+
156
180
  /**
157
- * Copilot-convergence merge precondition. Wires the current-head Copilot
158
- * body-disposition detection into the merge gate so the merge wrapper
159
- * and the loop (`copilotBodyFeedbackUnresolved`) read the SAME classification
160
- * (`classifyCopilotReviewBodyDisposition`). The classification is shared, so it
161
- * cannot drift; the POLICY differs by design (the loop self-blocks on 🔵, this
162
- * gate treats 🔵 as conductor-overridable). Fail-closed.
181
+ * Shared Copilot-convergence evaluation for pre-approval entry and merge.
182
+ * Both treat a thread-clean 🔵 as conductor-overridable; unresolved threads
183
+ * still block independently. Fail-closed.
163
184
  *
164
185
  * Only the LATEST Copilot review pinned to `currentHeadSha` is judged, so a
165
186
  * stale non-approval at an earlier head never blocks and a later same-head 🟢
@@ -175,16 +196,28 @@ export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews =
175
196
  * threads — the conductor's override is choosing to run the merge on a
176
197
  * thread-clean 🔵.
177
198
  * unrecognized disposition -> BLOCK (fail closed on a Copilot format change).
178
- * 🟢 clean / no current-head Copilot review / stale earlier-head -> PASS.
199
+ * 🟢 clean / headerless -> PASS.
179
200
  *
180
- * @returns {{ ok: boolean, disposition: string|null, reason: string|null }}
201
+ * Three distinct states (`state` on the result):
202
+ * current_head_clean a current-head review passes (🟢 / headerless / 🔵);
203
+ * current_head_findings a current-head review blocks (🟡 / unrecognized);
204
+ * no_current_head_review no current-head Copilot review exists (none yet, or
205
+ * every review is on an earlier head). This state
206
+ * passes ONLY through a sanctioned disposition recorded
207
+ * for the current head (`absentReviewDisposition`
208
+ * `{ kind, headSha }`, kind in
209
+ * COPILOT_ABSENT_REVIEW_DISPOSITION). Absence alone never
210
+ * converges, and a stale earlier-head review is never
211
+ * inherited as the current-head verdict.
212
+ *
213
+ * @returns {{ ok: boolean, state: string|null, disposition: string|null, reason: string|null }}
181
214
  */
182
- export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = [] } = {}) {
215
+ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = [], absentReviewDisposition = null } = {}) {
183
216
  const head = typeof currentHeadSha === "string" ? currentHeadSha.trim() : "";
184
217
  // Head unknown: no current-head review can be pinned. Fail closed (matches
185
218
  // verifyFreshHumanApproval), so this precondition can never pass without a
186
219
  // known head to pin the Copilot disposition to.
187
- if (head.length === 0) return { ok: false, disposition: null, reason: "current head SHA is unknown; cannot pin a Copilot review to it" };
220
+ if (head.length === 0) return { ok: false, state: null, disposition: null, reason: "current head SHA is unknown; cannot pin a Copilot review to it" };
188
221
 
189
222
  // Mirror summarizeCopilotReviews' current-head finding selection BYTE-FOR-BYTE
190
223
  // so the merge gate and the loop can never diverge (they already share
@@ -199,36 +232,65 @@ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = []
199
232
  // array order never silently drops a finding.
200
233
  let latestDisposition = null;
201
234
  let latestAt = null;
235
+ // The id of the review that owns latestDisposition: the review a
236
+ // copilot-body-disposition record must name to clear a finding. Null when two
237
+ // tied reviews both block, so no single record can clear the tie.
238
+ let latestReviewId = null;
202
239
  for (const entry of Array.isArray(reviews) ? reviews : []) {
203
- const login = reviewLogin(entry);
240
+ // Shape-tolerant Copilot-login + commit extraction: the merge gate feeds
241
+ // REST-shaped reviews (user.login/commit_id) while the gate-ENTRY detector
242
+ // feeds `gh pr view` GraphQL-shaped reviews (author.login/commit.oid). Both
243
+ // call sites route through this one evaluateCopilotConvergence, so it must
244
+ // recognize BOTH shapes to produce ONE convergence verdict. Only Copilot
245
+ // reviews matter here, so broadening the login read to author.login cannot
246
+ // affect verifyFreshHumanApproval (which keeps its own REST-only
247
+ // reviewLogin/reviewCommit).
248
+ const login = copilotConvergenceReviewLogin(entry);
204
249
  if (login === null || !isCopilotLogin(login)) continue;
205
- if (reviewCommit(entry) !== head) continue; // only current-head reviews
250
+ if (extractReviewCommitSha(entry) !== head) continue; // only current-head reviews
206
251
  const state = typeof entry?.state === "string" ? entry.state.toUpperCase() : "";
207
252
  if (state === "PENDING" || !SUBMITTED_REVIEW_STATES.has(state)) continue; // PENDING/unknown never sets the finding
208
253
  const disposition = classifyCopilotReviewBodyDisposition(state, entry?.body);
254
+ const reviewId = entry?.id !== null && entry?.id !== undefined ? String(entry.id) : null;
209
255
  const submittedAt = typeof entry?.submittedAt === "string"
210
256
  ? entry.submittedAt
211
257
  : (typeof entry?.submitted_at === "string" ? entry.submitted_at : null);
212
258
  if (submittedAt !== null && (latestAt === null || submittedAt > latestAt)) {
213
259
  latestDisposition = disposition; // a lexicographically-later submittedAt supersedes (matches summarize)
260
+ latestReviewId = reviewId;
214
261
  latestAt = submittedAt;
215
- } else if (submittedAt !== null && submittedAt === latestAt) {
216
- latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
217
- } else if (submittedAt === null && latestAt === null) {
218
- latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
262
+ } else if (submittedAt === latestAt) {
263
+ // Equal-string tie, or both null.
264
+ ({ disposition: latestDisposition, reviewId: latestReviewId } = foldTiedReview(
265
+ { disposition: latestDisposition, reviewId: latestReviewId },
266
+ { disposition, reviewId },
267
+ ));
219
268
  }
220
269
  // a null submittedAt once a non-null latest exists is ignored (mirrors summarize)
221
270
  }
222
- if (latestDisposition === null) return { ok: true, disposition: null, reason: null };
271
+ if (latestDisposition === null) {
272
+ const absent = COPILOT_CONVERGENCE_STATE.NO_CURRENT_HEAD_REVIEW;
273
+ const kind = absentReviewDisposition?.kind;
274
+ if (SANCTIONED_ABSENT_REVIEW_DISPOSITIONS.has(kind) && absentReviewDisposition?.headSha === head) {
275
+ return { ok: true, state: absent, disposition: kind, reason: null };
276
+ }
277
+ return {
278
+ ok: false,
279
+ state: absent,
280
+ disposition: null,
281
+ reason: `no current-head Copilot review exists on head ${head}; convergence requires a clean current-head review or a sanctioned disposition recorded for this head (${[...SANCTIONED_ABSENT_REVIEW_DISPOSITIONS].join(", ")})`,
282
+ };
283
+ }
223
284
 
285
+ const findings = COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_FINDINGS;
224
286
  if (latestDisposition === COPILOT_DISPOSITION.CHANGES_RECOMMENDED) {
225
- return { ok: false, disposition: latestDisposition, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
287
+ return { ok: false, state: findings, disposition: latestDisposition, reviewId: latestReviewId, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
226
288
  }
227
289
  if (latestDisposition === COPILOT_DISPOSITION.UNRECOGNIZED) {
228
- return { ok: false, disposition: latestDisposition, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
290
+ return { ok: false, state: findings, disposition: latestDisposition, reviewId: latestReviewId, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
229
291
  }
230
292
  // CLEAN, NONE, and NEEDS_CLOSER_LOOK (🔵, conductor-overridable) pass.
231
- return { ok: true, disposition: latestDisposition, reason: null };
293
+ return { ok: true, state: COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_CLEAN, disposition: latestDisposition, reviewId: latestReviewId, reason: null };
232
294
  }
233
295
 
234
296
  // Disposition blocking precedence, most-blocking first. Used to fold an
@@ -250,6 +312,19 @@ function moreBlockingDisposition(a, b) {
250
312
  return ra <= rb ? a : b;
251
313
  }
252
314
 
315
+ // Fold one tied current-head review into the running latest. The disposition
316
+ // folds toward the most blocking. The owning review id follows the blocking
317
+ // review; two tied blocking reviews leave no single owner (null).
318
+ const BLOCKING_CONVERGENCE_DISPOSITIONS = new Set([COPILOT_DISPOSITION.CHANGES_RECOMMENDED, COPILOT_DISPOSITION.UNRECOGNIZED]);
319
+ function foldTiedReview(latest, next) {
320
+ if (latest.disposition === null) return next;
321
+ if (BLOCKING_CONVERGENCE_DISPOSITIONS.has(latest.disposition) && BLOCKING_CONVERGENCE_DISPOSITIONS.has(next.disposition)) {
322
+ return { disposition: moreBlockingDisposition(latest.disposition, next.disposition), reviewId: null };
323
+ }
324
+ const disposition = moreBlockingDisposition(latest.disposition, next.disposition);
325
+ return { disposition, reviewId: disposition === latest.disposition ? latest.reviewId : next.reviewId };
326
+ }
327
+
253
328
  /**
254
329
  * Decide whether merge is authorized given the class, the standing
255
330
  * authorization signal, and any fresh per-merge approval.
@@ -327,6 +402,12 @@ export function evaluateMergePreconditions({
327
402
  comments = [],
328
403
  standingAuthorized = false,
329
404
  stableRelease = false,
405
+ copilotAbsentReviewDisposition = null,
406
+ copilotBodyDisposition = null,
407
+ // Refusal reason when a Copilot review submitted after the current-head
408
+ // verdict review sits on an earlier commit and is not converged (the latest
409
+ // review decides); null otherwise.
410
+ copilotLaterReviewRefusal = null,
330
411
  } = {}) {
331
412
  const failures = [];
332
413
 
@@ -375,9 +456,21 @@ export function evaluateMergePreconditions({
375
456
 
376
457
  // Copilot-convergence precondition: refuse a current-head Copilot non-approval
377
458
  // body disposition, mirroring the loop's copilotBodyFeedbackUnresolved.
378
- const copilotConvergence = evaluateCopilotConvergence({ currentHeadSha, reviews });
379
- if (!copilotConvergence.ok) {
459
+ // A trusted copilot-body-disposition record resolved for the current head
460
+ // (`{ headSha, reviewId, ... }`) clears a current-head finding, as it does
461
+ // at gate entry, only when it names the review that raised the finding.
462
+ const copilotConvergence = evaluateCopilotConvergence({ currentHeadSha, reviews, absentReviewDisposition: copilotAbsentReviewDisposition });
463
+ const head = typeof currentHeadSha === "string" ? currentHeadSha.trim().toLowerCase() : "";
464
+ const bodyCleared = copilotConvergence.state === COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_FINDINGS
465
+ && head.length > 0
466
+ && typeof copilotBodyDisposition?.headSha === "string" && copilotBodyDisposition.headSha.toLowerCase() === head
467
+ // The record clears only the review that raised the current-head finding.
468
+ && copilotConvergence.reviewId !== null
469
+ && String(copilotBodyDisposition.reviewId) === copilotConvergence.reviewId;
470
+ if (!copilotConvergence.ok && !bodyCleared) {
380
471
  failures.push({ precondition: "copilot_convergence", reason: copilotConvergence.reason });
472
+ } else if (typeof copilotLaterReviewRefusal === "string") {
473
+ failures.push({ precondition: "copilot_convergence", reason: copilotLaterReviewRefusal });
381
474
  }
382
475
 
383
476
  const mergeClass = resolveMergeClass({ sizeOutcome, touchesT1, stableRelease });
@@ -391,9 +484,12 @@ export function evaluateMergePreconditions({
391
484
  failures,
392
485
  mergeClass,
393
486
  approvalVia: decision.authorized ? decision.via : null,
394
- // Audit trace: the current-head Copilot disposition this verdict saw, so a
395
- // merge that ran on a conductor-overridable 🔵 (or any disposition) is
396
- // recorded on the machine-readable result rather than being invisible.
487
+ // Audit trace: which convergence state applied and what settled it (the
488
+ // current-head review disposition, or the sanctioned disposition kind when
489
+ // no current-head review exists), so a merge on a conductor-overridable 🔵
490
+ // or without a current-head review is recorded, never invisible.
491
+ copilotConvergenceState: copilotConvergence.state,
397
492
  copilotDisposition: copilotConvergence.disposition,
493
+ copilotBodyDisposition: bodyCleared ? copilotBodyDisposition : null,
398
494
  };
399
495
  }