Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion src/review/outcomes-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
// applyAutoTune engages nothing → isHoldOnly is false → the merge path is unchanged. The breaker only engages
// once a repo's merge precision actually drops below the floor over a real sample.

import { recordAuditEvent } from "../db/repositories";
import { getPullRequest, listPullRequestFiles, recordAuditEvent } from "../db/repositories";
import { evaluateSuccessorMatch, REVERSAL_SUPERSEDED_EVENT_TYPE, SUPERSEDED_LOOKBACK_MS } from "./reversal-superseded";
import { createSignalStore } from "./signal-tracking-wire";
import { AI_JUDGMENT_BLOCKER_CODES } from "../rules/advisory";
import { tryEnqueueDecisionPackRebuild } from "../services/decision-pack";
Expand Down Expand Up @@ -675,6 +676,10 @@ export async function recordReversalSignals(
await recordConfiguredGateBlockerOverrides(env, targetId).catch(() => undefined); // #8104
await recordLinkedIssueScopeMismatchOverride(env, targetId).catch(() => undefined); // #8101
}
// #8166: the one-shot culture's reversal shape — this merge may supersede a bot-CLOSED sibling PR
// (same linked issue, or same author reworking the same files). Best-effort, like every signal here.
await recordSupersededReversals(env, repoFullName, pr.number, payload.pull_request?.user?.login ?? null).catch(() => undefined);

const reverted = parseRevertedPrNumber(pr.body);
if (!reverted) return;
const revertedTargetKey = reviewAuditTargetId(repoFullName, reverted);
Expand Down Expand Up @@ -926,3 +931,77 @@ export async function runSelfTuneBreaker(env: Env): Promise<void> {
);
}
}

/**
* #8166: scan the window for bot-CLOSED PRs this merge supersedes, and record the culture-correct reversal
* signal for each match: a `reversal_superseded` row in BOTH stores (like its reopen/revert siblings, with
* the matched heuristics in the audit metadata so borderline calls stay reviewable), plus the SAME per-rule
* "the firing was wrong" overrides the reopen path records (#8101/#8104) — which is what finally feeds the
* calibration corpus its positive class. Conservative + idempotent: evaluateSuccessorMatch's own bar
* decides, a target with an existing superseded row is never re-recorded, and every step fails safe.
*/
export async function recordSupersededReversals(
env: Env,
repoFullName: string,
mergedPrNumber: number,
mergedAuthorLogin: string | null,
): Promise<void> {
try {
const project = repoFullName.slice(0, 200);
const mergedRecord = await getPullRequest(env, repoFullName, mergedPrNumber);
if (!mergedRecord) return;
const mergedFiles = (await listPullRequestFiles(env, repoFullName, mergedPrNumber)).map((file) => file.path);
const merged = {
authorLogin: mergedAuthorLogin ?? mergedRecord.authorLogin,
linkedIssues: mergedRecord.linkedIssues,
files: mergedFiles,
};

const sinceIso = new Date(Date.now() - SUPERSEDED_LOOKBACK_MS).toISOString();
const candidates = await env.DB.prepare(
// Same bot-close definition as lastBotActionWasClose: real (non-dry-run) executed closes only.
`SELECT DISTINCT target_key FROM audit_events
WHERE event_type = 'agent.action.close' AND outcome IN ('success', 'completed')
AND COALESCE(json_extract(metadata_json, '$.mode'), 'live') <> 'dry_run'
AND target_key LIKE ? AND created_at >= ?`,
)
.bind(`${project}#%`, sinceIso)
.all<{ target_key: string }>();

for (const row of candidates.results ?? []) {
const targetKey = row.target_key;
const closedNumber = Number(targetKey.slice(targetKey.lastIndexOf("#") + 1));
if (!Number.isFinite(closedNumber) || closedNumber === mergedPrNumber) continue;
// Idempotent per closed target: one superseded record ever, however many successors merge later.
const already = await env.DB.prepare("SELECT 1 AS x FROM audit_events WHERE event_type = ? AND target_key = ? LIMIT 1")
.bind(REVERSAL_SUPERSEDED_EVENT_TYPE, targetKey)
.first<{ x: number }>();
if (already) continue;

const closedRecord = await getPullRequest(env, repoFullName, closedNumber);
if (!closedRecord) continue;
const closedFiles = (await listPullRequestFiles(env, repoFullName, closedNumber)).map((file) => file.path);
const heuristics = evaluateSuccessorMatch(merged, {
authorLogin: closedRecord.authorLogin,
linkedIssues: closedRecord.linkedIssues,
files: closedFiles,
});
if (!heuristics) continue;

const summary = `Bot-closed PR #${closedNumber} superseded by merged PR #${mergedPrNumber}.`;
await appendReviewAudit(env, { project, targetId: targetKey, eventType: REVERSAL_SUPERSEDED_EVENT_TYPE, summary });
await recordAuditEvent(env, {
eventType: REVERSAL_SUPERSEDED_EVENT_TYPE,
actor: mergedAuthorLogin,
targetKey,
outcome: "completed",
detail: summary,
metadata: { repoFullName, pullNumber: closedNumber, supersededBy: mergedPrNumber, heuristics },
}).catch(() => undefined);
await recordConfiguredGateBlockerOverrides(env, targetKey).catch(() => undefined); // #8104
await recordLinkedIssueScopeMismatchOverride(env, targetKey).catch(() => undefined); // #8101
}
} catch (error) {
console.warn(JSON.stringify({ event: "reversal_superseded_error", repo: repoFullName, message: errorMessage(error).slice(0, 200) }));
}
}
2 changes: 1 addition & 1 deletion src/review/public-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export async function getPublicStats(
SELECT substr(target_key, 1, instr(target_key, '#') - 1) AS project,
CAST(substr(target_key, instr(target_key, '#') + 1) AS INTEGER) AS pr_number
FROM audit_events
WHERE event_type IN ('reversal_reopened', 'reversal_reverted')
WHERE event_type IN ('reversal_reopened', 'reversal_reverted', 'reversal_superseded')
AND outcome = 'completed' AND instr(target_key, '#') > 0
) ev
WHERE LOWER(ev.project) IN (${inList})
Expand Down
61 changes: 61 additions & 0 deletions src/review/reversal-superseded.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Successor-based reversal heuristics (#8166, feeds epic #8082's positive class). This gate's own one-shot
// design tells a wronged contributor "recovery = open a fresh PR", so the reopen-shaped reversal signal
// (`reversal_reopened`) is structurally near-impossible here — verified in production: zero reversal events
// ever, zero bot-closed PRs later merged. The culture's ACTUAL "the bot was wrong" shape is: bot CLOSES
// PR #N, and a SUCCESSOR PR — same linked issue, or same author reworking the same files — later MERGES.
//
// PURE MODULE: the match decision only. Conservative by design (the issue's own bar): a false "the bot was
// wrong" poisons calibration worse than a miss, so a match requires either a shared linked issue (the
// strongest intent signal this repo has — the same set-intersection `duplicate_pr_risk` trusts) or the same
// author reworking a majority of the closed PR's files. Borderline records NOTHING. The wire
// (outcomes-wire.ts's recordSupersededReversals) supplies the data and writes the events.

export const REVERSAL_SUPERSEDED_EVENT_TYPE = "reversal_superseded";

/** A successor must re-touch at least this fraction of the CLOSED PR's files for the same-author path. */
export const SUPERSEDED_FILE_OVERLAP_MIN = 0.5;

/** How far back a merge scans for bot-closed PRs it might supersede. Mirrors the calibration lookbacks'
* order of magnitude — a months-later rework is a new effort, not a supersession signal. */
export const SUPERSEDED_LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000;

export type SupersededSide = {
authorLogin: string | null | undefined;
linkedIssues: readonly number[];
files: readonly string[];
};

export type SupersededHeuristics = {
sameLinkedIssue: boolean;
sameAuthorFileOverlap: boolean;
/** |shared files| / |closed PR's files|; null when the closed PR has no recorded files. */
fileOverlapRatio: number | null;
};

/**
* Decide whether `merged` supersedes the bot-closed `closed` PR. Returns the matched heuristics (for the
* audit trail — every recorded event carries WHY it matched) or null when neither conservative path holds:
* • sameLinkedIssue — both sides link at least one common issue number;
* • sameAuthorFileOverlap — same author (case-insensitive; unknown authors never match) AND the merged PR
* re-touches ≥ {@link SUPERSEDED_FILE_OVERLAP_MIN} of the closed PR's recorded files (a closed PR with
* no recorded files can never match this path — fail-open to a miss, never a guess).
* PURE and deterministic.
*/
export function evaluateSuccessorMatch(merged: SupersededSide, closed: SupersededSide): SupersededHeuristics | null {
const sameLinkedIssue = closed.linkedIssues.length > 0 && closed.linkedIssues.some((issue) => merged.linkedIssues.includes(issue));

const mergedAuthor = merged.authorLogin?.trim().toLowerCase() ?? "";
const closedAuthor = closed.authorLogin?.trim().toLowerCase() ?? "";
const sameAuthor = mergedAuthor !== "" && mergedAuthor === closedAuthor;

let fileOverlapRatio: number | null = null;
if (closed.files.length > 0) {
const mergedFiles = new Set(merged.files);
const shared = closed.files.filter((file) => mergedFiles.has(file)).length;
fileOverlapRatio = shared / closed.files.length;
}
const sameAuthorFileOverlap = sameAuthor && fileOverlapRatio !== null && fileOverlapRatio >= SUPERSEDED_FILE_OVERLAP_MIN;

if (!sameLinkedIssue && !sameAuthorFileOverlap) return null;
return { sameLinkedIssue, sameAuthorFileOverlap, fileOverlapRatio };
}
2 changes: 1 addition & 1 deletion src/services/public-accuracy-trend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async function loadReversalDayRows(env: Env, projects: string[], sinceIso: strin
) orig
JOIN (
SELECT DISTINCT target_key FROM audit_events
WHERE event_type IN ('reversal_reopened', 'reversal_reverted') AND outcome = 'completed'
WHERE event_type IN ('reversal_reopened', 'reversal_reverted', 'reversal_superseded') AND outcome = 'completed'
) rev ON rev.target_key = orig.target_key
WHERE LOWER(orig.project) IN (${inList})
GROUP BY day`,
Expand Down
Loading