Skip to content

Commit e3f7b45

Browse files
authored
fix(queue): surface a finding when capture-unobtainable degrades the screenshot-table gate (#10256)
maybeAddScreenshotTableAdvisoryFinding early-returned on any action other than "advisory", so a close/block gate degraded by the #9881 capture-unobtainable check never appended a finding anywhere -- the close/hold comment that would have said so doesn't fire on the degraded path either, leaving the maintainer with no visibility that their gate is unsatisfiable. Thread captureUnobtainable into the function and its evaluateScreenshotTableGate call, and only early- return on a non-advisory action once enforcement wasn't degraded. Co-authored-by: bitfathers94 <237535319+bitfathers94@users.noreply.github.com>
1 parent 2794910 commit e3f7b45

2 files changed

Lines changed: 267 additions & 15 deletions

File tree

src/queue/processors.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,7 @@ import {
659659
} from "../review/linked-issue-hard-rules";
660660
import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL } from "../review/unlinked-issue-guardrail-config";
661661
import { resolveUnlinkedIssueMatchDisposition } from "../review/unlinked-issue-guardrail";
662-
import { DEFAULT_SCREENSHOT_CONTRACT_MESSAGE, DEFAULT_SCREENSHOT_TABLE_GATE, evaluateScreenshotTableGate, extractTableRowImageUrls, type ScreenshotTableGateConfig } from "../review/screenshot-table-gate";
662+
import { CAPTURE_UNOBTAINABLE_REASON, DEFAULT_SCREENSHOT_CONTRACT_MESSAGE, DEFAULT_SCREENSHOT_TABLE_GATE, evaluateScreenshotTableGate, extractTableRowImageUrls, type ScreenshotTableGateConfig } from "../review/screenshot-table-gate";
663663
import { isSafeHttpUrl } from "../review/content-lane/safe-url";
664664
import {
665665
buildScreenshotTableVisionFindings,
@@ -8928,14 +8928,15 @@ export async function maybeAddLockfileTamperFinding(
89288928
}
89298929

89308930
/**
8931-
* Screenshot-table gate advisory visibility (#2006 follow-up). `action: "close"` already communicates via its
8932-
* own templated close comment (see planAgentMaintenanceActions/screenshotTableCloseMessage), so a violation
8933-
* there never needs a SEPARATE advisory finding -- this only ever fires for `action: "advisory"`, which
8934-
* previously had NO visible effect at all: the live gate's only other `evaluateScreenshotTableGate` call site
8935-
* (`runAgentMaintenancePlanAndExecute`) discards the result entirely once `action !== "close"`. Mirrors
8936-
* `maybeAddLockfileTamperFinding` immediately above: off/out-of-scope is free, a violation appends ONE
8937-
* warning-severity, non-blocking finding (unrecognized by `isConfiguredGateBlocker`, so it can never gate),
8938-
* and any evaluation error is swallowed so it can never destabilize the gate.
8931+
* Screenshot-table gate advisory visibility (#2006 follow-up, #9881 degrade follow-up). `action: "close"`/
8932+
* `"block"` already communicate via their own templated close/hold comment (see
8933+
* planAgentMaintenanceActions/screenshotTableCloseMessage), so a violation there never needs a SEPARATE
8934+
* advisory finding UNLESS enforcement was degraded (#9881: the bot proved this repo's preview pipeline can
8935+
* never satisfy the gate) -- in that case the close/hold comment never fires either, so THIS finding is the
8936+
* only place a maintainer ever learns the gate is unsatisfiable here (#10060). Mirrors
8937+
* `maybeAddLockfileTamperFinding` immediately above: off is free, a violation appends ONE warning-severity,
8938+
* non-blocking finding (unrecognized by `isConfiguredGateBlocker`, so it can never gate), and any evaluation
8939+
* error is swallowed so it can never destabilize the gate.
89398940
*/
89408941
export async function maybeAddScreenshotTableAdvisoryFinding(
89418942
env: Env,
@@ -8947,23 +8948,50 @@ export async function maybeAddScreenshotTableAdvisoryFinding(
89478948
prBody: string | null | undefined;
89488949
prLabels: string[];
89498950
botCaptureSatisfied: boolean;
8951+
// #9881/#10060: true when the bot proved this repo's preview pipeline can never produce a capture for
8952+
// this head -- threaded from the SAME `Boolean(pr.headSha) && pr.visualCaptureUnobtainableSha ===
8953+
// pr.headSha` expression the enforcement call site (runAgentMaintenancePlanAndExecute) computes, so the
8954+
// two evaluations of this pure check can never disagree about whether this PR's gate is degraded.
8955+
captureUnobtainable: boolean;
89508956
files: Awaited<ReturnType<typeof listPullRequestFiles>> | null;
89518957
},
89528958
): Promise<void> {
8953-
if (!args.screenshotTableGateConfig.enabled || args.screenshotTableGateConfig.action !== "advisory") return;
8959+
if (!args.screenshotTableGateConfig.enabled) return;
89548960
try {
8955-
const files =
8956-
args.files ??
8957-
(await listPullRequestFiles(env, args.repoFullName, args.pullNumber));
8961+
// #10060: an if-fallback, not `args.files ?? (await listPullRequestFiles(...))` -- that shape left the
8962+
// statements immediately following it (the gate evaluation, the violated check) with a phantom 0 lcov hit
8963+
// count despite genuinely running every test, which would have sunk this file's Codecov patch coverage.
8964+
let files = args.files;
8965+
if (files === null) {
8966+
files = await listPullRequestFiles(env, args.repoFullName, args.pullNumber);
8967+
}
8968+
const changedFiles = files.map((file) => file.path);
89588969
const result = evaluateScreenshotTableGate({
89598970
config: args.screenshotTableGateConfig,
89608971
prBody: args.prBody,
89618972
prLabels: args.prLabels,
8962-
changedFiles: files.map((file) => file.path),
8973+
changedFiles,
89638974
botCaptureSatisfied: args.botCaptureSatisfied,
8975+
captureUnobtainable: args.captureUnobtainable,
89648976
});
89658977
if (!result.violated) return;
89668978
const detail = result.reason ?? DEFAULT_SCREENSHOT_CONTRACT_MESSAGE;
8979+
// #10060: a degraded gate must surface here REGARDLESS of the configured action -- close/block never get
8980+
// their own comment on this path (the enforcement that would have produced one was degraded away), so an
8981+
// advisory-mode repo and a close-mode repo with an unsatisfiable pipeline both need this same visibility.
8982+
if (result.enforcementDegradedReason !== undefined) {
8983+
const degradedDetail = `${detail}\n\n${CAPTURE_UNOBTAINABLE_REASON}`;
8984+
args.advisory.findings.push({
8985+
code: "screenshot_table_missing",
8986+
severity: "warning",
8987+
title: "Screenshot-table enforcement degraded (capture unobtainable)",
8988+
detail: degradedDetail,
8989+
action: "Enable preview deploys for this repository, or set requireScreenshotTable.action to advisory.",
8990+
publicText: degradedDetail,
8991+
});
8992+
return;
8993+
}
8994+
if (args.screenshotTableGateConfig.action !== "advisory") return;
89678995
args.advisory.findings.push({
89688996
code: "screenshot_table_missing",
89698997
severity: "warning",
@@ -12368,6 +12396,9 @@ async function maybePublishPrPublicSurface(
1236812396
prBody: pr.body,
1236912397
prLabels: pr.labels,
1237012398
botCaptureSatisfied: Boolean(pr.headSha) && pr.visualCaptureSatisfiedSha === pr.headSha,
12399+
// #9881/#10060: same expression runAgentMaintenancePlanAndExecute computes for the enforcement decision,
12400+
// so the two evaluations of this pure check cannot disagree about whether this PR's gate is degraded.
12401+
captureUnobtainable: Boolean(pr.headSha) && pr.visualCaptureUnobtainableSha === pr.headSha,
1237112402
files: await getReviewFiles(),
1237212403
});
1237312404

test/unit/screenshot-table-gate.test.ts

Lines changed: 222 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import {
1717
CAPTURE_UNOBTAINABLE_REASON,
1818
type ScreenshotMatrixPair,
1919
} from "../../src/review/screenshot-table-gate";
20-
import type { ScreenshotTableGateConfig } from "../../src/types";
20+
import type { Advisory, PullRequestFileRecord, ScreenshotTableGateConfig } from "../../src/types";
21+
import { maybeAddScreenshotTableAdvisoryFinding } from "../../src/queue/processors";
22+
import { planAgentMaintenanceActions, type AgentActionPlanInput } from "../../src/settings/agent-actions";
23+
import type { GateCheckConclusion } from "../../src/rules/advisory";
24+
import { createTestEnv } from "../helpers/d1";
2125

2226
function config(overrides: Partial<ScreenshotTableGateConfig> = {}): ScreenshotTableGateConfig {
2327
return { ...DEFAULT_SCREENSHOT_TABLE_GATE, whenLabels: [], whenPaths: [], ...overrides };
@@ -999,3 +1003,220 @@ describe("enforcement degrade when capture is unobtainable (#9881)", () => {
9991003
expect(before).toEqual(evaluateScreenshotTableGate({ ...violatingInput, captureUnobtainable: undefined }));
10001004
});
10011005
});
1006+
1007+
// #10060: maybeAddScreenshotTableAdvisoryFinding previously early-returned before evaluating anything unless
1008+
// `action === "advisory"`, so a `close`/`block` repo whose gate was degraded (#9881) never surfaced a finding
1009+
// anywhere — the close/hold comment that would have said so never fires on the degraded path either, so the
1010+
// maintainer learned nothing. These tests pin the fixed wiring.
1011+
describe("maybeAddScreenshotTableAdvisoryFinding degrade wiring (#10060)", () => {
1012+
function advisory(): Advisory {
1013+
return {
1014+
id: "adv-1",
1015+
targetType: "pull_request",
1016+
repoFullName: "acme/widgets",
1017+
pullNumber: 7,
1018+
targetKey: "acme/widgets#7",
1019+
headSha: "sha7",
1020+
conclusion: "neutral",
1021+
severity: "info",
1022+
title: "LoopOver advisory available",
1023+
summary: "ok",
1024+
findings: [],
1025+
generatedAt: "2026-07-31T00:00:00.000Z",
1026+
};
1027+
}
1028+
1029+
const NO_TABLE_FILES: PullRequestFileRecord[] = [
1030+
{ repoFullName: "acme/widgets", pullNumber: 7, path: "src/app.tsx", status: "modified", additions: 1, deletions: 0, changes: 1, payload: {} },
1031+
];
1032+
1033+
function gateConfig(action: "close" | "block" | "advisory"): ScreenshotTableGateConfig {
1034+
return { ...DEFAULT_SCREENSHOT_TABLE_GATE, enabled: true, whenLabels: [], whenPaths: [], action };
1035+
}
1036+
1037+
it("action: close, violated, captureUnobtainable: true — appends exactly one finding naming the remedy, and the same inputs plan no close/hold", async () => {
1038+
const env = createTestEnv();
1039+
const adv = advisory();
1040+
await maybeAddScreenshotTableAdvisoryFinding(env, {
1041+
advisory: adv,
1042+
repoFullName: "acme/widgets",
1043+
pullNumber: 7,
1044+
screenshotTableGateConfig: gateConfig("close"),
1045+
prBody: "no table here",
1046+
prLabels: [],
1047+
botCaptureSatisfied: false,
1048+
captureUnobtainable: true,
1049+
files: NO_TABLE_FILES,
1050+
});
1051+
expect(adv.findings).toHaveLength(1);
1052+
expect(adv.findings[0]?.code).toBe("screenshot_table_missing");
1053+
expect(adv.findings[0]?.detail).toContain(CAPTURE_UNOBTAINABLE_REASON);
1054+
expect(adv.findings[0]?.publicText).toContain(CAPTURE_UNOBTAINABLE_REASON);
1055+
1056+
// The same degraded facts the real caller threads through (processors.ts): screenshotTableMatch and
1057+
// screenshotEvidenceHold both stay absent, and screenshotTableEvidenceUnresolved stays false, so the
1058+
// planner falls through to ordinary disposition instead of closing or holding.
1059+
const plan = planAgentMaintenanceActions({
1060+
blockerTitles: [],
1061+
autonomy: { merge: "auto", close: "auto", review_state_label: "auto" },
1062+
autoMaintain: { requireApprovals: 1, mergeMethod: "squash" },
1063+
slopGateMinScore: 60,
1064+
changedPaths: [],
1065+
hardGuardrailGlobs: [],
1066+
authorIsOwner: false,
1067+
authorIsAdmin: false,
1068+
authorIsAutomationBot: false,
1069+
ciState: "passed",
1070+
conclusion: "success" as GateCheckConclusion,
1071+
manualReviewLabel: "human-review",
1072+
screenshotTableMatch: undefined,
1073+
screenshotEvidenceHold: undefined,
1074+
screenshotTableEvidenceUnresolved: false,
1075+
pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" },
1076+
} satisfies AgentActionPlanInput);
1077+
expect(plan.some((a) => a.actionClass === "close")).toBe(false);
1078+
expect(plan.some((a) => a.actionClass === "label" && a.label === "human-review" && a.labelOp !== "remove")).toBe(false);
1079+
});
1080+
1081+
it("action: block, violated, captureUnobtainable: true — appends the same degraded finding, and no hold is planned", async () => {
1082+
const env = createTestEnv();
1083+
const adv = advisory();
1084+
await maybeAddScreenshotTableAdvisoryFinding(env, {
1085+
advisory: adv,
1086+
repoFullName: "acme/widgets",
1087+
pullNumber: 7,
1088+
screenshotTableGateConfig: gateConfig("block"),
1089+
prBody: "no table here",
1090+
prLabels: [],
1091+
botCaptureSatisfied: false,
1092+
captureUnobtainable: true,
1093+
files: NO_TABLE_FILES,
1094+
});
1095+
expect(adv.findings).toHaveLength(1);
1096+
expect(adv.findings[0]?.detail).toContain(CAPTURE_UNOBTAINABLE_REASON);
1097+
1098+
const plan = planAgentMaintenanceActions({
1099+
blockerTitles: [],
1100+
autonomy: { merge: "auto", review_state_label: "auto" },
1101+
autoMaintain: { requireApprovals: 1, mergeMethod: "squash" },
1102+
slopGateMinScore: 60,
1103+
changedPaths: [],
1104+
hardGuardrailGlobs: [],
1105+
authorIsOwner: false,
1106+
authorIsAdmin: false,
1107+
authorIsAutomationBot: false,
1108+
ciState: "passed",
1109+
conclusion: "success" as GateCheckConclusion,
1110+
manualReviewLabel: "human-review",
1111+
screenshotTableMatch: undefined,
1112+
screenshotEvidenceHold: undefined,
1113+
screenshotTableEvidenceUnresolved: false,
1114+
pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" },
1115+
} satisfies AgentActionPlanInput);
1116+
expect(plan.some((a) => a.actionClass === "label" && a.label === "human-review" && a.labelOp !== "remove")).toBe(false);
1117+
expect(plan.some((a) => a.actionClass === "close")).toBe(false);
1118+
});
1119+
1120+
it("action: close, violated, captureUnobtainable: false — pins today's behavior: NO advisory finding", async () => {
1121+
const env = createTestEnv();
1122+
const adv = advisory();
1123+
await maybeAddScreenshotTableAdvisoryFinding(env, {
1124+
advisory: adv,
1125+
repoFullName: "acme/widgets",
1126+
pullNumber: 7,
1127+
screenshotTableGateConfig: gateConfig("close"),
1128+
prBody: "no table here",
1129+
prLabels: [],
1130+
botCaptureSatisfied: false,
1131+
captureUnobtainable: false,
1132+
files: NO_TABLE_FILES,
1133+
});
1134+
expect(adv.findings).toEqual([]);
1135+
});
1136+
1137+
it("action: advisory, violated, captureUnobtainable: false — byte-identical finding to today", async () => {
1138+
const env = createTestEnv();
1139+
const adv = advisory();
1140+
await maybeAddScreenshotTableAdvisoryFinding(env, {
1141+
advisory: adv,
1142+
repoFullName: "acme/widgets",
1143+
pullNumber: 7,
1144+
screenshotTableGateConfig: gateConfig("advisory"),
1145+
prBody: "no table here",
1146+
prLabels: [],
1147+
botCaptureSatisfied: false,
1148+
captureUnobtainable: false,
1149+
files: NO_TABLE_FILES,
1150+
});
1151+
expect(adv.findings).toHaveLength(1);
1152+
expect(adv.findings[0]).toMatchObject({
1153+
code: "screenshot_table_missing",
1154+
severity: "warning",
1155+
title: "Missing before/after screenshot table",
1156+
action: "Add a before/after screenshot table to the pull request description (advisory only — this does not block merge).",
1157+
});
1158+
expect(adv.findings[0]?.detail).not.toContain(CAPTURE_UNOBTAINABLE_REASON);
1159+
});
1160+
1161+
it("REGRESSION (#10060): a degraded action: close evaluation never produces a completely silent pass", async () => {
1162+
const env = createTestEnv();
1163+
const adv = advisory();
1164+
await maybeAddScreenshotTableAdvisoryFinding(env, {
1165+
advisory: adv,
1166+
repoFullName: "acme/widgets",
1167+
pullNumber: 7,
1168+
screenshotTableGateConfig: gateConfig("close"),
1169+
prBody: "no table here",
1170+
prLabels: [],
1171+
botCaptureSatisfied: false,
1172+
captureUnobtainable: true,
1173+
files: NO_TABLE_FILES,
1174+
});
1175+
expect(adv.findings.length).toBeGreaterThan(0);
1176+
});
1177+
1178+
it("not enabled: does not scan, no finding appended even when captureUnobtainable is true", async () => {
1179+
const env = createTestEnv();
1180+
const adv = advisory();
1181+
await maybeAddScreenshotTableAdvisoryFinding(env, {
1182+
advisory: adv,
1183+
repoFullName: "acme/widgets",
1184+
pullNumber: 7,
1185+
screenshotTableGateConfig: { ...gateConfig("close"), enabled: false },
1186+
prBody: "no table here",
1187+
prLabels: [],
1188+
botCaptureSatisfied: false,
1189+
captureUnobtainable: true,
1190+
files: NO_TABLE_FILES,
1191+
});
1192+
expect(adv.findings).toEqual([]);
1193+
});
1194+
1195+
it("fail-safe: a thrown error while loading files never propagates and appends no finding", async () => {
1196+
const env = createTestEnv();
1197+
const adv = advisory();
1198+
const throwingEnv = {
1199+
...env,
1200+
DB: {
1201+
...env.DB,
1202+
prepare: () => {
1203+
throw new Error("boom");
1204+
},
1205+
},
1206+
} as unknown as typeof env;
1207+
await expect(
1208+
maybeAddScreenshotTableAdvisoryFinding(throwingEnv, {
1209+
advisory: adv,
1210+
repoFullName: "acme/widgets",
1211+
pullNumber: 7,
1212+
screenshotTableGateConfig: gateConfig("close"),
1213+
prBody: "no table here",
1214+
prLabels: [],
1215+
botCaptureSatisfied: false,
1216+
captureUnobtainable: true,
1217+
files: null,
1218+
}),
1219+
).resolves.toBeUndefined();
1220+
expect(adv.findings).toEqual([]);
1221+
});
1222+
});

0 commit comments

Comments
 (0)