From fea268aae3a384c09ce95a67aae8dc1f021e20eb Mon Sep 17 00:00:00 2001 From: Amri Toufali Date: Tue, 4 Aug 2026 13:38:32 -0700 Subject: [PATCH 1/3] feat(github_utils): add blender_approved_head + merge_pr helpers (#117) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/github_utils.py | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/scripts/github_utils.py b/scripts/github_utils.py index d6940dd..cad2fc7 100644 --- a/scripts/github_utils.py +++ b/scripts/github_utils.py @@ -144,3 +144,53 @@ def enable_auto_merge(pr: PullRequest) -> str | None: if errors: return "; ".join(e.get("message", str(e)) for e in errors) return None + + +def blender_approved_head(pr: PullRequest) -> bool: + """True if BLEnder already has an APPROVED review on the current head SHA. + + Used to avoid re-submitting an identical approval every sweep when a + later step (enabling auto-merge) keeps failing — otherwise a PR can + collect hundreds of duplicate approvals and never converge. + """ + head = pr.head.sha + for review in pr.get_reviews(): + if ( + review.state == "APPROVED" + and review.user.login == BOT_LOGIN + and review.commit_id == head + ): + return True + return False + + +def merge_pr(pr: PullRequest) -> str | None: + """Merge a PR directly via the GraphQL API. + + Returns None on success, or an error message string on failure. + + Used when auto-merge cannot be armed because the PR is already + mergeable: repos with no required status checks reach a "clean" + state immediately, so enablePullRequestAutoMerge is refused and the + PR must be merged directly. Uses a repo-allowed merge method. + """ + method = _allowed_merge_method(pr) + query = """ + mutation MergePR($prId: ID!, $method: PullRequestMergeMethod!) { + mergePullRequest(input: {pullRequestId: $prId, mergeMethod: $method}) { + pullRequest { merged } + } + } + """ + _, data = pr._requester.requestJsonAndCheck( + "POST", + "/graphql", + input={ + "query": query, + "variables": {"prId": pr.node_id, "method": method}, + }, + ) + errors = data.get("errors") + if errors: + return "; ".join(e.get("message", str(e)) for e in errors) + return None From 8f193dc9bd40c7e01ce6c00748f6f647505d8d3c Mon Sep 17 00:00:00 2001 From: Amri Toufali Date: Tue, 4 Aug 2026 13:38:35 -0700 Subject: [PATCH 2/3] fix(automerge): idempotent approve + direct-merge when clean (#117) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/automerge_dependabot.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/automerge_dependabot.py b/scripts/automerge_dependabot.py index 8bca304..dd69c3b 100755 --- a/scripts/automerge_dependabot.py +++ b/scripts/automerge_dependabot.py @@ -35,16 +35,20 @@ try: from scripts.github_utils import ( BOT_LOGIN, + blender_approved_head, enable_auto_merge, has_blender_verdict, has_codeowner_approval, + merge_pr, ) except ModuleNotFoundError: from github_utils import ( BOT_LOGIN, + blender_approved_head, enable_auto_merge, has_blender_verdict, has_codeowner_approval, + merge_pr, ) from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.version import InvalidVersion, Version @@ -594,17 +598,34 @@ def gate_advisories(gh: Github, meta: PRMetadata) -> None: def approve_and_merge(pr: PullRequest, compat_score: int | None) -> None: - """Approve the PR and enable auto-merge.""" + """Approve the PR, then enable auto-merge (or merge directly if clean).""" compat_display = f"{compat_score}%" if compat_score is not None else "unknown" review_body = ( "BLEnder auto-merge: all safety gates passed " f"(CI green, patch/minor, compat {compat_display}, " "no advisories)." ) - pr.create_review(event="APPROVE", body=review_body) + # Idempotency (#117): don't re-approve the same commit every sweep. A + # persistent enable-auto-merge failure otherwise stacks endless approvals. + if blender_approved_head(pr): + print(" BLEnder already approved this commit; not re-approving.") + else: + pr.create_review(event="APPROVE", body=review_body) + error = enable_auto_merge(pr) - if error: - raise SkipPR(f"could not enable auto-merge: {error}") + if not error: + return + # Auto-merge can't be armed when the PR is already mergeable — repos with + # no required status checks reach a "clean" state immediately, so the queue + # API refuses. Merge directly in that case (#117). + if "clean status" in error.lower(): + print(" PR already mergeable; merging directly.") + merge_error = merge_pr(pr) + if merge_error: + raise SkipPR(f"direct merge failed: {merge_error}") + print(" Merged.") + return + raise SkipPR(f"could not enable auto-merge: {error}") # --- Main --- From c28154336b3333f24ec2e085d3eab435532c7612 Mon Sep 17 00:00:00 2001 From: Amri Toufali Date: Tue, 4 Aug 2026 13:48:05 -0700 Subject: [PATCH 3/3] test(automerge): idempotent approve + direct-merge when clean (#117) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/scripts/test_automerge_dependabot.py | 47 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/scripts/test_automerge_dependabot.py b/tests/scripts/test_automerge_dependabot.py index 93035d7..2ec3266 100644 --- a/tests/scripts/test_automerge_dependabot.py +++ b/tests/scripts/test_automerge_dependabot.py @@ -671,7 +671,8 @@ def test_defaults_to_merge_when_none_allowed(self): @patch("scripts.automerge_dependabot.enable_auto_merge") def test_approve_and_merge_raises_skippr_on_enable_error(mock_enable): - mock_enable.return_value = "Pull request is in clean status" + # A persistent, non-"clean status" error should surface as SkipPR. + mock_enable.return_value = "User is not authorized for this protected branch" pr = MagicMock() with pytest.raises(SkipPR, match="could not enable auto-merge"): approve_and_merge(pr, compat_score=90) @@ -684,3 +685,47 @@ def test_approve_and_merge_succeeds_when_enable_returns_none(mock_enable): pr = MagicMock() approve_and_merge(pr, compat_score=90) pr.create_review.assert_called_once() + + +# --- #117: idempotent approve + direct-merge when already clean --- + + +@patch("scripts.automerge_dependabot.enable_auto_merge") +@patch("scripts.automerge_dependabot.blender_approved_head") +def test_approve_and_merge_skips_reapproval_when_already_approved( + mock_approved, mock_enable +): + mock_approved.return_value = True + mock_enable.return_value = None + pr = MagicMock() + approve_and_merge(pr, compat_score=90) + pr.create_review.assert_not_called() + + +@patch("scripts.automerge_dependabot.merge_pr") +@patch("scripts.automerge_dependabot.enable_auto_merge") +@patch("scripts.automerge_dependabot.blender_approved_head") +def test_approve_and_merge_direct_merges_when_clean( + mock_approved, mock_enable, mock_merge +): + mock_approved.return_value = False + mock_enable.return_value = "Pull request is in clean status" + mock_merge.return_value = None + pr = MagicMock() + approve_and_merge(pr, compat_score=90) + pr.create_review.assert_called_once() + mock_merge.assert_called_once_with(pr) + + +@patch("scripts.automerge_dependabot.merge_pr") +@patch("scripts.automerge_dependabot.enable_auto_merge") +@patch("scripts.automerge_dependabot.blender_approved_head") +def test_approve_and_merge_raises_when_direct_merge_fails( + mock_approved, mock_enable, mock_merge +): + mock_approved.return_value = False + mock_enable.return_value = "Pull request is in clean status" + mock_merge.return_value = "merge conflict" + pr = MagicMock() + with pytest.raises(SkipPR, match="direct merge failed"): + approve_and_merge(pr, compat_score=90)