fix(source-control): fold every composing ruleset rule instead of the last - #2171
Conversation
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 1m 20s —— View job Security review complete
Scope: the 4 files changed in this PR — FindingsNo security issues found. This change is a pure logic fix inside
Nothing here falls into command/SQL/path/template injection, auth/access-control gaps, secret handling, or dangerous Actions patterns — and per the review lane's scope, supply-chain/unpinned-action/permissions/trigger-pattern concerns are deferred to zizmor (not applicable here anyway, since no workflow files changed). |
|
Claude finished @kyle-sexton's task in 4m 10s —— View job Code review complete
REVIEW.md is a two-lane split and this repo has a Note on the PR's own verification claims: the Test plan describes running the new FindingsOne inline comment posted on
What checked out
|
|
Claude has reviewed this PR 4 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
`branch_rules` assigned `requiredContexts` inside the loop over
`repos/{repo}/rules/branches/{branch}`, so each `required_status_checks`
rule overwrote the previous one and only the last ruleset's contexts
survived. That endpoint returns one such rule PER RULESET -- the
single-rule shape classic branch protection always produced, and
rulesets do not. On this repository the two rulesets governing `main`
collapsed to the one returned last, dropping three of four required
contexts from `effectiveRules` and from the unmet-required blocker.
Accumulate into a set and report it sorted, so every ruleset's contexts
survive, a context two rulesets both require is reported once, and the
order is stable regardless of the order the API returns rulesets in.
Entries carrying no `context` are dropped rather than sorted as `None`.
`base_is_unprotected` needs no change and is now honest: it derives from
the union, which is empty only when no ruleset requires anything. Under
the overwrite it hung on whichever ruleset came last, so a trailing rule
with an empty context list would have flipped it and silently retired the
hold on a non-self-authored PR onto an unprotected base.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`branch_rules` folded `required_status_checks` across every ruleset but still assigned the `pull_request` rule inside the same loop, so a second such rule would overwrite the first. Not observed misreporting: exactly one `pull_request` rule governs the branch today. Nothing prevents a second, and `requiredApprovingReviews` feeds both `base_is_unprotected` and the "needs N approving review(s)" blocker, so a ruleset requiring two approvals returned before one requiring zero would have reported zero. Fold `requiredApprovingReviews` with max and `requireThreadResolution` with OR. That is the fail-closed direction whatever GitHub's own composition rule turns out to be, which is why it needs no appeal to one: max/OR can only over-report, holding a PR for a human, where last-wins can under-report and release one. The count is a behaviour change on a multi-ruleset base; `requireThreadResolution` is report-only, never consumed as a blocker, since the gate holds on unresolved threads unconditionally. Bump source-control 0.51.4 -> 0.51.5 with its CHANGELOG entry, per the plugin's convention of versioning each behaviour fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3135c75 to
4742380
Compare
|
Claude finished @kyle-sexton's task in 1m 50s —— View job Security review complete
Scope: the 4 files changed in this PR — Findings1 SUGGESTION (defense-in-depth, not exploitable via normal API behavior):
What checked out
Nothing here falls into command/SQL/path/template injection, auth/access-control bypass reachable by an attacker, secret handling, or dangerous Actions patterns. Per the review lane's scope, supply-chain/unpinned-action/permissions/trigger-pattern concerns are deferred to zizmor (not applicable anyway — no workflow files changed in this PR). |
|
Claude finished @kyle-sexton's task in 2m 14s —— View job Code review complete
This is a re-review triggered by a REVIEW.md splits this repo's review into a code-review lane and a security lane ( FindingsOne inline comment (re-posted, unresolved from the prior review pass on this PR):
What checked out (unchanged from the prior pass, re-verified against current HEAD)
Note on execution: as in the prior review pass, this sandbox's Bash tool requires interactive approval for every |
…ot zero
The previous guard read `count if isinstance(count, int) else (1 if count
else 0)`, so every FALSY non-int -- `None`, `""`, `0.0`, `[]`, `{}` --
still collapsed to zero. That is the same fail-open the guard was added
to close, only narrower, and `None` is the realistic case: a ruleset
payload carrying `required_approving_review_count` with a null value.
The comment above it asserted an invariant the code did not deliver.
Absence and unreadability are now distinguished, which is the
distinction the previous two attempts blurred. No key means the rule
states no review requirement, which is genuinely zero. A key holding
anything unreadable as a count means a requirement IS stated and its
size is unknown, so it counts as one and holds the PR for a human.
`int()` normalizes the value so a `bool` -- an `int` subclass -- cannot
leak into the summary as `True`.
Pinned by tests over all five falsy non-ints plus the absent and bool
cases, so the fold's only-ever-over-report guarantee is now checked
rather than asserted in a comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 48s —— View job Security review complete
Scope: diff between the previously-reviewed commit ( FindingsNo security issues found. This commit closes the one finding flagged in both prior security-review passes on this PR ( The fix at if "required_approving_review_count" not in params:
count = 0
else:
raw = params["required_approving_review_count"]
count = int(raw) if isinstance(raw, int) else 1
required_reviews = max(required_reviews, count)
I checked the full |
Verification status of this PR — stated plainlyThe shipped diff has no independent verifier verdict. Three fresh-context verifiers were dispatched against it (two What verification does exist, all reproducible:
One reviewer finding was real and is fixed — the Recorded as unverified, not as refuted: a ruleset carrying bypass actors is the one shape where GitHub could plausibly report An unverified PR that says so is a different thing from one that implies verification it never got. This is the former. |
#2163 merged and took source-control 0.51.8, so this branch's ruleset-fold fix moves to 0.51.9. main's 0.51.8 section is kept intact under its own heading with its own content. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 48s —— View job Security review complete
Scope: diff between the previously-reviewed commit ( FindingsNo security issues found. The two files with security-relevant logic — Within
This PR's fold-logic fix ( Confirmed scope match: |
|
Claude finished @kyle-sexton's task in 1m 33s —— View job Code review complete
This is a re-review triggered by a Previously flagged finding — now resolvedCommit if "required_approving_review_count" not in params:
count = 0
else:
raw = params["required_approving_review_count"]
count = int(raw) if isinstance(raw, int) else 1
required_reviews = max(required_reviews, count)This distinguishes absence (no key → the rule states no review requirement → New tests in
This closes the gap identified in the two prior inline comments on line 296; no further action needed there. What else checked out
Note on execution: as with the prior passes on this PR, this sandbox requires interactive approval for |
|
Claude finished @kyle-sexton's task in 1m 11s —— View job Code review complete
This is a re-review triggered by a What changed since the last review pass: nothing in the fold logic. REVIEW.md splits this repo's review into a code-review lane and a security lane ( FindingsNone. The one previously-flagged issue — if "required_approving_review_count" not in params:
count = 0
else:
raw = params["required_approving_review_count"]
count = int(raw) if isinstance(raw, int) else 1
required_reviews = max(required_reviews, count)This separates "key absent" (a real, readable zero) from "key present but unreadable" (reads as one, unconditionally — no remaining truthiness test). What checked out (re-verified against current HEAD, unchanged from prior passes)
Note on execution: as in prior passes, this sandbox's Bash tool requires interactive approval for every |
Correction to the verification-status comment above — an independent verdict now existsThe comment above said this diff had no independent verifier verdict. That was true when written. It is no longer true, so I am correcting it rather than leaving a false statement standing. A fourth verifier was dispatched from a fresh context against the pinned head What it discharged
Item 3 is the one nobody had done, and it is the one that mattersPassing tests prove nothing about a guard unless they fail without it. The verifier reverted only the guard hunk to its exact historical pre-fix text — recovered from The 6 failures, across 2 methods:
Reported honestly rather than overstated: The tree was restored with Item 4 — the hole, raw outputFive falsy non-ints collapsing to The bool concern is fully closed
Verifier verdict: SAFE TO MERGE. Independently re-checked before merging, from a fresh contextNot taken on the agents' word — each read from the API at the pinned SHA:
|
…r-reason remedies (#2316) Closes #2265 ## What `branch_rules` computed `requireSignatures` and nothing consumed it (`babysit_merge.py` had zero matches for `.commit.verification`/`verified`): a head held only by an unsigned or mis-authored commit yielded `BLOCKED` plus the generic `mergeStateStatus` line naming four other causes — none of them the real one. - **`fetch_pull_request_commits`** (`babysit_gh.py`): reads `.commit.verification` per PR commit via `pulls/{n}/commits?per_page=100 --paginate`; a missing verification block reports `unverified`/`unreadable` rather than being skipped (the consumer may only over-report). - **`evaluate_required_signatures`** (`babysit_merge.py`): runs only when the rule is present (an ungoverned base pays no extra request), in the **read-only pass** (issue point 2), emitting one blocker per verification reason naming every offending commit. `unsigned`, `no_user`, and `unknown_key` carry distinct remedies — `no_user` states the signature IS valid and the author email is unlinked (#2162's recurring product; `--reset-author`, not keys). Fetch failure holds with its own "could not be read" blocker — fail closed, never a fabricated reason. Unrecognized reasons are reported verbatim. - The generic `mergeStateStatus` enumeration now names signatures (issue point 1, the one-line honesty fix). - `requiredSignatures` `{required, checked, unverified}` joins the JSON report. - CHANGELOG 0.51.17 + plugin.json bump (assumes #2312 = 0.51.15 and #2315 = 0.51.16 land first; re-resolved against `main` immediately before merge). ## Test proof (both directions) - With the fix: full babysit suite `python -m unittest discover -s tests` — **628 tests, OK** (re-run post-merge-forward at `cfbc5257`; 626 before the two new fetcher cases); `ruff check` clean. - Against `main`'s `babysit_merge.py` + `babysit_gh.py` (new tests + old modules in an isolated scratch copy): **11 failures/errors** — all 9 `RequiredSignaturesEnforcement` cases and both `FetchPullRequestCommitsTests` cases; suite exit FAILED. New tests pin: each reason's distinct message text (assertion bodies, not names), the distinct-blockers property under mixed reasons, the no-rule-makes-no-commit-read invariant, all-verified-is-ready, fail-closed fetch failure, verbatim unrecognized reasons, the generic-line honesty fix, and the fetcher's endpoint/pagination/projection including the missing-verification branch. Draft until an independent verifier verdict is posted here, per the batch rule. ## Related - #2312, #2315 — sibling batch PRs whose 0.51.15/0.51.16 this PR's 0.51.17 numbers above; re-resolved against `main` immediately before merge - #2162 — the harness bug that keeps producing the `no_user` state this PR names - #631 — unregistered signing keys producing `unknown_key` the same way - #2171 — the wrapper's previous rules-computed-but-not-acted-on fix, same file, same class 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Related - No linked issue beyond the closing keyword above. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
No linked issue
Summary
babysit_merge.branch_rulesreads the right endpoint —repos/{repo}/rules/branches/{branch}— but folds it as if each rule type appeared at most once. That endpoint returns one rule of a given type per ruleset governing the branch, and the fold is a plain assignment inside the loop, so each ruleset overwrote the previous one and only the last survived.Measured live on this repository.
mainis governed by two rulesets carrying required contexts, both org-sourced:pr-title / pr-title,do-not-merge / do-not-merge,ci-statussecurity-review / security-review19388547 is returned last, so the helper reported
effectiveRules.requiredContextsas only["security-review / security-review"]— three of four required contexts silently dropped. The single-rule assumption held under classic branch protection, which has exactly one such rule. It does not hold under rulesets.Impact: a reporting and defence-in-depth defect, not a merge-safety hole. The gate refuses independently on
mergeStateStatus not in READY_MERGE_STATES({CLEAN, HAS_HOOKS}), and GitHub integrates required checks into that field — liveMergeStateStatusintrospection givesCLEAN: "Mergeable and passing commit status",UNSTABLE: "Mergeable with non-passing commit status",BLOCKED: "The merge is blocked"— so a failing required context cannot present asCLEAN/HAS_HOOKS. The absent-context case is derived from required-status-check semantics, not observed: every required context runs on every PR here, so there was no live PR to reproduce it against. Unconditionalif failing:/if pending:blockers built from the whole rollup cover the rest. What the bug cost is the explanation:effectiveRulesand therequired checks not satisfiedblocker both under-reported, so an operator could not see which contexts actually govern.One safety-adjacent consequence, in the over-holding direction.
base_is_unprotected = not required_reviews and not required_context_list, and this repo'spull_requestrule setsrequired_approving_review_count: 0, so the flag hangs entirely onrequiredContextsbeing empty. Under the bug that meant "the last status-checks rule is empty"; fixed, it means "all of them are". "All empty" is a subset of "last empty", and both consumers of the flag only ever add blockers — so the bug produced a false hold on a superset of cases and never retired one. Latent here, since neither ruleset carries an empty context list. It is not a fail-open.Fix
Commit 1 —
required_status_checks.sorted(). Deduped because two rulesets may legitimately require the same context; sorted so the reported set is stable regardless of the order the API returns rulesets in.contextare dropped rather than carried. Previously a missing key produced aNonethat reached the reconciliation loop and surfaced as a literal"None"required context; it would also crash the new sort. This is a visible change in the helper's output.base_is_unprotectedneeds no code change and is confirm-safe once the union is correct: the union is empty only when no ruleset requires anything, which is exactly what the flag means.Commit 2 —
pull_request. The same assign-in-loop shape sat three lines below, in the same function. Not observed misreporting — exactly onepull_requestrule (ruleset 17988999) governs the branch today — but nothing prevents a second, and a ruleset requiring 2 approvals returned before one requiring 0 would have reported 0.requiredApprovingReviewsnow takes themax,requireThreadResolutiontheOR.That fold direction is deliberately argued from safety, not from GitHub's internal composition rule, which this change does not claim to know: max/OR can only ever over-report, which holds a PR for a human, where last-wins can under-report and release one.
This one could lose a blocker outright, not merely under-report: a trailing
pull_requestrule withrequired_approving_review_count: 0erased an earlier ruleset's requirement and dropped theneeds N approving review(s)blocker. Keep that distinct from thebase_is_unprotectedconsequence above, which runs the other way (over-hold).A malformed-but-present count reads as one review, never zero — reading it as zero would be the single fail-open step in a fold whose whole argument is that it can only over-report.
Severity split, kept separate on purpose:
requiredApprovingReviews— a fail-closed behaviour change, not currently firing. It feeds bothbase_is_unprotectedand theneeds N approving review(s)blocker.requireThreadResolution,requireSignatures,requireLinearHistory— report-only. Set into the summary, never consumed as a blocker; the gate holds on unresolved threads unconditionally viaif threads:. They do not borrow the first item's severity.Version bumped
0.51.5→0.51.6with a matching CHANGELOG entry, following the plugin's convention — every comparablefix(source-control)commit in recent history (cf743d61,ac27ea5a,30be2a0b,e6ee72ef) bumped the manifest version.Verification
New module
tests/test_babysit_merge_branch_rules.py(7 tests), each run against the fixed code and against the unfixed file:test_contexts_from_every_ruleset_survive['security-review / security-review'] != ['ci-status', 'do-not-merge / do-not-merge', 'pr-title / pr-title', 'security-review / security-review']test_a_context_required_by_two_rulesets_is_reported_once['ci-status'] != ['ci-status', 'pr-title / pr-title']test_a_context_less_entry_is_dropped[None] != []test_empty_trailing_rule_leaves_the_base_protectedTrue is not false(baseUnprotectedflipped)test_the_strictest_approval_count_wins0 != 2test_thread_resolution_required_by_any_ruleset_survivesFalse is not truetest_no_context_anywhere_still_reports_an_unprotected_baseSix regress. The seventh passes both ways by design — it is the over-correction guard, pinning that a genuinely context-less base still reports unprotected. It is labelled as such in its class docstring so nobody counts it among the regression tests.
test_empty_trailing_rule_leaves_the_base_protectedasserts onevaluate()'sbaseUnprotectedand blocker list, not onbranch_rulesalone, and its fixture setsrequired_approving_review_count: 0— with a non-zero count the flag would beFalseagainst the unfixed code too and the test would prove nothing.End-to-end against a live CLEAN PR. The fix feeds four contexts into the reconciliation matcher where one went before, so a context that failed to match its rollup entry would convert a silent under-report into a spurious blocker. Ran
evaluate()against #2150 (CLEAN, all four contexts green):Suite.
bash plugins/source-control/skills/babysit-prs/scripts/engine.test.shexits 0 — 612 tests OK,ruff(CI pin) clean, guarded-wrapper behaviour all PASS. No shell files changed, so no shellcheck surface.Changelog parity.
--checkand--check-orderpass.--check-bump origin/mainpassed 8/8 consecutive local runs on GNU Awk 5.4.0. Recording that as an observation, not a health claim: the gate is reported to have a SIGPIPE race after #2154, and local green does not establish CI green.Not verified — recorded, not claimed. A ruleset carrying bypass actors is the one shape where GitHub could plausibly report
CLEANto a bypassing identity while a required context is unmet; there the unmet-required blocker would be the only defence, which raises the severity of the under-report. Untestable here — every ruleset carriesbypass_actors: []. Likewise the absent-required-context case above. Neither refutes the characterisation; both are open.Two things worth knowing about the union. Adding
security-review / security-reviewdoes not mint a false blocker when that check skips:babysit_checks.pytreatsNEUTRAL/SKIPPED/SUCCESSas success states, so a name-stable skipped check still satisfies. And the deliberately loose context matcher now processes four contexts where it processed one — this amplifies pre-existing false-match exposure rather than introducing it, which is exactly what the liveevaluate()check above is there to catch.Sibling scripts.
babysit_resolve_thread.pyreads no branch rules, and a repo-wide search forrules/branches/required_status_checks/effectiveRulesfinds no other fold and no other consumer —babysit_merge.pyis the only one.plugins/source-control/skills/setup/SKILL.md:121documents the same endpoint to operators but instructs them to read the whole payload and flag zero-reviews-and-zero-contexts repos, so it carries no one-rule-wins assumption and needs no change.Related
Refs #2130, #2135 — reported as observed there.