diff --git a/example.config.yaml b/example.config.yaml index 0364ee56..fe6e9c2a 100644 --- a/example.config.yaml +++ b/example.config.yaml @@ -30,6 +30,14 @@ jira: user-mapping: : # if github user is not the same as jira +branch_protection: + strict: True + require_code_owner_reviews: True + dismiss_stale_reviews: False + required_approving_review_count: 1 + required_linear_history: True + required_conversation_resolution: True + repositories: my-repository: name: my-org/my-repository @@ -94,3 +102,10 @@ repositories: : # if github user is not the same as jira conventional-title: "ci,docs,feat,fix,refactor,test,release" # Check PR title start with any of these words + : + branch_protection: + strict: True + require_code_owner_reviews: True + dismiss_stale_reviews: False + required_approving_review_count: 1 + required_linear_history: True + required_conversation_resolution: True diff --git a/webhook_server_container/config/schema.yaml b/webhook_server_container/config/schema.yaml index b27e042f..80373275 100644 --- a/webhook_server_container/config/schema.yaml +++ b/webhook_server_container/config/schema.yaml @@ -51,6 +51,21 @@ properties: token: type: string format: password + branch_protection: + type: object + properties: + strict: + type: boolean + require_code_owner_reviews: + type: boolean + dismiss_stale_reviews: + type: boolean + required_approving_review_count: + type: integer + required_linear_history: + type: boolean + required_conversation_resolution: + type: boolean repositories: type: object properties: @@ -141,6 +156,21 @@ properties: type: array items: type: string + branch_protection: + type: object + properties: + strict: + type: boolean + require_code_owner_reviews: + type: boolean + dismiss_stale_reviews: + type: boolean + required_approving_review_count: + type: integer + required_linear_history: + type: boolean + required_conversation_resolution: + type: boolean can-be-merged-required-labels: type: array items: diff --git a/webhook_server_container/tests/test_branch_protection.py b/webhook_server_container/tests/test_branch_protection.py new file mode 100644 index 00000000..71160038 --- /dev/null +++ b/webhook_server_container/tests/test_branch_protection.py @@ -0,0 +1,95 @@ +import os +import pytest +from webhook_server_container.libs.config import Config +from webhook_server_container.utils.github_repository_settings import ( + get_repo_branch_protection_rules, + DEFAULT_BRANCH_PROTECTION, +) + + +@pytest.fixture() +def branch_protection_rules(request, mocker): + os.environ["WEBHOOK_SERVER_DATA_DIR"] = "webhook_server_container/tests/manifests" + config = Config() + repo_name = "test-repo" + data = config.data + data.setdefault("branch_protection", request.param.get("global", {})) + data["repositories"][repo_name].setdefault("branch_protection", request.param.get("repo", {})) + mocker.patch( + "webhook_server_container.libs.config.Config.data", new_callable=mocker.PropertyMock, return_value=data + ) + return get_repo_branch_protection_rules(config_data=config.data, repo_data=config.data["repositories"][repo_name])[ + "branch_protection" + ] + + +@pytest.mark.parametrize( + "branch_protection_rules, expected", + [ + pytest.param( + { + "global": { + "strict": True, + }, + "repo": { + "strict": False, + }, + }, + { + "strict": False, + }, + id="test_repo_branch_protection_rule", + ), + pytest.param( + { + "global": { + "strict": False, + }, + }, + { + "strict": False, + }, + id="test_global_branch_protection_rule", + ), + pytest.param( + { + "global": { + "strict": False, + "require_code_owner_reviews": True, + "dismiss_stale_reviews": False, + "required_approving_review_count": 2, + "required_linear_history": False, + }, + "repo": { + "strict": True, + "require_code_owner_reviews": True, + "dismiss_stale_reviews": False, + "required_approving_review_count": 1, + "required_linear_history": True, + }, + }, + { + "strict": True, + "require_code_owner_reviews": True, + "dismiss_stale_reviews": False, + "required_approving_review_count": 1, + "required_linear_history": True, + }, + id="test_repo_multiple_branch_protection_rule", + ), + pytest.param( + {}, + { + **DEFAULT_BRANCH_PROTECTION, + }, + id="test_default_branch_protection_rule", + ), + ], + indirect=["branch_protection_rules"], +) +def test_branch_protection_setup(branch_protection_rules, expected): + mismatch = {} + for key in expected: + if branch_protection_rules[key] != expected[key]: + mismatch[key] = f"Expected value for {key}: {expected[key]}, actual: {branch_protection_rules[key]}" + assert not mismatch, f"Following mismatches are found: {mismatch}" diff --git a/webhook_server_container/utils/github_repository_settings.py b/webhook_server_container/utils/github_repository_settings.py index 200071e2..d2391be8 100644 --- a/webhook_server_container/utils/github_repository_settings.py +++ b/webhook_server_container/utils/github_repository_settings.py @@ -28,8 +28,18 @@ get_api_with_highest_rate_limit, get_future_results, get_logger_with_params, + get_value_from_dicts, ) +DEFAULT_BRANCH_PROTECTION = { + "strict": True, + "require_code_owner_reviews": False, + "dismiss_stale_reviews": True, + "required_approving_review_count": 0, + "required_linear_history": True, + "required_conversation_resolution": True, +} + def _get_github_repo_api(github_api: github.Github, repository: int | str) -> Repository | None: logger = get_logger_with_params(name="github-repository-settings") @@ -49,19 +59,25 @@ def set_branch_protection( repository: Repository, required_status_checks: List[str], github_api: Github, + strict: bool, + require_code_owner_reviews: bool, + dismiss_stale_reviews: bool, + required_approving_review_count: int, + required_linear_history: bool, + required_conversation_resolution: bool, ) -> bool: logger = get_logger_with_params(name="github-repository-settings") api_user = github_api.get_user().login logger.info(f"Set branch {branch} setting for {repository.name}. enabled checks: {required_status_checks}") branch.edit_protection( - strict=True, - required_conversation_resolution=True, + strict=strict, + required_conversation_resolution=required_conversation_resolution, contexts=required_status_checks, - require_code_owner_reviews=False, - dismiss_stale_reviews=True, - required_approving_review_count=0, - required_linear_history=True, + require_code_owner_reviews=require_code_owner_reviews, + dismiss_stale_reviews=dismiss_stale_reviews, + required_approving_review_count=required_approving_review_count, + required_linear_history=required_linear_history, users_bypass_pull_request_allowances=[api_user], teams_bypass_pull_request_allowances=[api_user], apps_bypass_pull_request_allowances=[api_user], @@ -168,6 +184,18 @@ def set_repository_labels(repository: Repository) -> str: return f"{repository}: Setting repository labels is done" +def get_repo_branch_protection_rules(config_data: dict[str, Any], repo_data: dict[str, Any]) -> dict[str, Any]: + for rule_name in DEFAULT_BRANCH_PROTECTION: + repo_data.setdefault("branch_protection", {}) + repo_data["branch_protection"][rule_name] = get_value_from_dicts( + primary_dict=repo_data["branch_protection"], + secondary_dict=config_data.get("branch_protection", {}), + key=rule_name, + return_on_none=DEFAULT_BRANCH_PROTECTION[rule_name], + ) + return repo_data + + def set_repositories_settings(config_: Config, github_api: Github) -> None: logger = get_logger_with_params(name="github-repository-settings") @@ -186,6 +214,7 @@ def set_repositories_settings(config_: Config, github_api: Github) -> None: futures = [] with ThreadPoolExecutor() as executor: for _, data in config_data["repositories"].items(): + data = get_repo_branch_protection_rules(config_data=config_data, repo_data=data) futures.append( executor.submit( set_repository, @@ -208,6 +237,7 @@ def set_repository( repository: str = data["name"] logger.info(f"Processing repository {repository}") protected_branches: Dict[str, Any] = data.get("protected-branches", {}) + repo_branch_protection_rules: Dict[str, Any] = data["branch_protection"] repo = _get_github_repo_api(github_api=github_api, repository=repository) if not repo: return False, f"{repository}: Failed to get repository", logger.error @@ -241,7 +271,6 @@ def set_repository( default_status_checks=_default_status_checks, exclude_status_checks=exclude_status_checks, ) - futures.append( executor.submit( set_branch_protection, @@ -251,6 +280,7 @@ def set_repository( "required_status_checks": required_status_checks, "github_api": github_api, }, + **repo_branch_protection_rules, ) )