Skip to content

Commit 118588e

Browse files
committed
fix(gate): an ignored check must not hold the PR via mergeable_state either
gate.ignoredCheckRuns (#9813) excluded the check from LoopOver's own CI aggregate, but mergeable_state is GITHUB's computation and stays "unstable" while the check exists at all -- and derivePrDisposition holds unconditionally on unstable. So the ignore was half-effective: the check no longer failed the gate, and the PR was held anyway. Observed live on #9816 immediately after the config flip, reason "mergeable_state is unstable -- non-required check(s) not passing: Contributor trust". Dismiss an unstable state ONLY when the ignore list fully explains it: at least one ignored run concluded non-passing, our aggregate found no non-required failure, and ciState is not failed. Any other unstable cause still holds, and the flag never rescues a PR held for a different reason. Also make the un-itemized unstable message actionable. It used to say only "a non-required check or status is not passing" -- no check name, no next step. GitHub never says why, and the aggregate can legitimately fail to itemize it (a commit status rather than a check-run, an unreadable app page, a run that appeared after CI was read). Name that ambiguity and point at the Checks tab.
1 parent 9d7f567 commit 118588e

5 files changed

Lines changed: 82 additions & 6 deletions

File tree

src/github/backfill.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2626,7 +2626,7 @@ async function fetchPullRequestChecks(
26262626
// auto-approval). (#fork-action-required) — a THIRD-PARTY app's own COMPLETED action_required verdict (e.g. a
26272627
// security/check tool) is handled separately below and fails closed as a manual-hold signal, not green CI.
26282628
const CI_FAILING_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "startup_failure"]);
2629-
const CI_PASSING_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
2629+
export const CI_PASSING_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
26302630
// The bot's OWN check-runs — it posts these (in_progress, then concluded) as PART OF reviewing. They are NOT
26312631
// "CI to wait on": counting them self-deadlocks (the review waits for all CI to finish; these only finish when
26322632
// the very review they're blocking runs → the PR defers forever). Excluded from the CI aggregate entirely.

src/queue/processors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ import {
122122
isReviewsCacheUpToDate,
123123
primeDurablePrStateCache,
124124
refreshPullRequestDetails,
125+
CI_PASSING_CONCLUSIONS,
125126
} from "../github/backfill";
126127
import {
127128
contributorRepoStatsFromGittensor,
@@ -2863,6 +2864,9 @@ function buildAgentMaintenancePlanInput(args: {
28632864
// be silent (#8711). Threaded so the planner's unstable hold can NAME the culprit check(s) in its
28642865
// reason/comment; the hold itself keys on pr.mergeableState, so an empty list still holds with generic wording.
28652866
nonRequiredCheckFailures: ciAggregate.nonRequiredFailingDetails,
2867+
// #9810 follow-up: only the NON-PASSING ignored runs. A maintainer-ignored check that concluded fine is
2868+
// not an explanation for instability, so it must not license dismissing one.
2869+
ignoredCheckNonPassing: ciAggregate.ignoredCheckDetails.filter((run) => !CI_PASSING_CONCLUSIONS.has(run.conclusion)),
28662870
...(blacklistEntry !== null
28672871
? { blacklistMatch: { matched: true, reason: blacklistEntry.reason } }
28682872
: {}),

src/settings/agent-actions.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,11 @@ export type AgentActionPlanInput = {
422422
// held-for-review state so a maintainer can act on the signal their installed app raised. Each entry names the
423423
// triggering check/app/conclusion so the hold reason (and the manual-review label's comment) is actionable.
424424
advisoryCheckHold?: ReadonlyArray<{ name: string; appSlug: string; conclusion: string }> | undefined;
425+
// #9810 follow-up: non-passing check-runs the maintainer listed in `gate.ignoredCheckRuns`, as seen on this
426+
// head. Used ONLY to decide whether a GitHub "unstable" mergeable_state is fully explained by them -- the
427+
// CI aggregate already excludes these from pass/fail, but mergeable_state is GitHub's own computation and
428+
// stays unstable while the check exists at all.
429+
ignoredCheckNonPassing?: ReadonlyArray<{ name: string; appSlug: string; conclusion: string }> | undefined;
425430
// Non-required failing checks/statuses (#8758, the #8711 silent-stall fix). The CI aggregate's
426431
// nonRequiredFailingDetails: red checks that are neither branch-protection-required nor declared advisory —
427432
// they never feed ciState or a close, but GitHub folds them into mergeable_state "unstable", which suppresses
@@ -799,13 +804,23 @@ function mergeUnstableHoldReason(failures: ReadonlyArray<{ name: string }> | und
799804
const names = (failures ?? []).map((f) => `"${f.name}"`);
800805
return names.length > 0
801806
? `mergeable_state is unstable — non-required check(s) not passing: ${names.join("; ")}`
802-
: "mergeable_state is unstable — a non-required check or status is not passing";
807+
// #9810 follow-up: the un-itemized case used to say only "a non-required check or status is not passing",
808+
// which tells a maintainer nothing they can act on -- not which check, not where to look. GitHub computes
809+
// mergeable_state itself and never says why, and our aggregate can legitimately fail to itemize it (a
810+
// COMMIT STATUS rather than a check-run, a check from an app whose page we couldn't read, or a run that
811+
// appeared after the aggregate was taken). Name that ambiguity and point at the one place the answer
812+
// always exists, instead of restating the state.
813+
: "mergeable_state is unstable — GitHub reports a non-required check or status as not passing, but this pass could not itemize which one (it may be a commit status rather than a check-run, or it appeared after CI was read). See the PR's own Checks tab for the current list";
803814
}
804815

805816
function mergeUnstableHoldComment(failures: ReadonlyArray<{ name: string }> | undefined): string {
806817
const names = (failures ?? []).map((f) => `\`${f.name}\``);
807818
const culprit = names.length > 0 ? ` — ${names.join(", ")} —` : "";
808-
return `Held for manual review: the gate and required CI are green, but GitHub reports this pull request's mergeable state as \`unstable\` because a non-required check or status${culprit} is not passing, so LoopOver will not auto-merge. A maintainer can resolve the failing check or review and merge manually. This is an automated maintenance action.`;
819+
const whereToLook =
820+
names.length > 0
821+
? ""
822+
: " This pass could not identify which check (it may be a commit status rather than a check-run, or it appeared after CI was read) — the PR's own Checks tab has the current list.";
823+
return `Held for manual review: the gate and required CI are green, but GitHub reports this pull request's mergeable state as \`unstable\` because a non-required check or status${culprit} is not passing, so LoopOver will not auto-merge.${whereToLook} A maintainer can resolve the failing check or review and merge manually. This is an automated maintenance action.`;
809824
}
810825

811826
/**
@@ -1102,6 +1117,11 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
11021117
migrationCollisionHold: input.migrationCollisionHold !== undefined,
11031118
unlinkedIssueMatchHold: input.unlinkedIssueMatchHold !== undefined,
11041119
advisoryCheckHold: input.advisoryCheckHold !== undefined && input.advisoryCheckHold.length > 0,
1120+
// Deliberately conjoined with "nothing else adverse": an unstable state is only attributed to the ignore
1121+
// list when our own aggregate found NO failing check of any kind. If some other non-required check is
1122+
// also red, failingDetails is non-empty and the hold stands -- an ignore must never mask a real failure.
1123+
unstableExplainedByIgnoredChecks:
1124+
input.ignoredCheckNonPassing !== undefined && input.ignoredCheckNonPassing.length > 0 && (input.nonRequiredCheckFailures ?? []).length === 0 && input.ciState !== "failed",
11051125
unlinkedIssueMatchCloseWithoutCloseActing: input.unlinkedIssueMatchClose !== undefined && !acting("close"),
11061126
});
11071127
const heldForManualReview = disposition.heldForManualReview;

src/settings/pr-disposition.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ export type PrDispositionInput = {
6161
migrationCollisionHold: boolean;
6262
unlinkedIssueMatchHold: boolean;
6363
advisoryCheckHold: boolean;
64+
/** #9810 follow-up: GitHub says `unstable`, but the ONLY non-passing check explaining it is one the
65+
* maintainer listed in `gate.ignoredCheckRuns`. LoopOver's own CI aggregate already excludes such a run --
66+
* yet `mergeable_state` is GitHub's computation, not ours, and it stays "unstable" while the check exists
67+
* at all. Without this the ignore was half-effective: the check no longer failed the gate, and the PR was
68+
* held anyway (observed on JSONbored/loopover#9816, reason "mergeable_state is unstable — non-required
69+
* check(s) not passing: Contributor trust"). Set ONLY when nothing else adverse was seen. */
70+
unstableExplainedByIgnoredChecks?: boolean | undefined;
6471
/** A confirmed repeat unlinked-issue-match while `close` autonomy is NOT acting (the planner's own
6572
* fold-into-hold escape hatch — see agent-actions.ts's heldForManualReview doc). */
6673
unlinkedIssueMatchCloseWithoutCloseActing: boolean;
@@ -89,22 +96,25 @@ export type PrDisposition = {
8996

9097
export function derivePrDisposition(input: PrDispositionInput): PrDisposition {
9198
const mergeable = assessMergeableState(input.mergeableState);
99+
// An "unstable" state that ONLY an ignored check explains carries no signal a maintainer asked to act on:
100+
// they explicitly declared that check meaningless for this repo. Every other unstable cause still holds.
101+
const unstableHolds = mergeable === "unstable" && input.unstableExplainedByIgnoredChecks !== true;
92102
const heldForManualReview =
93103
input.guardrailHit ||
94104
input.migrationCollisionHold ||
95105
input.unlinkedIssueMatchHold ||
96106
input.advisoryCheckHold ||
97-
mergeable === "unstable" ||
107+
unstableHolds ||
98108
input.unlinkedIssueMatchCloseWithoutCloseActing;
99-
const heldForUnstableMergeState = mergeable === "unstable";
109+
const heldForUnstableMergeState = unstableHolds;
100110
const wouldApprove = input.reviewGood && !heldForManualReview && mergeable !== "conflict";
101111
const wouldMerge = input.reviewGood && !heldForManualReview && mergeable === "clean";
102112
// The comment's historical downgrade set, byte-identical to deriveUnifiedStatus's own
103113
// {dirty, behind, unstable} check (#ready-needs-mergeable / #pr-5288-confusing-verdict): "behind"
104114
// downgrades the COMMENT's "safe to merge" claim (the rebase hasn't happened yet) even though it never
105115
// holds the PLANNER (the rebase rail acts) — a deliberate, documented asymmetry, not drift: the two
106116
// surfaces answer different questions ("is it safe to claim mergeable NOW" vs "should a human step in").
107-
const commentMergeStateHeld = mergeable === "conflict" || mergeable === "behind" || mergeable === "unstable";
117+
const commentMergeStateHeld = mergeable === "conflict" || mergeable === "behind" || unstableHolds;
108118
return { mergeable, heldForManualReview, heldForUnstableMergeState, wouldApprove, wouldMerge, commentMergeStateHeld };
109119
}
110120

test/unit/pr-disposition-invariants.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,45 @@ describe("cross-surface: deriveUnifiedStatus consumes the bridge-resolved boolea
177177
}
178178
});
179179
});
180+
181+
describe("unstable explained only by an IGNORED check (#9810 follow-up)", () => {
182+
const base = {
183+
reviewGood: true, guardrailHit: false, migrationCollisionHold: false, unlinkedIssueMatchHold: false,
184+
advisoryCheckHold: false, unlinkedIssueMatchCloseWithoutCloseActing: false,
185+
};
186+
187+
it("REGRESSION: an unstable state the ignore list fully explains no longer holds", () => {
188+
// The live half-fix: gate.ignoredCheckRuns removed the check from LoopOver's CI aggregate, but
189+
// mergeable_state is GitHub's own computation and stayed "unstable" while the check existed at all —
190+
// so JSONbored/loopover#9816 was still held, reason "mergeable_state is unstable — non-required
191+
// check(s) not passing: Contributor trust". The ignore was half-effective until this.
192+
const d = derivePrDisposition({ ...base, mergeableState: "unstable", unstableExplainedByIgnoredChecks: true });
193+
expect(d.heldForManualReview).toBe(false);
194+
expect(d.heldForUnstableMergeState).toBe(false);
195+
expect(d.commentMergeStateHeld).toBe(false);
196+
});
197+
198+
it("INVARIANT: unstable from ANY other cause still holds — the flag is not a blanket override", () => {
199+
const d = derivePrDisposition({ ...base, mergeableState: "unstable", unstableExplainedByIgnoredChecks: false });
200+
expect(d.heldForManualReview).toBe(true);
201+
expect(d.heldForUnstableMergeState).toBe(true);
202+
});
203+
204+
it("INVARIANT: absent flag behaves exactly as before (byte-identical for every existing caller)", () => {
205+
expect(derivePrDisposition({ ...base, mergeableState: "unstable" }).heldForManualReview).toBe(true);
206+
});
207+
208+
it("INVARIANT: the flag never rescues a PR held for a DIFFERENT reason", () => {
209+
// An ignored check explaining the instability must not also wave through a guardrail hit.
210+
const d = derivePrDisposition({ ...base, guardrailHit: true, mergeableState: "unstable", unstableExplainedByIgnoredChecks: true });
211+
expect(d.heldForManualReview).toBe(true);
212+
expect(d.wouldMerge).toBe(false);
213+
});
214+
215+
it("a dismissed-unstable PR can actually merge when everything else is clean", () => {
216+
// The point of the fix: not merely "not held", but genuinely mergeable again. GitHub still says
217+
// unstable, so mergeableState stays the gate on wouldMerge — the PR approves rather than merging.
218+
const d = derivePrDisposition({ ...base, mergeableState: "unstable", unstableExplainedByIgnoredChecks: true });
219+
expect(d.wouldApprove).toBe(true);
220+
});
221+
});

0 commit comments

Comments
 (0)