You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
settings(pr-disposition): isCommentMergeStateHeld ignores unstableExplainedByIgnoredChecks, so the comment surface still holds a PR the planner does not #10055
⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
src/settings/pr-disposition.ts exists specifically to remove the "four surfaces each re-derive their own
meaning for GitHub's raw mergeable_state" class its own header describes
(src/settings/pr-disposition.ts:1–src/settings/pr-disposition.ts:8). It exports two things that are
supposed to agree:
The "equal by construction" claim was true before unstableExplainedByIgnoredChecks was introduced
(src/settings/pr-disposition.ts:93). It is false now: for mergeableState === "unstable" with unstableExplainedByIgnoredChecks: true, derivePrDisposition(...).commentMergeStateHeld is false while isCommentMergeStateHeld("unstable") is true. Two exported functions in the single-interpretation module
disagree, and one of them documents that they cannot.
The divergence is live, not theoretical. The planner resolves the flag from real data:
That boolean is authoritative downstream: src/review/unified-comment.ts:394–src/review/unified-comment.ts:396
turns an otherwise-"ready" status into "held" whenever readiness.mergeStateHeld is true.
So for a PR whose mergeable_state is unstable explained solely by a check the maintainer listed in gate.ignoredCheckRuns, the planner correctly does not hold it (that is what unstableExplainedByIgnoredChecks was added for — see the field's own doc at src/settings/pr-disposition.ts:87–src/settings/pr-disposition.ts:92, which cites the observed live case of
a PR held with reason "non-required check(s) not passing: Contributor trust"), while the public unified
comment still renders it held and refuses to say the PR is safe to merge. The ignoredCheckRuns setting is
half-effective in exactly the way that field was added to stop.
The invariant test that is supposed to pin this cannot catch it, because it only ever exercises the default
arm:
// test/unit/pr-disposition-invariants.test.ts:82it("isCommentMergeStateHeld equals derivePrDisposition(...).commentMergeStateHeld for every raw state (equal by construction, pinned)",()=>{for(constrawofRAW_STATES){expect(isCommentMergeStateHeld(raw),`state=${String(raw)}`).toBe(derivePrDisposition(dispositionInput({mergeableState: raw})).commentMergeStateHeld,);}});
dispositionInput never sets unstableExplainedByIgnoredChecks, so the loop compares only the arm where the
two happen to still agree.
Requirements
isCommentMergeStateHeld must accept the ignored-checks resolution as an explicit parameter and honour it,
so it is genuinely equal by construction to derivePrDisposition(...).commentMergeStateHeld for every
combination of raw state and that flag. Concretely: it must take a second argument (e.g. unstableExplainedByIgnoredChecks?: boolean | undefined) and return false for "unstable" when that
argument is true, while every other raw state keeps its current result for both values of the argument.
Omitting the argument must keep today's behaviour exactly (undefined ⇒ unstable holds), so no caller that
cannot resolve it changes.
src/queue/processors.ts:2188 must pass the same resolution the planner computes at src/settings/agent-actions.ts:1167. buildPublicCommentMergeFacts already holds the inputs that formula
needs — args.liveCi.nonRequiredFailingDetails and the derived ciState
(src/queue/processors.ts:2175–src/queue/processors.ts:2180) — plus whatever names the repo's ignored
checks; the ignored-check list must be threaded to this call site rather than re-derived from a different
source, so the two surfaces read one resolution and not two.
The "equal by construction" JSDoc at src/settings/pr-disposition.ts:155 must remain TRUE after the change,
and the invariant test must actually prove it across both values of the flag.
Must NOT change: assessMergeableState's mapping (src/settings/pr-disposition.ts:39), the heldForManualReview / wouldApprove / wouldMerge formulas, or the deliberate asymmetry documented at src/settings/pr-disposition.ts:143–src/settings/pr-disposition.ts:147 whereby "behind" downgrades the
comment but never holds the planner.
⚠️ Required pattern: the corrected isCommentMergeStateHeld must compute its unstable term with the
identical expression derivePrDisposition uses at src/settings/pr-disposition.ts:128
(mergeable === "unstable" && flag !== true), so the two cannot drift again. What does NOT satisfy this
issue: (a) deleting isCommentMergeStateHeld and having src/queue/processors.ts:2188 call derivePrDisposition with a fabricated PrDispositionInput — that surface has no access to the hold
inputs and would have to invent them; (b) weakening the JSDoc to say the two are "approximately" equal or
deleting the "equal by construction" sentence instead of restoring the property; (c) a test-only PR that
adds the missing flag case and asserts the CURRENT divergent values; (d) changing src/review/unified-comment.ts's zero-import contract by importing pr-disposition.ts there — the bridge
must keep passing a resolved boolean.
Deliverables
isCommentMergeStateHeld (src/settings/pr-disposition.ts) takes the ignored-checks resolution and
returns false for ("unstable", true), true for ("unstable", false) and ("unstable", undefined),
and its existing result for "dirty", "behind", "clean", "blocked", "unknown", undefined
under both values.
src/queue/processors.ts:2188 passes the resolution computed by the same formula as src/settings/agent-actions.ts:1167, from buildPublicCommentMergeFacts's own inputs.
test/unit/pr-disposition-invariants.test.ts:82's "equal by construction" loop is extended to iterate unstableExplainedByIgnoredChecks over [undefined, false, true] for every raw state, and passes.
A regression test named for this bug at test/unit/pr-disposition-invariants.test.ts asserting that for mergeableState: "unstable" with unstableExplainedByIgnoredChecks: true, isCommentMergeStateHeld("unstable", true) and derivePrDisposition({...base, mergeableState: "unstable", unstableExplainedByIgnoredChecks: true}).commentMergeStateHeld
are both false — the exact pair that disagrees today.
A test at test/unit/pr-disposition-invariants.test.ts asserting that deriveUnifiedStatus-equivalent behaviour is preserved for a "ready" status with mergeStateHeld: false (renders ready) and mergeStateHeld: true (renders held), so the src/review/unified-comment.ts:394 branch stays covered on both arms.
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example one
that fixes isCommentMergeStateHeld's signature but leaves src/queue/processors.ts:2188 passing only the
raw string, so the comment surface still holds the PR — does not resolve this issue.
Test Coverage Requirements
This repo enforces 99%+ Codecov patch coverage, branch-counted. vitest.config.ts's coverage.include
covers src/**/*.ts and packages/loopover-engine/src/**/*.ts; both src/settings/pr-disposition.ts and src/queue/processors.ts are measured. The change introduces a new && flag !== true conjunct in isCommentMergeStateHeld — both arms need a test ("unstable" with the flag true, and "unstable" with
it false/undefined) — and a new conditional at the src/queue/processors.ts:2188 call site whose true and
false results must each be exercised through buildPublicCommentMergeFacts.
Expected Outcome
A repo that lists a check in gate.ignoredCheckRuns gets the full effect of that setting: the planner does
not hold the PR and the public review comment stops claiming it is held. The two exported functions in pr-disposition.ts are once again equal by construction across every input, and the invariant test proves it
over the flag rather than only over the raw state — so the next field added to PrDispositionInput cannot
silently reintroduce the split.
Links & Resources
src/settings/pr-disposition.ts:128 — unstableHolds, the term the helper omits
src/settings/pr-disposition.ts:155 — the false "equal by construction" claim
src/settings/agent-actions.ts:1167 — the planner's resolution of unstableExplainedByIgnoredChecks
src/queue/processors.ts:2188 — the only production caller of the state-only helper
src/review/unified-comment.ts:394 — where mergeStateHeld turns "ready" into "held"
test/unit/pr-disposition-invariants.test.ts:82 — the invariant test that cannot currently fail
Context
src/settings/pr-disposition.tsexists specifically to remove the "four surfaces each re-derive their ownmeaning for GitHub's raw
mergeable_state" class its own header describes(
src/settings/pr-disposition.ts:1–src/settings/pr-disposition.ts:8). It exports two things that aresupposed to agree:
The "equal by construction" claim was true before
unstableExplainedByIgnoredCheckswas introduced(
src/settings/pr-disposition.ts:93). It is false now: formergeableState === "unstable"withunstableExplainedByIgnoredChecks: true,derivePrDisposition(...).commentMergeStateHeldisfalsewhileisCommentMergeStateHeld("unstable")istrue. Two exported functions in the single-interpretation moduledisagree, and one of them documents that they cannot.
The divergence is live, not theoretical. The planner resolves the flag from real data:
The comment surface does not — it is the only production caller of the state-only helper, and it passes the
raw string:
That boolean is authoritative downstream:
src/review/unified-comment.ts:394–src/review/unified-comment.ts:396turns an otherwise-
"ready"status into"held"wheneverreadiness.mergeStateHeldis true.So for a PR whose
mergeable_stateisunstableexplained solely by a check the maintainer listed ingate.ignoredCheckRuns, the planner correctly does not hold it (that is whatunstableExplainedByIgnoredCheckswas added for — see the field's own doc atsrc/settings/pr-disposition.ts:87–src/settings/pr-disposition.ts:92, which cites the observed live case ofa PR held with reason "non-required check(s) not passing: Contributor trust"), while the public unified
comment still renders it held and refuses to say the PR is safe to merge. The
ignoredCheckRunssetting ishalf-effective in exactly the way that field was added to stop.
The invariant test that is supposed to pin this cannot catch it, because it only ever exercises the default
arm:
dispositionInputnever setsunstableExplainedByIgnoredChecks, so the loop compares only the arm where thetwo happen to still agree.
Requirements
isCommentMergeStateHeldmust accept the ignored-checks resolution as an explicit parameter and honour it,so it is genuinely equal by construction to
derivePrDisposition(...).commentMergeStateHeldfor everycombination of raw state and that flag. Concretely: it must take a second argument (e.g.
unstableExplainedByIgnoredChecks?: boolean | undefined) and returnfalsefor"unstable"when thatargument is
true, while every other raw state keeps its current result for both values of the argument.Omitting the argument must keep today's behaviour exactly (
undefined⇒ unstable holds), so no caller thatcannot resolve it changes.
src/queue/processors.ts:2188must pass the same resolution the planner computes atsrc/settings/agent-actions.ts:1167.buildPublicCommentMergeFactsalready holds the inputs that formulaneeds —
args.liveCi.nonRequiredFailingDetailsand the derivedciState(
src/queue/processors.ts:2175–src/queue/processors.ts:2180) — plus whatever names the repo's ignoredchecks; the ignored-check list must be threaded to this call site rather than re-derived from a different
source, so the two surfaces read one resolution and not two.
src/settings/pr-disposition.ts:155must remain TRUE after the change,and the invariant test must actually prove it across both values of the flag.
assessMergeableState's mapping (src/settings/pr-disposition.ts:39), theheldForManualReview/wouldApprove/wouldMergeformulas, or the deliberate asymmetry documented atsrc/settings/pr-disposition.ts:143–src/settings/pr-disposition.ts:147whereby"behind"downgrades thecomment but never holds the planner.
Deliverables
isCommentMergeStateHeld(src/settings/pr-disposition.ts) takes the ignored-checks resolution andreturns
falsefor("unstable", true),truefor("unstable", false)and("unstable", undefined),and its existing result for
"dirty","behind","clean","blocked","unknown",undefinedunder both values.
src/queue/processors.ts:2188passes the resolution computed by the same formula assrc/settings/agent-actions.ts:1167, frombuildPublicCommentMergeFacts's own inputs.test/unit/pr-disposition-invariants.test.ts:82's "equal by construction" loop is extended to iterateunstableExplainedByIgnoredChecksover[undefined, false, true]for every raw state, and passes.test/unit/pr-disposition-invariants.test.tsasserting that formergeableState: "unstable"withunstableExplainedByIgnoredChecks: true,isCommentMergeStateHeld("unstable", true)andderivePrDisposition({...base, mergeableState: "unstable", unstableExplainedByIgnoredChecks: true}).commentMergeStateHeldare both
false— the exact pair that disagrees today.test/unit/pr-disposition-invariants.test.tsasserting thatderiveUnifiedStatus-equivalent behaviour is preserved for a"ready"status withmergeStateHeld: false(renders ready) andmergeStateHeld: true(renders held), so thesrc/review/unified-comment.ts:394branch stays covered on both arms.All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example one
that fixes
isCommentMergeStateHeld's signature but leavessrc/queue/processors.ts:2188passing only theraw string, so the comment surface still holds the PR — does not resolve this issue.
Test Coverage Requirements
This repo enforces 99%+ Codecov patch coverage, branch-counted.
vitest.config.ts'scoverage.includecovers
src/**/*.tsandpackages/loopover-engine/src/**/*.ts; bothsrc/settings/pr-disposition.tsandsrc/queue/processors.tsare measured. The change introduces a new&& flag !== trueconjunct inisCommentMergeStateHeld— both arms need a test ("unstable"with the flag true, and"unstable"withit false/undefined) — and a new conditional at the
src/queue/processors.ts:2188call site whose true andfalse results must each be exercised through
buildPublicCommentMergeFacts.Expected Outcome
A repo that lists a check in
gate.ignoredCheckRunsgets the full effect of that setting: the planner doesnot hold the PR and the public review comment stops claiming it is held. The two exported functions in
pr-disposition.tsare once again equal by construction across every input, and the invariant test proves itover the flag rather than only over the raw state — so the next field added to
PrDispositionInputcannotsilently reintroduce the split.
Links & Resources
src/settings/pr-disposition.ts:128—unstableHolds, the term the helper omitssrc/settings/pr-disposition.ts:155— the false "equal by construction" claimsrc/settings/agent-actions.ts:1167— the planner's resolution ofunstableExplainedByIgnoredCheckssrc/queue/processors.ts:2188— the only production caller of the state-only helpersrc/review/unified-comment.ts:394— wheremergeStateHeldturns"ready"into"held"test/unit/pr-disposition-invariants.test.ts:82— the invariant test that cannot currently fail