diff --git a/services/github/branches/get_required_status_checks.py b/services/github/branches/get_required_status_checks.py new file mode 100644 index 000000000..c4a2fd7df --- /dev/null +++ b/services/github/branches/get_required_status_checks.py @@ -0,0 +1,45 @@ +# Standard imports +from typing import cast + +# Third party imports +import requests + +# Local imports +from config import GITHUB_API_URL, TIMEOUT +from services.github.types.branch_protection import BranchProtection +from services.github.utils.create_headers import create_headers +from utils.error.handle_exceptions import handle_exceptions + + +@handle_exceptions(default_return_value=None, raise_on_error=False) +def get_required_status_checks(owner: str, repo: str, branch: str, token: str): + """https://docs.github.com/en/rest/branches/branch-protection#get-branch-protection""" + url = f"{GITHUB_API_URL}/repos/{owner}/{repo}/branches/{branch}/protection" + headers = create_headers(token=token) + response = requests.get(url=url, headers=headers, timeout=TIMEOUT) + + # NOTE: 403 happens when GitHub App lacks "Administration: Read" permission + if response.status_code == 403: + print(f"No permission to read branch protection for {owner}/{repo}/{branch}") + return None + + if response.status_code == 404: + print(f"No branch protection configured for {owner}/{repo}/{branch}") + return None + + response.raise_for_status() + protection = cast(BranchProtection, response.json()) + required_status_checks = protection.get("required_status_checks") + + if not required_status_checks: + print( + f"Branch protection exists but no required status checks configured for {owner}/{repo}/{branch}" + ) + return None + + contexts = set(required_status_checks.get("contexts", [])) + checks = { + check.get("context") for check in required_status_checks.get("checks", []) + } + + return list(contexts | checks) diff --git a/services/github/branches/test_get_required_status_checks.py b/services/github/branches/test_get_required_status_checks.py new file mode 100644 index 000000000..ebb06b133 --- /dev/null +++ b/services/github/branches/test_get_required_status_checks.py @@ -0,0 +1,211 @@ +# Standard imports +from unittest.mock import MagicMock, patch + +# Third party imports +import pytest +import requests + +# Local imports +from services.github.branches.get_required_status_checks import ( + get_required_status_checks, +) + + +@pytest.fixture +def mock_branch_protection_response(): + return { + "required_status_checks": { + "strict": True, + "contexts": ["ci/circleci: test", "Codecov"], + "checks": [ + {"context": "CircleCI Checks", "app_id": 12345}, + {"context": "Aikido Security", "app_id": 67890}, + ], + } + } + + +def test_get_required_status_checks_success( + test_owner, test_repo, test_token, mock_branch_protection_response +): + with patch( + "services.github.branches.get_required_status_checks.requests.get" + ) as mock_get, patch( + "services.github.branches.get_required_status_checks.create_headers" + ) as mock_headers: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_branch_protection_response + mock_get.return_value = mock_response + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_required_status_checks( + owner=test_owner, repo=test_repo, branch="main", token=test_token + ) + + mock_get.assert_called_once_with( + url=f"https://api.github.com/repos/{test_owner}/{test_repo}/branches/main/protection", + headers={"Authorization": "Bearer test_token"}, + timeout=120, + ) + assert result is not None + assert set(result) == { + "ci/circleci: test", + "Codecov", + "CircleCI Checks", + "Aikido Security", + } + + +def test_get_required_status_checks_403_no_permission( + test_owner, test_repo, test_token, capsys +): + with patch( + "services.github.branches.get_required_status_checks.requests.get" + ) as mock_get, patch( + "services.github.branches.get_required_status_checks.create_headers" + ) as mock_headers: + mock_response = MagicMock() + mock_response.status_code = 403 + mock_get.return_value = mock_response + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_required_status_checks( + owner=test_owner, repo=test_repo, branch="main", token=test_token + ) + + assert result is None + captured = capsys.readouterr() + assert "No permission to read branch protection" in captured.out + + +def test_get_required_status_checks_404_no_protection( + test_owner, test_repo, test_token, capsys +): + with patch( + "services.github.branches.get_required_status_checks.requests.get" + ) as mock_get, patch( + "services.github.branches.get_required_status_checks.create_headers" + ) as mock_headers: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_required_status_checks( + owner=test_owner, repo=test_repo, branch="main", token=test_token + ) + + assert result is None + captured = capsys.readouterr() + assert "No branch protection configured" in captured.out + + +def test_get_required_status_checks_no_required_checks( + test_owner, test_repo, test_token, capsys +): + with patch( + "services.github.branches.get_required_status_checks.requests.get" + ) as mock_get, patch( + "services.github.branches.get_required_status_checks.create_headers" + ) as mock_headers: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"required_status_checks": None} + mock_get.return_value = mock_response + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_required_status_checks( + owner=test_owner, repo=test_repo, branch="main", token=test_token + ) + + assert result is None + captured = capsys.readouterr() + assert "no required status checks configured" in captured.out + + +def test_get_required_status_checks_only_contexts(test_owner, test_repo, test_token): + with patch( + "services.github.branches.get_required_status_checks.requests.get" + ) as mock_get, patch( + "services.github.branches.get_required_status_checks.create_headers" + ) as mock_headers: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "required_status_checks": { + "strict": True, + "contexts": ["ci/circleci: test"], + "checks": [], + } + } + mock_get.return_value = mock_response + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_required_status_checks( + owner=test_owner, repo=test_repo, branch="main", token=test_token + ) + + assert result == ["ci/circleci: test"] + + +def test_get_required_status_checks_only_checks(test_owner, test_repo, test_token): + with patch( + "services.github.branches.get_required_status_checks.requests.get" + ) as mock_get, patch( + "services.github.branches.get_required_status_checks.create_headers" + ) as mock_headers: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "required_status_checks": { + "strict": False, + "contexts": [], + "checks": [{"context": "CircleCI Checks", "app_id": 12345}], + } + } + mock_get.return_value = mock_response + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_required_status_checks( + owner=test_owner, repo=test_repo, branch="main", token=test_token + ) + + assert result == ["CircleCI Checks"] + + +def test_get_required_status_checks_http_error_500(test_owner, test_repo, test_token): + with patch( + "services.github.branches.get_required_status_checks.requests.get" + ) as mock_get, patch( + "services.github.branches.get_required_status_checks.create_headers" + ) as mock_headers: + mock_response = MagicMock() + mock_response.status_code = 500 + http_error = requests.exceptions.HTTPError("500 Server Error") + http_error.response = mock_response + mock_response.raise_for_status.side_effect = http_error + mock_get.return_value = mock_response + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_required_status_checks( + owner=test_owner, repo=test_repo, branch="main", token=test_token + ) + + assert result is None + + +def test_get_required_status_checks_network_error(test_owner, test_repo, test_token): + with patch( + "services.github.branches.get_required_status_checks.requests.get" + ) as mock_get, patch( + "services.github.branches.get_required_status_checks.create_headers" + ) as mock_headers: + mock_get.side_effect = requests.exceptions.ConnectionError("Network error") + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_required_status_checks( + owner=test_owner, repo=test_repo, branch="main", token=test_token + ) + + assert result is None diff --git a/services/github/check_suites/get_check_suites.py b/services/github/check_suites/get_check_suites.py new file mode 100644 index 000000000..d766d8572 --- /dev/null +++ b/services/github/check_suites/get_check_suites.py @@ -0,0 +1,20 @@ +# Third party imports +import requests + +# Local imports +from config import GITHUB_API_URL, TIMEOUT +from services.github.types.check_suite import CheckSuite +from services.github.utils.create_headers import create_headers +from utils.error.handle_exceptions import handle_exceptions + + +@handle_exceptions(default_return_value=None, raise_on_error=False) +def get_check_suites(owner: str, repo: str, ref: str, token: str): + """https://docs.github.com/en/rest/checks/suites#list-check-suites-for-a-git-reference""" + url = f"{GITHUB_API_URL}/repos/{owner}/{repo}/commits/{ref}/check-suites" + headers = create_headers(token=token) + response = requests.get(url=url, headers=headers, timeout=TIMEOUT) + response.raise_for_status() + data = response.json() + check_suites: list[CheckSuite] = data.get("check_suites", []) + return check_suites diff --git a/services/github/check_suites/test_get_check_suites.py b/services/github/check_suites/test_get_check_suites.py new file mode 100644 index 000000000..ace6c9f32 --- /dev/null +++ b/services/github/check_suites/test_get_check_suites.py @@ -0,0 +1,111 @@ +# Standard imports +from unittest.mock import MagicMock, patch + +# Third party imports +import pytest +import requests + +# Local imports +from services.github.check_suites.get_check_suites import get_check_suites + + +@pytest.fixture +def mock_check_suites_response(): + return { + "total_count": 2, + "check_suites": [ + { + "id": 53232653600, + "status": "completed", + "conclusion": "failure", + "app": {"name": "CircleCI Checks", "slug": "circleci-checks"}, + }, + { + "id": 53232653801, + "status": "completed", + "conclusion": "success", + "app": {"name": "Aikido PR Checks", "slug": "aikido"}, + }, + ], + } + + +def test_get_check_suites_success( + test_owner, test_repo, test_token, mock_check_suites_response +): + with patch( + "services.github.check_suites.get_check_suites.requests.get" + ) as mock_get, patch( + "services.github.check_suites.get_check_suites.create_headers" + ) as mock_headers: + mock_response = MagicMock() + mock_response.json.return_value = mock_check_suites_response + mock_get.return_value = mock_response + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_check_suites( + owner=test_owner, repo=test_repo, ref="abc123", token=test_token + ) + + mock_get.assert_called_once_with( + url=f"https://api.github.com/repos/{test_owner}/{test_repo}/commits/abc123/check-suites", + headers={"Authorization": "Bearer test_token"}, + timeout=120, + ) + mock_response.raise_for_status.assert_called_once() + assert result == mock_check_suites_response["check_suites"] + + +def test_get_check_suites_headers_creation(test_owner, test_repo): + with patch( + "services.github.check_suites.get_check_suites.requests.get" + ) as mock_get, patch( + "services.github.check_suites.get_check_suites.create_headers" + ) as mock_headers: + mock_response = MagicMock() + mock_response.json.return_value = {"check_suites": []} + mock_get.return_value = mock_response + mock_headers.return_value = {"Authorization": "Bearer custom_token"} + + get_check_suites( + owner=test_owner, repo=test_repo, ref="abc123", token="custom_token_123" + ) + + mock_headers.assert_called_once_with(token="custom_token_123") + + +def test_get_check_suites_http_error_404(test_owner, test_repo, test_token): + with patch( + "services.github.check_suites.get_check_suites.requests.get" + ) as mock_get, patch( + "services.github.check_suites.get_check_suites.create_headers" + ) as mock_headers: + mock_response = MagicMock() + mock_response.status_code = 404 + http_error = requests.exceptions.HTTPError("404 Client Error") + http_error.response = mock_response + mock_response.raise_for_status.side_effect = http_error + mock_get.return_value = mock_response + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_check_suites( + owner=test_owner, repo=test_repo, ref="nonexistent", token=test_token + ) + + assert result is None + + +def test_get_check_suites_network_error(test_owner, test_repo, test_token): + with patch( + "services.github.check_suites.get_check_suites.requests.get" + ) as mock_get, patch( + "services.github.check_suites.get_check_suites.create_headers" + ) as mock_headers: + mock_get.side_effect = requests.exceptions.ConnectionError("Network error") + mock_headers.return_value = {"Authorization": "Bearer test_token"} + + result = get_check_suites( + owner=test_owner, repo=test_repo, ref="abc123", token=test_token + ) + + assert result is None diff --git a/services/github/types/branch_protection.py b/services/github/types/branch_protection.py new file mode 100644 index 000000000..226c4d828 --- /dev/null +++ b/services/github/types/branch_protection.py @@ -0,0 +1,16 @@ +from typing import TypedDict + + +class StatusCheckContext(TypedDict): + context: str # "CircleCI Checks" + app_id: int # 12345 + + +class RequiredStatusChecks(TypedDict): + strict: bool # True + contexts: list[str] # ["ci/circleci: test", "Codecov"] + checks: list[StatusCheckContext] + + +class BranchProtection(TypedDict): + required_status_checks: RequiredStatusChecks diff --git a/services/webhook/successful_check_suite_handler.py b/services/webhook/successful_check_suite_handler.py index a1199ed54..d3bc5b56d 100644 --- a/services/webhook/successful_check_suite_handler.py +++ b/services/webhook/successful_check_suite_handler.py @@ -2,6 +2,10 @@ from config import PRODUCT_ID from constants.urls import DOC_URLS +from services.github.branches.get_required_status_checks import ( + get_required_status_checks, +) +from services.github.check_suites.get_check_suites import get_check_suites from services.github.comments.create_comment import create_comment from services.github.commits.check_commit_has_skip_ci import check_commit_has_skip_ci from services.github.commits.create_empty_commit import create_empty_commit @@ -43,6 +47,58 @@ def handle_successful_check_suite(payload: CheckSuiteCompletedPayload): owner_name = repo["owner"]["login"] repo_name = repo["name"] + # Get installation token + installation_id = payload["installation"]["id"] + token = get_installation_access_token(installation_id=installation_id) + if not token: + msg = f"Failed to get installation token for {owner_name}/{repo_name} PR #{pr_number}, installation_id={installation_id}" + print(msg) + raise RuntimeError(msg) + + head_sha = check_suite["head_sha"] + base_branch = pull_request["base"]["ref"] + + all_suites = get_check_suites( + owner=owner_name, repo=repo_name, ref=head_sha, token=token + ) + if not all_suites: + msg = f"Failed to fetch check suites for {owner_name}/{repo_name} PR #{pr_number}@{head_sha}" + print(msg) + raise RuntimeError(msg) + + required_checks = get_required_status_checks( + owner=owner_name, repo=repo_name, branch=base_branch, token=token + ) + + if required_checks: + print(f"Using required status checks: {required_checks}") + for suite in all_suites: + app_name = suite["app"]["name"] + status = suite["status"] + if app_name in required_checks and status != "completed": + print(f"Required check '{app_name}' not completed: status={status}") + return + print("All required checks completed") + else: + print("No required checks, using fallback: wait for all non-queued suites") + + active_suites = [s for s in all_suites if s["status"] != "queued"] + if not active_suites: + queued_suite_names = [s["app"]["name"] for s in all_suites] + msg = f"No active check suites for {owner_name}/{repo_name} PR #{pr_number}@{head_sha} (all queued: {queued_suite_names})" + print(msg) + raise RuntimeError(msg) + + for suite in active_suites: + app_name = suite["app"]["name"] + status = suite["status"] + if status != "completed": + print(f"Check suite '{app_name}' not completed: status={status}") + return + print( + f"All {len(active_suites)} active check suites completed (ignored {len(all_suites) - len(active_suites)} queued)" + ) + # Get the most recent usage record for this PR result = ( supabase.table("usage") @@ -72,16 +128,7 @@ def handle_successful_check_suite(payload: CheckSuiteCompletedPayload): print(msg) return - # Get installation token - installation_id = payload["installation"]["id"] - token = get_installation_access_token(installation_id=installation_id) - if not token: - msg = f"Failed to get installation token for installation_id={installation_id}" - print(msg) - return - # Check if last commit has [skip ci] - if so, tests never ran, trigger them - head_sha = check_suite["head_sha"] head_branch = check_suite["head_branch"] if check_commit_has_skip_ci( owner=owner_name, repo=repo_name, commit_sha=head_sha, token=token diff --git a/services/webhook/test_successful_check_suite_handler.py b/services/webhook/test_successful_check_suite_handler.py index 9a36f4092..14f4bb037 100644 --- a/services/webhook/test_successful_check_suite_handler.py +++ b/services/webhook/test_successful_check_suite_handler.py @@ -14,8 +14,12 @@ def load_payload(filename: str): return json.load(f) +@patch("services.webhook.successful_check_suite_handler.get_required_status_checks") +@patch("services.webhook.successful_check_suite_handler.get_check_suites") @patch("services.webhook.successful_check_suite_handler.get_installation_access_token") -def test_handle_successful_check_suite_with_pr(mock_get_token): +def test_handle_successful_check_suite_with_pr( + mock_get_token, mock_get_check_suites, mock_get_required_checks +): payload = load_payload("completed_by_circleci.json") # Modify to use PRODUCT_ID branch pattern payload["check_suite"]["pull_requests"][0]["head"][ @@ -24,6 +28,10 @@ def test_handle_successful_check_suite_with_pr(mock_get_token): payload["check_suite"]["head_branch"] = f"{PRODUCT_ID}/issue-2-test" mock_get_token.return_value = "test-token" + mock_get_check_suites.return_value = [ + {"app": {"name": "CircleCI Checks"}, "status": "completed"} + ] + mock_get_required_checks.return_value = ["CircleCI Checks"] with patch( "services.webhook.successful_check_suite_handler.supabase" @@ -95,6 +103,8 @@ def test_handle_successful_check_suite_without_pr(): mock_supabase.table.assert_not_called() +@patch("services.webhook.successful_check_suite_handler.get_required_status_checks") +@patch("services.webhook.successful_check_suite_handler.get_check_suites") @patch("services.webhook.successful_check_suite_handler.merge_pull_request") @patch("services.webhook.successful_check_suite_handler.get_pull_request_files") @patch("services.webhook.successful_check_suite_handler.check_commit_has_skip_ci") @@ -108,6 +118,8 @@ def test_handle_successful_check_suite_no_usage_record_found( mock_check_skip_ci, mock_get_files, mock_merge_pr, # pylint: disable=unused-argument + mock_get_check_suites, + mock_get_required_checks, ): payload = load_payload("completed_failed_github_actions.json") payload["check_suite"]["pull_requests"][0]["head"][ @@ -117,6 +129,10 @@ def test_handle_successful_check_suite_no_usage_record_found( mock_get_repo_features.return_value = {"auto_merge": True, "merge_method": "merge"} mock_get_token.return_value = "test-token" + mock_get_check_suites.return_value = [ + {"app": {"name": "GitHub Actions"}, "status": "completed"} + ] + mock_get_required_checks.return_value = ["GitHub Actions"] mock_get_pr.return_value = {"mergeable_state": "clean"} mock_check_skip_ci.return_value = False mock_get_files.return_value = [] @@ -175,6 +191,8 @@ def test_handle_successful_check_suite_with_exception(mock_get_token): assert result is None +@patch("services.webhook.successful_check_suite_handler.get_required_status_checks") +@patch("services.webhook.successful_check_suite_handler.get_check_suites") @patch("services.webhook.successful_check_suite_handler.check_commit_has_skip_ci") @patch("services.webhook.successful_check_suite_handler.merge_pull_request") @patch("services.webhook.successful_check_suite_handler.get_pull_request_files") @@ -190,6 +208,8 @@ def test_auto_merge_success( mock_get_files, mock_merge_pr, mock_check_skip_ci, + mock_get_check_suites, + mock_get_required_checks, ): payload = load_payload("completed_by_circleci.json") payload["check_suite"]["pull_requests"][0]["head"][ @@ -203,6 +223,10 @@ def test_auto_merge_success( "auto_merge_only_test_files": False, } mock_get_token.return_value = "test-token" + mock_get_check_suites.return_value = [ + {"app": {"name": "CircleCI Checks"}, "status": "completed"} + ] + mock_get_required_checks.return_value = ["CircleCI Checks"] mock_get_pr.return_value = {"mergeable_state": "clean"} mock_check_skip_ci.return_value = False mock_get_files.return_value = [ @@ -289,6 +313,8 @@ def test_auto_merge_disabled( handle_successful_check_suite(cast(CheckSuiteCompletedPayload, payload)) +@patch("services.webhook.successful_check_suite_handler.get_required_status_checks") +@patch("services.webhook.successful_check_suite_handler.get_check_suites") @patch("services.webhook.successful_check_suite_handler.check_commit_has_skip_ci") @patch("services.webhook.successful_check_suite_handler.merge_pull_request") @patch("services.webhook.successful_check_suite_handler.get_pull_request_files") @@ -304,6 +330,8 @@ def test_auto_merge_multiple_test_files_changed( mock_get_files, mock_merge_pr, mock_check_skip_ci, + mock_get_check_suites, + mock_get_required_checks, ): payload = load_payload("completed_by_circleci.json") payload["check_suite"]["pull_requests"][0]["head"][ @@ -317,6 +345,10 @@ def test_auto_merge_multiple_test_files_changed( "auto_merge_only_test_files": True, } mock_get_token.return_value = "test-token" + mock_get_check_suites.return_value = [ + {"app": {"name": "CircleCI Checks"}, "status": "completed"} + ] + mock_get_required_checks.return_value = ["CircleCI Checks"] mock_get_pr.return_value = {"mergeable_state": "clean"} mock_check_skip_ci.return_value = False mock_get_files.return_value = [ @@ -433,6 +465,8 @@ def is_test_side_effect(filename): mock_merge_pr.assert_not_called() +@patch("services.webhook.successful_check_suite_handler.get_required_status_checks") +@patch("services.webhook.successful_check_suite_handler.get_check_suites") @patch("services.webhook.successful_check_suite_handler.check_commit_has_skip_ci") @patch("services.webhook.successful_check_suite_handler.merge_pull_request") @patch("services.webhook.successful_check_suite_handler.get_pull_request_files") @@ -448,6 +482,8 @@ def test_auto_merge_with_non_test_files_allowed( mock_get_files, mock_merge_pr, mock_check_skip_ci, + mock_get_check_suites, + mock_get_required_checks, ): payload = load_payload("completed_by_circleci.json") payload["check_suite"]["pull_requests"][0]["head"][ @@ -461,6 +497,10 @@ def test_auto_merge_with_non_test_files_allowed( "auto_merge_only_test_files": False, } mock_get_token.return_value = "test-token" + mock_get_check_suites.return_value = [ + {"app": {"name": "CircleCI Checks"}, "status": "completed"} + ] + mock_get_required_checks.return_value = ["CircleCI Checks"] mock_get_pr.return_value = {"mergeable_state": "clean"} mock_check_skip_ci.return_value = False mock_get_files.return_value = [ @@ -556,6 +596,8 @@ def test_auto_merge_skipped_for_human_pr( mock_merge_pr.assert_not_called() +@patch("services.webhook.successful_check_suite_handler.get_required_status_checks") +@patch("services.webhook.successful_check_suite_handler.get_check_suites") @patch("services.webhook.successful_check_suite_handler.check_commit_has_skip_ci") @patch("services.webhook.successful_check_suite_handler.merge_pull_request") @patch("services.webhook.successful_check_suite_handler.get_pull_request_files") @@ -571,6 +613,8 @@ def test_auto_merge_with_blocked_state( mock_get_files, mock_merge_pr, mock_check_skip_ci, + mock_get_check_suites, + mock_get_required_checks, ): payload = load_payload("completed_by_circleci.json") payload["check_suite"]["pull_requests"][0]["head"][ @@ -584,6 +628,10 @@ def test_auto_merge_with_blocked_state( "auto_merge_only_test_files": False, } mock_get_token.return_value = "test-token" + mock_get_check_suites.return_value = [ + {"app": {"name": "CircleCI Checks"}, "status": "completed"} + ] + mock_get_required_checks.return_value = ["CircleCI Checks"] mock_get_pr.return_value = {"mergeable_state": "blocked"} mock_check_skip_ci.return_value = False mock_get_files.return_value = [ @@ -627,6 +675,8 @@ def test_auto_merge_with_blocked_state( ) +@patch("services.webhook.successful_check_suite_handler.get_required_status_checks") +@patch("services.webhook.successful_check_suite_handler.get_check_suites") @patch("services.webhook.successful_check_suite_handler.create_empty_commit") @patch("services.webhook.successful_check_suite_handler.create_comment") @patch("services.webhook.successful_check_suite_handler.check_commit_has_skip_ci") @@ -640,6 +690,8 @@ def test_auto_merge_blocked_skip_ci( mock_check_skip_ci, mock_create_comment, mock_create_empty_commit, + mock_get_check_suites, + mock_get_required_checks, ): payload = load_payload("completed_failed_github_actions.json") payload["check_suite"]["pull_requests"][0]["head"][ @@ -649,6 +701,10 @@ def test_auto_merge_blocked_skip_ci( mock_get_repo_features.return_value = {"auto_merge": True} mock_get_token.return_value = "test-token" + mock_get_check_suites.return_value = [ + {"app": {"name": "GitHub Actions"}, "status": "completed"} + ] + mock_get_required_checks.return_value = ["GitHub Actions"] mock_get_pr.return_value = {"mergeable_state": "clean"} mock_check_skip_ci.return_value = True