|
23 | 23 | // applyAutoTune engages nothing → isHoldOnly is false → the merge path is unchanged. The breaker only engages |
24 | 24 | // once a repo's merge precision actually drops below the floor over a real sample. |
25 | 25 |
|
26 | | -import { recordAuditEvent } from "../db/repositories"; |
| 26 | +import { getPullRequest, listPullRequestFiles, recordAuditEvent } from "../db/repositories"; |
| 27 | +import { evaluateSuccessorMatch, REVERSAL_SUPERSEDED_EVENT_TYPE, SUPERSEDED_LOOKBACK_MS } from "./reversal-superseded"; |
27 | 28 | import { createSignalStore } from "./signal-tracking-wire"; |
28 | 29 | import { AI_JUDGMENT_BLOCKER_CODES } from "../rules/advisory"; |
29 | 30 | import { tryEnqueueDecisionPackRebuild } from "../services/decision-pack"; |
@@ -675,6 +676,10 @@ export async function recordReversalSignals( |
675 | 676 | await recordConfiguredGateBlockerOverrides(env, targetId).catch(() => undefined); // #8104 |
676 | 677 | await recordLinkedIssueScopeMismatchOverride(env, targetId).catch(() => undefined); // #8101 |
677 | 678 | } |
| 679 | + // #8166: the one-shot culture's reversal shape — this merge may supersede a bot-CLOSED sibling PR |
| 680 | + // (same linked issue, or same author reworking the same files). Best-effort, like every signal here. |
| 681 | + await recordSupersededReversals(env, repoFullName, pr.number, payload.pull_request?.user?.login ?? null).catch(() => undefined); |
| 682 | + |
678 | 683 | const reverted = parseRevertedPrNumber(pr.body); |
679 | 684 | if (!reverted) return; |
680 | 685 | const revertedTargetKey = reviewAuditTargetId(repoFullName, reverted); |
@@ -926,3 +931,77 @@ export async function runSelfTuneBreaker(env: Env): Promise<void> { |
926 | 931 | ); |
927 | 932 | } |
928 | 933 | } |
| 934 | + |
| 935 | +/** |
| 936 | + * #8166: scan the window for bot-CLOSED PRs this merge supersedes, and record the culture-correct reversal |
| 937 | + * signal for each match: a `reversal_superseded` row in BOTH stores (like its reopen/revert siblings, with |
| 938 | + * the matched heuristics in the audit metadata so borderline calls stay reviewable), plus the SAME per-rule |
| 939 | + * "the firing was wrong" overrides the reopen path records (#8101/#8104) — which is what finally feeds the |
| 940 | + * calibration corpus its positive class. Conservative + idempotent: evaluateSuccessorMatch's own bar |
| 941 | + * decides, a target with an existing superseded row is never re-recorded, and every step fails safe. |
| 942 | + */ |
| 943 | +export async function recordSupersededReversals( |
| 944 | + env: Env, |
| 945 | + repoFullName: string, |
| 946 | + mergedPrNumber: number, |
| 947 | + mergedAuthorLogin: string | null, |
| 948 | +): Promise<void> { |
| 949 | + try { |
| 950 | + const project = repoFullName.slice(0, 200); |
| 951 | + const mergedRecord = await getPullRequest(env, repoFullName, mergedPrNumber); |
| 952 | + if (!mergedRecord) return; |
| 953 | + const mergedFiles = (await listPullRequestFiles(env, repoFullName, mergedPrNumber)).map((file) => file.path); |
| 954 | + const merged = { |
| 955 | + authorLogin: mergedAuthorLogin ?? mergedRecord.authorLogin, |
| 956 | + linkedIssues: mergedRecord.linkedIssues, |
| 957 | + files: mergedFiles, |
| 958 | + }; |
| 959 | + |
| 960 | + const sinceIso = new Date(Date.now() - SUPERSEDED_LOOKBACK_MS).toISOString(); |
| 961 | + const candidates = await env.DB.prepare( |
| 962 | + // Same bot-close definition as lastBotActionWasClose: real (non-dry-run) executed closes only. |
| 963 | + `SELECT DISTINCT target_key FROM audit_events |
| 964 | + WHERE event_type = 'agent.action.close' AND outcome IN ('success', 'completed') |
| 965 | + AND COALESCE(json_extract(metadata_json, '$.mode'), 'live') <> 'dry_run' |
| 966 | + AND target_key LIKE ? AND created_at >= ?`, |
| 967 | + ) |
| 968 | + .bind(`${project}#%`, sinceIso) |
| 969 | + .all<{ target_key: string }>(); |
| 970 | + |
| 971 | + for (const row of candidates.results ?? []) { |
| 972 | + const targetKey = row.target_key; |
| 973 | + const closedNumber = Number(targetKey.slice(targetKey.lastIndexOf("#") + 1)); |
| 974 | + if (!Number.isFinite(closedNumber) || closedNumber === mergedPrNumber) continue; |
| 975 | + // Idempotent per closed target: one superseded record ever, however many successors merge later. |
| 976 | + const already = await env.DB.prepare("SELECT 1 AS x FROM audit_events WHERE event_type = ? AND target_key = ? LIMIT 1") |
| 977 | + .bind(REVERSAL_SUPERSEDED_EVENT_TYPE, targetKey) |
| 978 | + .first<{ x: number }>(); |
| 979 | + if (already) continue; |
| 980 | + |
| 981 | + const closedRecord = await getPullRequest(env, repoFullName, closedNumber); |
| 982 | + if (!closedRecord) continue; |
| 983 | + const closedFiles = (await listPullRequestFiles(env, repoFullName, closedNumber)).map((file) => file.path); |
| 984 | + const heuristics = evaluateSuccessorMatch(merged, { |
| 985 | + authorLogin: closedRecord.authorLogin, |
| 986 | + linkedIssues: closedRecord.linkedIssues, |
| 987 | + files: closedFiles, |
| 988 | + }); |
| 989 | + if (!heuristics) continue; |
| 990 | + |
| 991 | + const summary = `Bot-closed PR #${closedNumber} superseded by merged PR #${mergedPrNumber}.`; |
| 992 | + await appendReviewAudit(env, { project, targetId: targetKey, eventType: REVERSAL_SUPERSEDED_EVENT_TYPE, summary }); |
| 993 | + await recordAuditEvent(env, { |
| 994 | + eventType: REVERSAL_SUPERSEDED_EVENT_TYPE, |
| 995 | + actor: mergedAuthorLogin, |
| 996 | + targetKey, |
| 997 | + outcome: "completed", |
| 998 | + detail: summary, |
| 999 | + metadata: { repoFullName, pullNumber: closedNumber, supersededBy: mergedPrNumber, heuristics }, |
| 1000 | + }).catch(() => undefined); |
| 1001 | + await recordConfiguredGateBlockerOverrides(env, targetKey).catch(() => undefined); // #8104 |
| 1002 | + await recordLinkedIssueScopeMismatchOverride(env, targetKey).catch(() => undefined); // #8101 |
| 1003 | + } |
| 1004 | + } catch (error) { |
| 1005 | + console.warn(JSON.stringify({ event: "reversal_superseded_error", repo: repoFullName, message: errorMessage(error).slice(0, 200) })); |
| 1006 | + } |
| 1007 | +} |
0 commit comments