Skip to content

Commit 7967e78

Browse files
authored
Merge pull request #4423 from JSONbored/claude/fix-third-party-check-manual-hold
fix(github): hold non-required third-party action_required checks instead of auto-closing
2 parents f13706f + 708cbd8 commit 7967e78

6 files changed

Lines changed: 468 additions & 7 deletions

File tree

src/github/backfill.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2775,6 +2775,8 @@ async function reduceLiveCiAggregate(
27752775
// 1) Check-runs (GitHub Actions jobs, CodeQL, app checks). Deduped by check-run identity first, so a
27762776
// re-run job's stale duplicate entry can never contribute its own failingDetails/pending signal alongside the
27772777
// current one, without collapsing unrelated checks that merely share a display name.
2778+
const checkRunSummary = (run: LiveCiCheckRun): string | undefined =>
2779+
[run.output?.title, run.output?.summary].find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim().slice(0, 200);
27782780
for (const run of dedupeLatestCheckRunsByIdentity(checkRuns)) {
27792781
seenContextNames.add(run.name); // mark BEFORE bot-check skip: a bot-owned required context is "seen"
27802782
const appSlug = (run.app?.slug ?? "").toLowerCase();
@@ -2784,13 +2786,25 @@ async function reduceLiveCiAggregate(
27842786
const conclusion = (run.conclusion ?? "").toLowerCase();
27852787
const status = (run.status ?? "").toLowerCase();
27862788
// A THIRD-PARTY app's OWN action_required verdict on an already-COMPLETED check-run (for example, a
2787-
// security/check tool asking for human review) is a settled, terminal adverse result. This is NOT the
2788-
// github-actions "awaiting maintainer Approve and run" case the action_required exclusion above exists for:
2789-
// non-Actions apps use their own conclusion as a policy signal, so fail closed instead of treating it as
2790-
// green CI. Conservative: an unknown/absent app slug is NOT treated as third-party here.
2791-
const isThirdPartyActionRequiredFailure = conclusion === "action_required" && status === "completed" && appSlug !== "" && appSlug !== "github-actions";
2792-
if (isThirdPartyActionRequiredFailure || (conclusion ? CI_FAILING_CONCLUSIONS.has(conclusion) : false)) {
2793-
const summary = [run.output?.title, run.output?.summary].find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim().slice(0, 200);
2789+
// security/check tool asking for human review) is a settled, terminal adverse result -- but ONLY when that
2790+
// check is actually a REQUIRED context. A non-required third-party check must never hard-fail/auto-close the
2791+
// PR on its own say-so: the same app can post multiple check-runs (e.g. a required "X Security Scan" plus a
2792+
// separate, NEVER-required "X Contributor trust" advisory check), and treating either one's action_required
2793+
// the same way conflates them (#4414 regressed exactly this -- a non-required advisory check started
2794+
// auto-closing real contributor PRs). This is NOT the github-actions "awaiting maintainer Approve and run"
2795+
// case the action_required exclusion above exists for: non-Actions apps use their own conclusion as a policy
2796+
// signal. Conservative: an unknown/absent app slug is NOT treated as third-party here.
2797+
const isThirdPartyActionRequired = conclusion === "action_required" && status === "completed" && appSlug !== "" && appSlug !== "github-actions";
2798+
if (isThirdPartyActionRequired && isRequired(run.name)) {
2799+
const summary = checkRunSummary(run);
2800+
failingDetails.push({ name: run.name, ...(summary ? { summary } : {}), ...(run.details_url ? { detailsUrl: run.details_url } : {}) });
2801+
} else if (isThirdPartyActionRequired) {
2802+
// Non-required: visible (never silently folded into "passed" either, unlike the pre-#4414 behavior) but
2803+
// non-blocking -- routed to nonRequiredFailingDetails, which never feeds ciState or a close decision.
2804+
const summary = checkRunSummary(run);
2805+
nonRequiredFailingDetails.push({ name: run.name, ...(summary ? { summary } : {}), ...(run.details_url ? { detailsUrl: run.details_url } : {}) });
2806+
} else if (conclusion ? CI_FAILING_CONCLUSIONS.has(conclusion) : false) {
2807+
const summary = checkRunSummary(run);
27942808
failingDetails.push({ name: run.name, ...(summary ? { summary } : {}), ...(run.details_url ? { detailsUrl: run.details_url } : {}) });
27952809
} else if (conclusion ? CI_PASSING_CONCLUSIONS.has(conclusion) : status === "completed") {
27962810
// concluded and not failing → passing

src/queue/processors.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10599,13 +10599,23 @@ async function maybePublishPrPublicSurface(
1059910599
...(detail.detailsUrl ? { detailsUrl: detail.detailsUrl } : {}),
1060010600
}),
1060110601
);
10602+
// Non-required-but-red checks (#4414-class advisory holds): surfaced so a flagged check is never silently
10603+
// invisible, but never folded into failingChecks/failingDetails -- those two drive ciState/close.
10604+
const nonRequiredFailingDetails: CheckFailureDetail[] = liveCi.nonRequiredFailingDetails.map(
10605+
(detail) => ({
10606+
name: detail.name,
10607+
...(detail.summary ? { summary: detail.summary } : {}),
10608+
...(detail.detailsUrl ? { detailsUrl: detail.detailsUrl } : {}),
10609+
}),
10610+
);
1060210611
const mergeReadiness: MergeReadiness = {
1060310612
ciState,
1060410613
...(mergeStateLabel ? { mergeStateLabel } : {}),
1060510614
...(failingDetails.length > 0
1060610615
? { failingChecks: failingDetails.map((detail) => detail.name) }
1060710616
: {}),
1060810617
...(failingDetails.length > 0 ? { failingDetails } : {}),
10618+
...(nonRequiredFailingDetails.length > 0 ? { nonRequiredFailingDetails } : {}),
1060910619
};
1061010620
// The public comment must match the authoritative Gate check-run conclusion.
1061110621
const commentGate = gateEvaluation;

src/review/unified-comment.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ export interface MergeReadiness {
9191
ciState: "passed" | "failed" | "unverified";
9292
failingChecks?: string[];
9393
failingDetails?: CheckFailureDetail[];
94+
/** Checks that reported red (e.g. a third-party app's `action_required` conclusion) but are NOT a
95+
* branch-protection required context -- so they never flip `ciState`/block merge on their own, but must
96+
* still be VISIBLE rather than silently dropped (#4414-class regression: a non-required advisory check must
97+
* neither auto-close the PR nor vanish without a trace). Rendered as its own non-blocking collapsible,
98+
* independent of `ciState`. */
99+
nonRequiredFailingDetails?: CheckFailureDetail[];
94100
}
95101

96102
/** The structured synthesis of the reviewers' notes that drives BOTH the legacy unified comment
@@ -524,6 +530,22 @@ function failingChecksBlock(readiness: MergeReadiness | undefined): string {
524530
return [...new Set(names)].map((name) => `- ${escapePublicHtmlAngles(name)}`).join("\n");
525531
}
526532

533+
/** Render non-required-but-red checks (#4414-class advisory holds) as a `name — reason` bullet list, same
534+
* shape/public-safety rules as `failingChecksBlock`. Unlike that one, this is NOT gated on `ciState` -- these
535+
* checks by definition never flip `ciState`, so the section must render purely off the data's own presence. */
536+
function nonRequiredFailingChecksBlock(readiness: MergeReadiness | undefined): string {
537+
const details = readiness?.nonRequiredFailingDetails ?? [];
538+
const lines = details
539+
.map((detail) => {
540+
const name = escapePublicHtmlAngles(detail.name.trim());
541+
if (!name) return "";
542+
const reason = detail.summary?.trim() ? ` — ${escapePublicHtmlAngles(detail.summary.trim())}` : "";
543+
return `- ${name}${reason}`;
544+
})
545+
.filter((line) => line.length > 0);
546+
return lines.join("\n");
547+
}
548+
527549
function signalTable(input: UnifiedReviewInput, ctx: UnifiedCommentContext): string {
528550
const blockerCount = (input.blockers ?? []).length;
529551
const reviewerEvidence =
@@ -651,6 +673,13 @@ export function renderUnifiedReviewComment(input: UnifiedReviewInput, ctx: Unifi
651673
const failingChecks = failingChecksBlock(input.readiness);
652674
if (failingChecks) blocks.push(`**CI checks failing**\n${failingChecks}`);
653675

676+
// Non-required-but-red checks (#4414-class advisory holds): visible but never blocking, so this renders
677+
// independent of ciState/status -- omitted entirely when nothing was flagged (default) ⇒ byte-identical.
678+
const nonRequiredFailingChecks = nonRequiredFailingChecksBlock(input.readiness);
679+
if (nonRequiredFailingChecks && verbosity !== "quiet") {
680+
blocks.push(details("Flagged checks (non-blocking)", nonRequiredFailingChecks, undefined, collapsiblesOpen));
681+
}
682+
654683
blocks.push(signalTable(input, ctx));
655684

656685
// Linked-issue satisfaction advisory (#2174): additive, collapsed section — omitted entirely when the host

test/unit/backfill.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4237,6 +4237,99 @@ describe("GitHub backfill", () => {
42374237
expect(aggregate.failingDetails).toEqual([{ name: "Contributor trust" }]);
42384238
});
42394239

4240+
it("a third-party app's COMPLETED action_required check-run that IS a required context carries its summary/detailsUrl into failingDetails", async () => {
4241+
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
4242+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
4243+
const url = input.toString();
4244+
if (url.includes("/check-runs?")) {
4245+
return Response.json({
4246+
check_runs: [
4247+
{ name: "coverage", status: "completed", conclusion: "success", app: { slug: "github-actions" } },
4248+
{
4249+
name: "Contributor trust",
4250+
status: "completed",
4251+
conclusion: "action_required",
4252+
app: { slug: "superagent-security" },
4253+
output: { title: "Manual review needed" },
4254+
details_url: "https://superagent.example/checks/contributor-trust",
4255+
},
4256+
],
4257+
});
4258+
}
4259+
if (url.includes("/status?")) return Response.json({ statuses: [] });
4260+
if (url.includes("/check-suites?")) return Response.json({ check_suites: [{ status: "completed", app: { slug: "github-actions" } }] });
4261+
return new Response("not found", { status: 404 });
4262+
});
4263+
4264+
const aggregate = await fetchLiveCiAggregate(env, "JSONbored/awesome-claude", "sha4729", "public-token", new Set(["coverage", "Contributor trust"]));
4265+
4266+
expect(aggregate.ciState).toBe("failed");
4267+
expect(aggregate.failingDetails).toEqual([
4268+
{ name: "Contributor trust", summary: "Manual review needed", detailsUrl: "https://superagent.example/checks/contributor-trust" },
4269+
]);
4270+
});
4271+
4272+
it("a third-party app's COMPLETED action_required check-run that is NOT a required context is held as a non-blocking advisory, never auto-closing the PR (#4414-regression)", async () => {
4273+
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
4274+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
4275+
const url = input.toString();
4276+
if (url.includes("/check-runs?")) {
4277+
return Response.json({
4278+
check_runs: [
4279+
{ name: "validate", status: "completed", conclusion: "success", app: { slug: "github-actions" } },
4280+
{ name: "Superagent Security Scan", status: "completed", conclusion: "success", app: { slug: "superagent-security" } },
4281+
{
4282+
name: "Contributor trust",
4283+
status: "completed",
4284+
conclusion: "action_required",
4285+
app: { slug: "superagent-security" },
4286+
output: { title: "Manual review needed" },
4287+
details_url: "https://superagent.example/checks/contributor-trust",
4288+
},
4289+
],
4290+
});
4291+
}
4292+
if (url.includes("/status?")) return Response.json({ statuses: [] });
4293+
if (url.includes("/check-suites?")) return Response.json({ check_suites: [{ status: "completed", app: { slug: "github-actions" } }] });
4294+
return new Response("not found", { status: 404 });
4295+
});
4296+
4297+
// Matches real branch protection: only "validate" + "Superagent Security Scan" are required contexts --
4298+
// "Contributor trust" is a SEPARATE, never-required check-run posted by the same app.
4299+
const aggregate = await fetchLiveCiAggregate(env, "JSONbored/gittensory", "sha9001", "public-token", new Set(["validate", "Superagent Security Scan"]));
4300+
4301+
expect(aggregate.ciState).toBe("passed");
4302+
expect(aggregate.hasPending).toBe(false);
4303+
expect(aggregate.failingDetails).toEqual([]);
4304+
expect(aggregate.nonRequiredFailingDetails).toEqual([
4305+
{ name: "Contributor trust", summary: "Manual review needed", detailsUrl: "https://superagent.example/checks/contributor-trust" },
4306+
]);
4307+
});
4308+
4309+
it("a non-required third-party action_required check-run with no output/details_url still lands in nonRequiredFailingDetails, bare (name-only)", async () => {
4310+
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
4311+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
4312+
const url = input.toString();
4313+
if (url.includes("/check-runs?")) {
4314+
return Response.json({
4315+
check_runs: [
4316+
{ name: "validate", status: "completed", conclusion: "success", app: { slug: "github-actions" } },
4317+
{ name: "Contributor trust", status: "completed", conclusion: "action_required", app: { slug: "superagent-security" } },
4318+
],
4319+
});
4320+
}
4321+
if (url.includes("/status?")) return Response.json({ statuses: [] });
4322+
if (url.includes("/check-suites?")) return Response.json({ check_suites: [{ status: "completed", app: { slug: "github-actions" } }] });
4323+
return new Response("not found", { status: 404 });
4324+
});
4325+
4326+
const aggregate = await fetchLiveCiAggregate(env, "JSONbored/gittensory", "sha9002", "public-token", new Set(["validate"]));
4327+
4328+
expect(aggregate.ciState).toBe("passed");
4329+
expect(aggregate.failingDetails).toEqual([]);
4330+
expect(aggregate.nonRequiredFailingDetails).toEqual([{ name: "Contributor trust" }]);
4331+
});
4332+
42404333
it("a github-actions workflow awaiting 'Approve and run' (action_required) is still treated as pending, not settled (#fork-action-required)", async () => {
42414334
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
42424335
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {

0 commit comments

Comments
 (0)