Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions scripts/automerge_dependabot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ---
Expand Down
50 changes: 50 additions & 0 deletions scripts/github_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 46 additions & 1 deletion tests/scripts/test_automerge_dependabot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)