Skip to content

Commit a8ac83b

Browse files
committed
refactor(disposition): one shared mergeable-state interpretation consumed by planner, comment, and labels (#8759)
The #8711 incident's root CLASS: four surfaces each re-derived their own meaning for GitHub's raw mergeable_state string (comment {dirty,behind, unstable}; merge !== clean; approve {dirty}; hold-label none pre-#8758). #8758 unified the predicates; this removes the class: - src/settings/pr-disposition.ts (new, pure, dependency-free): assessMergeableState — THE single raw-string interpretation point — and derivePrDisposition (heldForManualReview / wouldApprove / wouldMerge / commentMergeStateHeld), each semantic documented as a contract. - agent-actions.ts derives the disposition once and consumes its fields for heldForManualReview, the approve gate, and canMerge; isConflict routes through assessMergeableState. Byte-identical: all 308 existing planner tests pass unchanged. - unified-comment.ts's readiness gains mergeStateHeld — resolved by processors.ts via isCommentMergeStateHeld so the self-contained renderer consumes a boolean instead of re-deriving meaning (zero-import contract intact); legacy callers keep the byte-identical raw-string fallback. - test/unit/pr-disposition-invariants.test.ts: the cross-surface invariant suite — module invariants over the full state matrix, planner-vs- disposition agreement per state, renderer-vs-disposition agreement through the bridge boolean, and the legacy-fallback equivalence pin.
1 parent b0bdaaa commit a8ac83b

5 files changed

Lines changed: 341 additions & 24 deletions

File tree

src/queue/processors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ import {
280280
type AgentDispositionLabelSettings,
281281
type PlannedAgentAction,
282282
} from "../settings/agent-actions";
283+
import { isCommentMergeStateHeld } from "../settings/pr-disposition";
283284
import { isAutoCloseExempt } from "../settings/auto-close-exempt";
284285
import {
285286
isSkipAutomationBotPullRequestsEnabledGlobally,
@@ -2063,6 +2064,10 @@ export function derivePublicCommentMergeFacts(args: {
20632064
const mergeReadiness: MergeReadiness = {
20642065
ciState,
20652066
...(mergeStateLabel ? { mergeStateLabel } : {}),
2067+
// #8759: the SHARED interpretation of the merge state (pr-disposition.ts) — the same one the
2068+
// disposition planner reads — resolved here so the self-contained renderer consumes a boolean
2069+
// instead of re-deriving meaning from the raw string (the #8711 four-surfaces-disagree class).
2070+
...(mergeStateLabel ? { mergeStateHeld: isCommentMergeStateHeld(mergeStateLabel) } : {}),
20662071
...(failingDetails.length > 0 ? { failingChecks: failingDetails.map((detail) => detail.name) } : {}),
20672072
...(failingDetails.length > 0 ? { failingDetails } : {}),
20682073
...(nonRequiredFailingDetails.length > 0 ? { nonRequiredFailingDetails } : {}),

src/review/unified-comment.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ export interface CheckFailureDetail {
8989
* Canonical home (#288): was duplicated identically in the awesome-claude + metagraphed agents. */
9090
export interface MergeReadiness {
9191
mergeStateLabel?: string;
92+
/** #8759: the SHARED interpretation of mergeStateLabel, resolved by the bridge via
93+
* pr-disposition.ts's isCommentMergeStateHeld so this self-contained file never re-derives meaning
94+
* from the raw string. When present it is authoritative; absent (older callers) the legacy raw-string
95+
* check below applies, byte-identical to the pre-#8759 behavior. */
96+
mergeStateHeld?: boolean;
9297
ciState: "passed" | "failed" | "unverified";
9398
failingChecks?: string[];
9499
failingDetails?: CheckFailureDetail[];
@@ -378,7 +383,10 @@ export function deriveUnifiedStatus(input: UnifiedReviewInput, ctx: UnifiedComme
378383
// merge" on the SAME PR the disposition planner is actively holding, which is the contradiction #5288 reported.
379384
// Other states — clean, a not-yet-computed `unknown`, or a `blocked` that the bot's own pending approval will
380385
// clear — do not downgrade. (#ready-needs-mergeable)
381-
if (status === "ready" && input.readiness?.mergeStateLabel) {
386+
if (status === "ready" && input.readiness?.mergeStateHeld !== undefined) {
387+
// #8759: the bridge resolved the shared interpretation (pr-disposition.ts) — authoritative when present.
388+
if (input.readiness.mergeStateHeld) return "held";
389+
} else if (status === "ready" && input.readiness?.mergeStateLabel) {
382390
const mergeState = input.readiness.mergeStateLabel.toLowerCase();
383391
if (mergeState === "dirty" || mergeState === "behind" || mergeState === "unstable") return "held";
384392
}

src/settings/agent-actions.ts

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AgentActionClass, AutoMaintainPolicy, AutoMergeMethod, AutonomyPolicy } from "../types";
22
import { AI_JUDGMENT_BLOCKER_CODES, type GateCheckConclusion } from "../rules/advisory";
33
import { DEFAULT_AUTO_MAINTAIN_POLICY, autonomyRequiresApproval, isActingAutonomyLevel, resolveAutonomy } from "./autonomy";
4+
import { assessMergeableState, derivePrDisposition } from "./pr-disposition";
45
import { changedPathsHittingGuardrail, isGuardrailHit } from "../signals/change-guardrail";
56
import { AGENT_LABEL_PENDING_CLOSURE } from "../review/linked-issue-hard-rules";
67
import { REVIEW_THREAD_BLOCKER_CODE } from "../review/review-thread-findings";
@@ -966,7 +967,8 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
966967
// The gate verdict is authoritative. Green CI is still required for merge/approve, but it does not rewrite an AI
967968
// or review-thread blocker into success once the gate has classified it as blocking.
968969
const conclusion: GateCheckConclusion = input.conclusion;
969-
const isConflict = input.pr.mergeableState === "dirty"; // conflicts with base — can't merge as-is
970+
// #8759: the raw mergeable_state string is interpreted ONLY by assessMergeableState — one shared meaning.
971+
const isConflict = assessMergeableState(input.pr.mergeableState) === "conflict"; // conflicts with base — can't merge as-is
970972
// True when an unresolved GitHub review thread is (at least one of) this close's justifications -- the SAME
971973
// staleness class as isConflict above (#3863), just triggered by a contributor clicking "Resolve conversation"
972974
// on GitHub instead of the base branch becoming mergeable again. A mixed blocker set (thread + something else)
@@ -1020,33 +1022,34 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
10201022
// would silently MERGE straight through the escalation instead of being held. When `close` IS acting, the
10211023
// dedicated close branch below handles it and this term is redundant (harmless: both paths agree the PR
10221024
// must not silently merge).
1023-
// Unstable mergeable state (#8758, the #8711 silent-stall fix): GitHub reports "unstable" when every REQUIRED
1024-
// check is green but some non-required check/status is not — exactly the state where canMerge below
1025-
// self-suppresses (mergeableClean requires "clean") while, pre-#8758, nothing else held, labeled, or explained.
1026-
// Folding it into heldForManualReview downgrades the would-approve/would-merge into the SAME loud
1027-
// held-for-review disposition every other merge-suppressing hold gets: no approve claiming "gate satisfied",
1028-
// no ready-to-merge label, and a manual-review label + comment naming the culprit check. Deliberately ONLY
1029-
// "unstable": "dirty" is the close path (isConflict), "behind" belongs to the rebase rail, and
1030-
// "blocked"/"unknown"/absent stay approvable (the approval itself can be the unblocking act — see the approve
1031-
// block's own doc comment — and a transient null must not spray hold labels). Every consumer that acts on this
1032-
// flag is conjoined with reviewGood, so a red-CI/failed-gate PR's close is never softened by this term.
1033-
const mergeableStateUnstable = input.pr.mergeableState === "unstable";
1034-
const heldForManualReview =
1035-
guardrailHit ||
1036-
input.migrationCollisionHold !== undefined ||
1037-
input.unlinkedIssueMatchHold !== undefined ||
1038-
(input.advisoryCheckHold !== undefined && input.advisoryCheckHold.length > 0) ||
1039-
mergeableStateUnstable ||
1040-
(input.unlinkedIssueMatchClose !== undefined && !acting("close"));
1025+
// #8759: the hold/approve/merge core now comes from the SHARED disposition module — the same
1026+
// derivation the unified comment's bridge reads — so the four surfaces can never again disagree on
1027+
// what a raw mergeable_state means (#8711's root class). The unstable-hold semantics are #8758's,
1028+
// unchanged (see derivePrDisposition's own doc + the MergeableAssessment contract): "dirty" stays the
1029+
// close path, "behind" stays the rebase rail's, "blocked"/"unknown" stay approvable, "unstable" holds
1030+
// loudly. The disposition's wouldApprove/wouldMerge feed the approve/merge gates below, still
1031+
// conjoined with the planner-private terms (autonomy, idempotency, approvals, terminal-block) that
1032+
// are not disposition. reviewGood is computed here (moved up from beside canMerge — same formula,
1033+
// gate passes AND CI green) because the disposition needs it.
1034+
const reviewGood = gatePassing && ciPassed;
1035+
const disposition = derivePrDisposition({
1036+
mergeableState: input.pr.mergeableState,
1037+
reviewGood,
1038+
guardrailHit,
1039+
migrationCollisionHold: input.migrationCollisionHold !== undefined,
1040+
unlinkedIssueMatchHold: input.unlinkedIssueMatchHold !== undefined,
1041+
advisoryCheckHold: input.advisoryCheckHold !== undefined && input.advisoryCheckHold.length > 0,
1042+
unlinkedIssueMatchCloseWithoutCloseActing: input.unlinkedIssueMatchClose !== undefined && !acting("close"),
1043+
});
1044+
const heldForManualReview = disposition.heldForManualReview;
1045+
const mergeableStateUnstable = disposition.heldForUnstableMergeState;
10411046
const labels = resolveAgentDispositionLabels(input);
10421047
// Canonical (reviewbot non-content-gate) policy, tuned to the operator's minimize-manual goal: merge-or-close
10431048
// with high accuracy; manual review is the RARE exception. A PR is "review-good" when the gate passes AND CI is
10441049
// green — that's the only thing that earns an auto-merge or an approve. Everything else, for a CONTRIBUTOR, is a
10451050
// one-shot CLOSE (taopedia model: resolve + open a fresh PR). The guardrail is handled SEPARATELY: it converts
10461051
// would-approve/would-merge dispositions into a manual hold.
10471052
const ciUnverified = input.ciState === "unverified";
1048-
const reviewGood = gatePassing && ciPassed;
1049-
const mergeableClean = input.pr.mergeableState === "clean";
10501053
// RC3: a prior merge attempt failed terminally for THIS exact head SHA (403/405/409/conflict) → never re-plan
10511054
// the merge; it can't complete for this commit. A new commit makes the live head differ from mergeBlockedSha.
10521055
const mergeTerminallyBlocked = input.pr.mergeBlockedSha != null && input.pr.headSha != null && input.pr.mergeBlockedSha === input.pr.headSha;
@@ -1055,7 +1058,9 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
10551058
// reviewDecision to APPROVED, so reviewDecision alone can't dedup). A new commit makes the heads differ →
10561059
// approve may fire again. Absent approved-head SHA (never approved by the bot) ⇒ not idempotent-skipped.
10571060
const alreadyApprovedThisHead = input.pr.approvedHeadSha != null && input.pr.headSha != null && input.pr.approvedHeadSha === input.pr.headSha;
1058-
const canMerge = reviewGood && !heldForManualReview && acting("merge") && mergeableClean && approvalsSatisfied && !mergeTerminallyBlocked;
1061+
// #8759: disposition.wouldMerge = reviewGood && !held && exactly-clean — the shared core; the terms
1062+
// conjoined here (autonomy, approvals, terminal-block) are planner-private state, not disposition.
1063+
const canMerge = disposition.wouldMerge && acting("merge") && approvalsSatisfied && !mergeTerminallyBlocked;
10591064
// CLOSE a contributor PR ONLY on a REAL adverse signal — a confirmed gate FAILURE, red CI, or a base
10601065
// CONFLICT. NEVER close merely because CI is UNVERIFIED (a fork whose Actions await approval, or unreadable
10611066
// checks) or otherwise not-yet-mergeable — those are HELD for review, not killed (#harm-stop fork-false-close).
@@ -1348,7 +1353,9 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
13481353
// An `unstable` PR is excluded too, via heldForManualReview's mergeableStateUnstable term (#8758): the merge
13491354
// below would self-suppress on it, and approve firing while merge silently never comes was exactly #8711's
13501355
// "approved, labeled ready, never merged, nobody told" incident. */
1351-
if (reviewGood && !heldForManualReview && !linkedIssueCloseInFlight && !isConflict && acting("approve") && input.pr.reviewDecision !== "APPROVED" && !alreadyApprovedThisHead) {
1356+
// #8759: disposition.wouldApprove = reviewGood && !held && not-a-conflict — the shared core the executor's
1357+
// live recheck mirrors; the terms conjoined here are planner-private (close-in-flight, autonomy, idempotency).
1358+
if (disposition.wouldApprove && !linkedIssueCloseInFlight && acting("approve") && input.pr.reviewDecision !== "APPROVED" && !alreadyApprovedThisHead) {
13521359
actions.push({
13531360
actionClass: "approve",
13541361
requiresApproval: approval("approve"),

src/settings/pr-disposition.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Shared PR-disposition core (#8759, epic #8757). The #8711 incident's root CLASS was four surfaces —
2+
// the disposition planner (agent-actions.ts), the executor's live rechecks (agent-action-executor.ts),
3+
// the unified comment's status derivation (unified-comment.ts, via the bridge), and the review-state
4+
// labels — each re-deriving its own meaning for GitHub's raw `mergeable_state` string, with four
5+
// different subsets treated as "bad" (comment {dirty,behind,unstable}; merge !== "clean"; approve
6+
// {dirty}; hold-label ∅ pre-#8758). #8758 unified the PREDICATES; this module removes the CLASS by
7+
// giving every surface ONE place the raw string is interpreted and ONE shared held/approve/merge
8+
// assessment derived from it.
9+
//
10+
// PURE AND DEPENDENCY-FREE by design: agent-actions.ts (the planner), processors.ts (the comment
11+
// bridge's caller), and the executor all import from here; this file imports nothing of theirs, so it
12+
// can never participate in a cycle. The self-contained unified-comment.ts still receives plain data
13+
// (the bridge passes the RESOLVED assessment, never an import), preserving its zero-import contract.
14+
//
15+
// INVARIANT CONTRACT (pinned by test/unit/pr-disposition-invariants.test.ts): for any input state,
16+
// • approve is never allowed while the state is one merge would refuse for a reason no other rail
17+
// resolves (assessment "conflict" → the close path owns it; "unstable" → the manual hold owns it);
18+
// • "behind" never holds (the rebase rail owns it) and stays approvable;
19+
// • "blocked"/"unknown"/absent stay approvable (the bot's own approval can be the unblocking act,
20+
// and a transient null must not spray hold labels);
21+
// • merge requires exactly "clean" — the strictest predicate, unchanged since before #8758.
22+
23+
/** Every meaning the raw GitHub `mergeable_state` string carries for the disposition surfaces. This is
24+
* THE single interpretation point — no other module may compare the raw string against a literal. */
25+
export type MergeableAssessment =
26+
/** Safe to merge right now (the only state `canMerge` accepts). */
27+
| "clean"
28+
/** Hard base conflict — the CLOSE path's business (`isConflict`), never approve, never hold-label. */
29+
| "conflict"
30+
/** Behind the base — the rebase rail's business; approvable, never a manual hold. */
31+
| "behind"
32+
/** Required checks green but a non-required check/status is not (#8711/#8758): merge self-suppresses,
33+
* so the PR must be HELD loudly (manual-review label + comment) and never approved into a stall. */
34+
| "unstable"
35+
/** blocked / unknown / null / anything else: not mergeable YET, but approvable — the missing piece may
36+
* be the bot's own approval (blocked) or a transient computation (unknown). Never a hold. */
37+
| "indeterminate";
38+
39+
export function assessMergeableState(state: string | null | undefined): MergeableAssessment {
40+
switch ((state ?? "").toLowerCase()) {
41+
case "clean":
42+
return "clean";
43+
case "dirty":
44+
return "conflict";
45+
case "behind":
46+
return "behind";
47+
case "unstable":
48+
return "unstable";
49+
default:
50+
return "indeterminate";
51+
}
52+
}
53+
54+
/** The hold inputs every surface must agree on. Each field mirrors the planner input of the same name —
55+
* the caller (planner or processors.ts) resolves them once and both surfaces read the same values. */
56+
export type PrDispositionInput = {
57+
mergeableState: string | null | undefined;
58+
/** Gate conclusion success/neutral AND required CI passed — the only thing that earns approve/merge. */
59+
reviewGood: boolean;
60+
guardrailHit: boolean;
61+
migrationCollisionHold: boolean;
62+
unlinkedIssueMatchHold: boolean;
63+
advisoryCheckHold: boolean;
64+
/** A confirmed repeat unlinked-issue-match while `close` autonomy is NOT acting (the planner's own
65+
* fold-into-hold escape hatch — see agent-actions.ts's heldForManualReview doc). */
66+
unlinkedIssueMatchCloseWithoutCloseActing: boolean;
67+
};
68+
69+
export type PrDisposition = {
70+
mergeable: MergeableAssessment;
71+
/** The SAME formula agent-actions.ts's heldForManualReview computes — one definition, two readers. */
72+
heldForManualReview: boolean;
73+
/** True when the ONLY thing suppressing a would-merge is the unstable mergeable state (#8758's loud
74+
* hold): the planner uses it to attach the check-naming comment; the comment surface uses it to
75+
* downgrade "safe to merge". */
76+
heldForUnstableMergeState: boolean;
77+
/** reviewGood && not held && not the close path's conflict — the approve gate's shared core. The
78+
* planner still conjoins its own idempotency/autonomy terms (reviewDecision, approvedHeadSha,
79+
* acting("approve")) — those are planner-private state, not disposition. */
80+
wouldApprove: boolean;
81+
/** reviewGood && not held && exactly-clean — the merge gate's shared core. The planner still conjoins
82+
* approvalsSatisfied / mergeTerminallyBlocked / acting("merge") — planner-private state. */
83+
wouldMerge: boolean;
84+
/** The comment surface's readiness downgrade: an otherwise-"ready" status must render held for any
85+
* state in this set (conflict/behind/unstable — never claim "safe to merge" while GitHub disagrees),
86+
* mirroring deriveUnifiedStatus's historical {dirty, behind, unstable} set exactly. */
87+
commentMergeStateHeld: boolean;
88+
};
89+
90+
export function derivePrDisposition(input: PrDispositionInput): PrDisposition {
91+
const mergeable = assessMergeableState(input.mergeableState);
92+
const heldForManualReview =
93+
input.guardrailHit ||
94+
input.migrationCollisionHold ||
95+
input.unlinkedIssueMatchHold ||
96+
input.advisoryCheckHold ||
97+
mergeable === "unstable" ||
98+
input.unlinkedIssueMatchCloseWithoutCloseActing;
99+
const heldForUnstableMergeState = mergeable === "unstable";
100+
const wouldApprove = input.reviewGood && !heldForManualReview && mergeable !== "conflict";
101+
const wouldMerge = input.reviewGood && !heldForManualReview && mergeable === "clean";
102+
// The comment's historical downgrade set, byte-identical to deriveUnifiedStatus's own
103+
// {dirty, behind, unstable} check (#ready-needs-mergeable / #pr-5288-confusing-verdict): "behind"
104+
// downgrades the COMMENT's "safe to merge" claim (the rebase hasn't happened yet) even though it never
105+
// holds the PLANNER (the rebase rail acts) — a deliberate, documented asymmetry, not drift: the two
106+
// 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";
108+
return { mergeable, heldForManualReview, heldForUnstableMergeState, wouldApprove, wouldMerge, commentMergeStateHeld };
109+
}
110+
111+
/** The comment surface's merge-state downgrade as a standalone predicate (#8759): the bridge
112+
* (unified-comment-bridge.ts) resolves it and passes the BOOLEAN into the self-contained renderer, so
113+
* unified-comment.ts keeps its zero-import contract while reading the same interpretation the planner
114+
* uses. Equal by construction to derivePrDisposition(...).commentMergeStateHeld. */
115+
export function isCommentMergeStateHeld(state: string | null | undefined): boolean {
116+
const mergeable = assessMergeableState(state);
117+
return mergeable === "conflict" || mergeable === "behind" || mergeable === "unstable";
118+
}

0 commit comments

Comments
 (0)