diff --git a/README.md b/README.md index e7e988f..723ff18 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,91 @@ github: protected_branches: ~ ~~~ +#### Branch Protection with Patterns + +You can now protect branches using regex patterns in addition to exact branch names. This allows you to apply protection rules to multiple branches that match a pattern, such as all feature branches or release branches. + +~~~yaml +github: + protected_branches: + # Exact branch protection (existing behavior) + main: + required_signatures: true + required_linear_history: true + + develop: + required_pull_request_reviews: + required_approving_review_count: 2 + + # Pattern-based protection (NEW) + feature-branches: + pattern: "feature/.*" + required_pull_request_reviews: + required_approving_review_count: 1 + + release-branches: + pattern: "release/v\\d+\\.\\d+" + required_signatures: true + required_linear_history: true + + hotfix-branches: + pattern: "hotfix/.*" + required_pull_request_reviews: + required_approving_review_count: 2 + dismiss_stale_reviews: true +~~~ + +**How Pattern Rules Work:** + +- **Rule Names**: When using patterns, the key (e.g., `feature-branches`) is just a descriptive name for the rule +- **Pattern Field**: Add a `pattern` field with a regular expression to match branch names +- **Precedence**: Exact branch names always override pattern matches +- **Conflicts**: When multiple patterns match the same branch, the first pattern in the configuration takes precedence + +**Common Pattern Examples:** + +| Pattern | Description | Matches | +|---------|-------------|---------| +| `feature/.*` | All feature branches | `feature/auth`, `feature/payment` | +| `release/v\\d+\\.\\d+` | Version releases | `release/v1.0`, `release/v2.1` | +| `hotfix/.*` | All hotfix branches | `hotfix/security`, `hotfix/bug-123` | +| `users/[^/]+/.*` | User branches | `users/john/feature`, `users/jane/fix` | +| `.*` | All branches | Any branch name | + +**Pattern Syntax:** + +- Use standard regular expression syntax +- Escape special characters with backslashes: `\\.` for literal dots, `\\d` for digits +- YAML string escaping: Use double backslashes `\\` in YAML strings +- Pattern length is limited to 1000 characters for security + +**Warnings and Validation:** + +The system will provide helpful warnings for common issues: +- Pattern rules that match no existing branches +- Branches that match multiple patterns (shows which rule wins) +- Exact rules that override pattern matches +- Invalid regular expression syntax + +**Example Output:** +``` +Branch Protection Changes: + +=== Branches Protected by Pattern Rules === + +feature/user-auth (via pattern rule "feature-branches" (feature/.*)): + - Set required approving review count to 1 + +feature/payment (via pattern rule "feature-branches" (feature/.*)): + - Set required approving review count to 1 + +=== Branches Protected by Exact Rules === + +main (via exact rule "main"): + - Set required signatures to True + - Set required linear history to True +``` +

Custom subject lines for GitHub events

You can customize the subject lines for GitHub events (issues and pull requests being opened, closed, and commented on) on a per-repository basis. diff --git a/asfyaml/feature/github/__init__.py b/asfyaml/feature/github/__init__.py index ec770e8..2e93325 100644 --- a/asfyaml/feature/github/__init__.py +++ b/asfyaml/feature/github/__init__.py @@ -98,12 +98,14 @@ class ASFGitHubFeature(ASFYamlFeature, name="github"): # GitHub Pages: branch (can be default or gh-pages) and path (can be /docs or /) strictyaml.Optional("ghp_branch"): strictyaml.Str(), strictyaml.Optional("ghp_path", default="/docs"): strictyaml.Str(), - # Branch protection rules - TODO: add actual schema + # Branch protection rules - supports exact branch names and regex patterns strictyaml.Optional("protected_branches"): asfyaml.validators.EmptyValue() | strictyaml.MapPattern( strictyaml.Str(), strictyaml.Map( { + # NEW: Optional regex pattern for matching multiple branches + strictyaml.Optional("pattern"): asfyaml.validators.BranchPattern(), strictyaml.Optional("required_signatures", default=False): strictyaml.Bool(), strictyaml.Optional("required_linear_history", default=False): strictyaml.Bool(), strictyaml.Optional("required_conversation_resolution", default=False): strictyaml.Bool(), diff --git a/asfyaml/feature/github/branch_protection.py b/asfyaml/feature/github/branch_protection.py index ec3d907..fb337e9 100644 --- a/asfyaml/feature/github/branch_protection.py +++ b/asfyaml/feature/github/branch_protection.py @@ -17,7 +17,8 @@ """GitHub branch protections""" -from typing import Mapping, Any +import re +from typing import Mapping, Any, Dict, List, Tuple, Set import github as pygithub from github.GithubObject import NotSet, Opt, is_defined from . import directive, ASFGitHubFeature @@ -83,6 +84,122 @@ def get_head_refs(self: ASFGitHubFeature) -> list[Mapping[str, Any]]: return [] +def compile_protection_rules(branches_config: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Separate and validate exact branch rules from pattern rules + + Returns: + Tuple of (pattern_rules, exact_rules) + """ + pattern_rules = {} + exact_rules = {} + + for rule_name, settings in branches_config.items(): + if "pattern" in settings: + # This is a pattern rule + pattern_str = settings["pattern"] + try: + compiled_pattern = re.compile(pattern_str) + pattern_rules[rule_name] = {"regex": compiled_pattern, "pattern_str": pattern_str, "settings": settings} + except re.error as e: + raise Exception(f"Invalid regex pattern in rule '{rule_name}': {e}") + else: + # This is an exact branch rule (existing behavior) + exact_rules[rule_name] = settings + + return pattern_rules, exact_rules + + +def match_branches_to_patterns(all_branches: List[str], pattern_rules: Dict[str, Any]) -> Dict[str, List[str]]: + """Match all repository branches against compiled pattern rules + + Returns: + Dict mapping branch_name -> list of matching rule names + """ + branch_matches = {} + + for branch_name in all_branches: + matching_rules = [] + for rule_name, rule_data in pattern_rules.items(): + if rule_data["regex"].match(branch_name): + matching_rules.append(rule_name) + + if matching_rules: + branch_matches[branch_name] = matching_rules + + return branch_matches + + +def resolve_protection_rules( + all_branches: Dict[str, Any], pattern_rules: Dict[str, Any], exact_rules: Dict[str, Any] +) -> Tuple[Dict[str, Dict], List[str]]: + """Resolve final protection rules with precedence handling + + Precedence: + 1. Exact branch names override pattern matches + 2. First matching pattern wins for conflicts + + Returns: + Tuple of (final_rules_dict, warnings_list) + """ + warnings = [] + final_rules = {} + + # Get all branch names from the repository + branch_names = list(all_branches.keys()) + + # Match branches against patterns + branch_pattern_matches = match_branches_to_patterns(branch_names, pattern_rules) + + # Apply pattern matches first + for branch_name, matching_rule_names in branch_pattern_matches.items(): + if len(matching_rule_names) > 1: + warnings.append( + f"Branch '{branch_name}' matches multiple patterns: {matching_rule_names}. " + f"Using first match: '{matching_rule_names[0]}'" + ) + + # Use first matching pattern + first_rule = matching_rule_names[0] + final_rules[branch_name] = { + "settings": pattern_rules[first_rule]["settings"], + "source": f'pattern rule "{first_rule}" ({pattern_rules[first_rule]["pattern_str"]})', + "rule_type": "pattern", + } + + # Apply exact rules (these override pattern matches) + for branch_name, settings in exact_rules.items(): + if branch_name in all_branches: + if branch_name in final_rules: + warnings.append( + f"Exact rule '{branch_name}' overrides pattern match from {final_rules[branch_name]['source']}" + ) + + final_rules[branch_name] = { + "settings": settings, + "source": f'exact rule "{branch_name}"', + "rule_type": "exact", + } + else: + warnings.append(f"Exact branch rule '{branch_name}' does not match any existing branch") + + # Check for patterns with no matches + pattern_match_counts = {} + for rule_name in pattern_rules: + pattern_match_counts[rule_name] = 0 + + for matching_rules in branch_pattern_matches.values(): + for rule_name in matching_rules: + pattern_match_counts[rule_name] += 1 + + for rule_name, match_count in pattern_match_counts.items(): + if match_count == 0: + warnings.append( + f"Pattern rule '{rule_name}' with pattern '{pattern_rules[rule_name]['pattern_str']}' matches no branches" + ) + + return final_rules, warnings + + @directive def branch_protection(self: ASFGitHubFeature): # Branch protections @@ -96,30 +213,51 @@ def branch_protection(self: ASFGitHubFeature): print(f"Error: failed to retrieve current refs: {ex!s}") refs = [] - protected_branches = set() + # Build dict of all repository branches + all_branches = {} + currently_protected_branches = set() for ref in refs: name = ref["name"] + all_branches[name] = ref branch_protection_rule = ref.get("branchProtectionRule") if branch_protection_rule is not None: - protected_branches.add(name) + currently_protected_branches.add(name) - branches = self.yaml.get("protected_branches", {}) + branches_config = self.yaml.get("protected_branches", {}) # If protected_branches is set to ~ (None), reset it to an empty map - # We still need to remove existing branch protection rules from all existing branches later on - if branches is None: - branches = {} + if branches_config is None: + branches_config = {} + + # NEW: Compile pattern and exact rules with validation + try: + pattern_rules, exact_rules = compile_protection_rules(branches_config) + final_rules, rule_warnings = resolve_protection_rules(all_branches, pattern_rules, exact_rules) + + # Print warnings about rule conflicts and missing matches + for warning in rule_warnings: + print(f"Warning: {warning}") + + except Exception as e: + print(f"Error processing branch protection rules: {e}") + return protection_changes = {} - for branch, brsettings in branches.items(): - if branch in protected_branches: - protected_branches.remove(branch) + + # Process final resolved rules (both exact and pattern-matched branches) + for branch_name, rule_data in final_rules.items(): + brsettings = rule_data["settings"] + + # Remove this branch from currently protected (so we don't remove protection later) + currently_protected_branches.discard(branch_name) branch_changes = [] try: - ghbranch = self.ghrepo.get_branch(branch=branch) + ghbranch = self.ghrepo.get_branch(branch=branch_name) except pygithub.GithubException as e: if e.status == 404: # No such branch, skip to next rule - protection_changes[branch] = [f"Branch {branch} does not exist, protection could not be configured"] + protection_changes[branch_name] = [ + f"Branch {branch_name} does not exist, protection could not be configured" + ] continue else: # propagate other errors, GitHub API might have an outage @@ -288,20 +426,57 @@ def branch_protection(self: ASFGitHubFeature): # Log all the changes we made to this branch if branch_changes: - protection_changes[branch] = branch_changes + protection_changes[branch_name] = branch_changes - # remove branch protection from all remaining protected branches - for branch_name in protected_branches: + # remove branch protection from all remaining currently protected branches + # (these are branches that had protection but are no longer in our rules) + for branch_name in currently_protected_branches: branch = self.ghrepo.get_branch(branch_name) - protection_changes[branch] = [f"Remove branch protection from branch '{branch_name}'"] + protection_changes[branch_name] = [ + f"Remove branch protection from branch '{branch_name}' (no longer matches any rules)" + ] if not self.noop("github::protected_branches"): branch.remove_protection() if protection_changes: - summary = "" - for branch, changes in protection_changes.items(): - summary += f"Updates to the {branch} branch:\n" - for change in changes: - summary += f" - {change}\n" + summary = "Branch Protection Changes:\n" + + # Show pattern rule matches first + pattern_applied = [] + exact_applied = [] + removed = [] + + for branch_name, changes in protection_changes.items(): + if branch_name in final_rules: + rule_data = final_rules[branch_name] + if rule_data["rule_type"] == "pattern": + pattern_applied.append((branch_name, changes, rule_data["source"])) + else: + exact_applied.append((branch_name, changes, rule_data["source"])) + else: + removed.append((branch_name, changes)) + + # Group output by rule type for clarity + if pattern_applied: + summary += "\n=== Branches Protected by Pattern Rules ===\n" + for branch_name, changes, source in pattern_applied: + summary += f"\n{branch_name} (via {source}):\n" + for change in changes: + summary += f" - {change}\n" + + if exact_applied: + summary += "\n=== Branches Protected by Exact Rules ===\n" + for branch_name, changes, source in exact_applied: + summary += f"\n{branch_name} (via {source}):\n" + for change in changes: + summary += f" - {change}\n" + + if removed: + summary += "\n=== Branch Protection Removed ===\n" + for branch_name, changes in removed: + summary += f"\n{branch_name}:\n" + for change in changes: + summary += f" - {change}\n" + print(summary) diff --git a/asfyaml/validators.py b/asfyaml/validators.py index b9612b0..a5027ac 100644 --- a/asfyaml/validators.py +++ b/asfyaml/validators.py @@ -15,10 +15,41 @@ # specific language governing permissions and limitations # under the License. +import re import strictyaml import strictyaml.exceptions +class BranchPattern(strictyaml.ScalarValidator): + """Validates regex patterns for branch matching with security constraints""" + + MAX_PATTERN_LENGTH = 1000 # Prevent ReDoS attacks + + def validate_scalar(self, chunk): + pattern = chunk.contents.strip() + + # Basic validation + if not pattern: + chunk.expecting_but_found("pattern cannot be empty") + + if len(pattern) > self.MAX_PATTERN_LENGTH: + chunk.expecting_but_found(f"pattern too long ({len(pattern)} chars), maximum {self.MAX_PATTERN_LENGTH}") + + # Validate regex compilation + try: + compiled = re.compile(pattern) + # Test compilation with empty string to catch some ReDoS patterns early + compiled.match("") + return pattern + except re.error as e: + chunk.expecting_but_found(f"invalid regex pattern: {e}") + except Exception as e: + chunk.expecting_but_found(f"regex compilation error: {e}") + + def to_yaml(self, data): + return str(data) + + class EmptyValue(strictyaml.ScalarValidator): """Legacy YAML null type that supports the tilde null marker not considered proper by strictyaml: a: null diff --git a/tests/github_branch_protection_patterns.py b/tests/github_branch_protection_patterns.py new file mode 100644 index 0000000..03cd950 --- /dev/null +++ b/tests/github_branch_protection_patterns.py @@ -0,0 +1,646 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for branch protection pattern matching functionality""" + +import pytest +import re +import strictyaml +from asfyaml.feature.github.branch_protection import ( + compile_protection_rules, + match_branches_to_patterns, + resolve_protection_rules +) +from asfyaml.validators import BranchPattern + + +class TestPatternCompilation: + """Test regex pattern compilation and validation""" + + def test_valid_pattern_rules(self): + """Test compilation of valid pattern rules""" + branches_config = { + "feature-branches": { + "pattern": "feature/.*", + "required_signatures": True + }, + "release-branches": { + "pattern": r"release/v\d+\.\d+", + "required_linear_history": True + } + } + + pattern_rules, exact_rules = compile_protection_rules(branches_config) + + assert len(pattern_rules) == 2 + assert len(exact_rules) == 0 + + assert "feature-branches" in pattern_rules + assert "release-branches" in pattern_rules + + # Test that regex objects are compiled + assert isinstance(pattern_rules["feature-branches"]["regex"], re.Pattern) + assert isinstance(pattern_rules["release-branches"]["regex"], re.Pattern) + + def test_mixed_pattern_and_exact_rules(self): + """Test compilation with both pattern and exact rules""" + branches_config = { + "main": { + "required_signatures": True + }, + "develop": { + "required_pull_request_reviews": { + "required_approving_review_count": 2 + } + }, + "feature-branches": { + "pattern": "feature/.*", + "required_signatures": False + } + } + + pattern_rules, exact_rules = compile_protection_rules(branches_config) + + assert len(pattern_rules) == 1 + assert len(exact_rules) == 2 + + assert "feature-branches" in pattern_rules + assert "main" in exact_rules + assert "develop" in exact_rules + + def test_invalid_regex_pattern(self): + """Test handling of invalid regex patterns""" + branches_config = { + "bad-pattern": { + "pattern": "feature/[invalid", + "required_signatures": True + } + } + + with pytest.raises(Exception, match="Invalid regex pattern"): + compile_protection_rules(branches_config) + + def test_empty_pattern(self): + """Test handling of empty pattern""" + branches_config = { + "empty-pattern": { + "pattern": "", + "required_signatures": True + } + } + + # Should not raise exception at compilation (validation happens at schema level) + pattern_rules, exact_rules = compile_protection_rules(branches_config) + assert len(pattern_rules) == 1 + + +class TestBranchMatching: + """Test branch matching against patterns""" + + def test_simple_pattern_matching(self): + """Test basic pattern matching""" + pattern_rules = { + "feature-branches": { + "regex": re.compile("feature/.*"), + "pattern_str": "feature/.*", + "settings": {"required_signatures": True} + } + } + + branches = ["main", "feature/auth", "feature/payment", "hotfix/urgent"] + matches = match_branches_to_patterns(branches, pattern_rules) + + assert len(matches) == 2 + assert "feature/auth" in matches + assert "feature/payment" in matches + assert matches["feature/auth"] == ["feature-branches"] + assert matches["feature/payment"] == ["feature-branches"] + + def test_multiple_patterns_single_branch(self): + """Test single branch matching multiple patterns""" + pattern_rules = { + "all-branches": { + "regex": re.compile(".*"), + "pattern_str": ".*", + "settings": {"required_signatures": True} + }, + "feature-branches": { + "regex": re.compile("feature/.*"), + "pattern_str": "feature/.*", + "settings": {"required_pull_request_reviews": True} + } + } + + branches = ["feature/auth", "main"] + matches = match_branches_to_patterns(branches, pattern_rules) + + assert len(matches) == 2 + assert len(matches["feature/auth"]) == 2 + assert len(matches["main"]) == 1 + assert "all-branches" in matches["feature/auth"] + assert "feature-branches" in matches["feature/auth"] + + def test_complex_patterns(self): + """Test complex regex patterns""" + pattern_rules = { + "version-releases": { + "regex": re.compile(r"release/v\d+\.\d+"), + "pattern_str": r"release/v\d+\.\d+", + "settings": {"required_signatures": True} + }, + "user-branches": { + "regex": re.compile(r"users/[^/]+/.*"), + "pattern_str": r"users/[^/]+/.*", + "settings": {"required_pull_request_reviews": True} + } + } + + branches = [ + "release/v1.0", "release/v2.1", "release/beta", + "users/john/feature", "users/jane/hotfix", "feature/auth" + ] + matches = match_branches_to_patterns(branches, pattern_rules) + + assert len(matches) == 4 + assert "release/v1.0" in matches + assert "release/v2.1" in matches + assert "users/john/feature" in matches + assert "users/jane/hotfix" in matches + assert "release/beta" not in matches + assert "feature/auth" not in matches + + +class TestRuleResolution: + """Test final rule resolution with precedence""" + + def test_exact_overrides_pattern(self): + """Test exact branch rules override pattern matches""" + all_branches = { + "main": {"name": "main"}, + "feature/auth": {"name": "feature/auth"}, + "feature/payment": {"name": "feature/payment"} + } + + pattern_rules = { + "all-branches": { + "regex": re.compile(".*"), + "pattern_str": ".*", + "settings": {"required_signatures": False} + } + } + + exact_rules = { + "main": {"required_signatures": True} + } + + final_rules, warnings = resolve_protection_rules(all_branches, pattern_rules, exact_rules) + + assert len(final_rules) == 3 + assert final_rules["main"]["rule_type"] == "exact" + assert final_rules["main"]["settings"]["required_signatures"] is True + assert final_rules["feature/auth"]["rule_type"] == "pattern" + assert final_rules["feature/auth"]["settings"]["required_signatures"] is False + + # Should warn about exact rule overriding pattern + assert any("overrides pattern match" in w for w in warnings) + + def test_first_pattern_wins_conflicts(self): + """Test first matching pattern wins for conflicts""" + all_branches = { + "feature/auth": {"name": "feature/auth"} + } + + # Order matters - first pattern should win + pattern_rules = { + "first-pattern": { + "regex": re.compile("feature/.*"), + "pattern_str": "feature/.*", + "settings": {"required_signatures": True} + }, + "second-pattern": { + "regex": re.compile(".*"), + "pattern_str": ".*", + "settings": {"required_signatures": False} + } + } + + exact_rules = {} + + final_rules, warnings = resolve_protection_rules(all_branches, pattern_rules, exact_rules) + + assert len(final_rules) == 1 + assert final_rules["feature/auth"]["settings"]["required_signatures"] is True + assert "first-pattern" in final_rules["feature/auth"]["source"] + + # Should warn about multiple pattern matches + assert any("matches multiple patterns" in w for w in warnings) + + def test_pattern_no_matches_warning(self): + """Test warning when pattern matches no branches""" + all_branches = { + "main": {"name": "main"} + } + + pattern_rules = { + "feature-branches": { + "regex": re.compile("feature/.*"), + "pattern_str": "feature/.*", + "settings": {"required_signatures": True} + } + } + + exact_rules = {} + + final_rules, warnings = resolve_protection_rules(all_branches, pattern_rules, exact_rules) + + assert len(final_rules) == 0 + assert any("matches no branches" in w for w in warnings) + + def test_exact_branch_not_found_warning(self): + """Test warning when exact branch doesn't exist""" + all_branches = { + "main": {"name": "main"} + } + + pattern_rules = {} + exact_rules = { + "nonexistent": {"required_signatures": True} + } + + final_rules, warnings = resolve_protection_rules(all_branches, pattern_rules, exact_rules) + + assert len(final_rules) == 0 + assert any("does not match any existing branch" in w for w in warnings) + + +class TestIntegrationScenarios: + """Test realistic integration scenarios""" + + def test_typical_project_setup(self): + """Test a typical project branch protection setup""" + all_branches = { + "main": {"name": "main"}, + "develop": {"name": "develop"}, + "feature/user-auth": {"name": "feature/user-auth"}, + "feature/payment": {"name": "feature/payment"}, + "release/v1.0": {"name": "release/v1.0"}, + "hotfix/security-fix": {"name": "hotfix/security-fix"} + } + + branches_config = { + "main": { + "required_signatures": True, + "required_linear_history": True + }, + "develop": { + "required_pull_request_reviews": { + "required_approving_review_count": 1 + } + }, + "feature-branches": { + "pattern": "feature/.*", + "required_pull_request_reviews": { + "required_approving_review_count": 1 + } + }, + "release-branches": { + "pattern": r"release/.*", + "required_signatures": True + }, + "hotfix-branches": { + "pattern": "hotfix/.*", + "required_pull_request_reviews": { + "required_approving_review_count": 2 + } + } + } + + pattern_rules, exact_rules = compile_protection_rules(branches_config) + final_rules, warnings = resolve_protection_rules(all_branches, pattern_rules, exact_rules) + + # Verify all branches get protection + assert len(final_rules) == 6 + + # Check exact rules + assert final_rules["main"]["rule_type"] == "exact" + assert final_rules["main"]["settings"]["required_signatures"] is True + assert final_rules["develop"]["rule_type"] == "exact" + + # Check pattern matches + assert final_rules["feature/user-auth"]["rule_type"] == "pattern" + assert final_rules["feature/payment"]["rule_type"] == "pattern" + assert final_rules["release/v1.0"]["rule_type"] == "pattern" + assert final_rules["hotfix/security-fix"]["rule_type"] == "pattern" + + # Verify settings are correctly applied + assert final_rules["release/v1.0"]["settings"]["required_signatures"] is True + assert final_rules["hotfix/security-fix"]["settings"]["required_pull_request_reviews"]["required_approving_review_count"] == 2 + + # Should have minimal warnings + assert len(warnings) == 0 + + +class TestBranchPatternValidator: + """Test the BranchPattern validator class""" + + def test_validator_valid_patterns(self): + """Test validator accepts valid regex patterns""" + validator = BranchPattern() + + class MockChunk: + def __init__(self, contents): + self.contents = contents + + # Test various valid patterns + valid_patterns = [ + "feature/.*", + r"release/v\d+\.\d+", + "hotfix/.*", + r"users/[^/]+/.*", + ".*", + "main", + "develop" + ] + + for pattern in valid_patterns: + chunk = MockChunk(pattern) + result = validator.validate_scalar(chunk) + assert result == pattern + + def test_validator_invalid_regex(self): + """Test validator rejects invalid regex patterns""" + validator = BranchPattern() + + class MockChunk: + def __init__(self, contents): + self.contents = contents + + def expecting_but_found(self, message): + # StrictYAML expects different arguments + raise Exception(message) + + invalid_patterns = [ + "feature/[invalid", # Missing closing bracket + "release/(unclosed", # Unclosed parenthesis + ] + + for pattern in invalid_patterns: + chunk = MockChunk(pattern) + with pytest.raises(Exception, match="invalid regex pattern"): + validator.validate_scalar(chunk) + + # Test that valid patterns don't raise (sanity check) + valid_chunk = MockChunk("hotfix/.*") + result = validator.validate_scalar(valid_chunk) + assert result == "hotfix/.*" + + def test_validator_empty_pattern(self): + """Test validator rejects empty patterns""" + validator = BranchPattern() + + class MockChunk: + def __init__(self, contents): + self.contents = contents + + def expecting_but_found(self, message): + raise Exception(message) + + chunk = MockChunk("") + with pytest.raises(Exception, match="pattern cannot be empty"): + validator.validate_scalar(chunk) + + def test_validator_pattern_too_long(self): + """Test validator rejects patterns that are too long (ReDoS protection)""" + validator = BranchPattern() + + class MockChunk: + def __init__(self, contents): + self.contents = contents + + def expecting_but_found(self, message): + raise Exception(message) + + # Create a pattern longer than MAX_PATTERN_LENGTH (1000) + long_pattern = "a" * 1001 + chunk = MockChunk(long_pattern) + + with pytest.raises(Exception, match="pattern too long"): + validator.validate_scalar(chunk) + + def test_validator_to_yaml(self): + """Test validator's to_yaml method""" + validator = BranchPattern() + assert validator.to_yaml("feature/.*") == "feature/.*" + assert validator.to_yaml("main") == "main" + + +class TestEdgeCases: + """Test edge cases and error conditions""" + + def test_compile_rules_with_invalid_regex_in_runtime(self): + """Test runtime regex compilation errors are handled""" + # This tests the secondary validation in compile_protection_rules + # even if something slips past the schema validator + + # Mock a scenario where invalid regex gets through + branches_config = { + "bad-rule": { + "pattern": "[invalid-regex", + "required_signatures": True + } + } + + with pytest.raises(Exception, match="Invalid regex pattern"): + compile_protection_rules(branches_config) + + def test_match_branches_empty_pattern_rules(self): + """Test matching with empty pattern rules""" + branches = ["main", "feature/auth"] + pattern_rules = {} + + matches = match_branches_to_patterns(branches, pattern_rules) + assert len(matches) == 0 + + def test_match_branches_empty_branches(self): + """Test matching with no branches""" + branches = [] + pattern_rules = { + "feature-branches": { + "regex": re.compile("feature/.*"), + "pattern_str": "feature/.*", + "settings": {"required_signatures": True} + } + } + + matches = match_branches_to_patterns(branches, pattern_rules) + assert len(matches) == 0 + + def test_resolve_rules_empty_inputs(self): + """Test rule resolution with empty inputs""" + all_branches = {} + pattern_rules = {} + exact_rules = {} + + final_rules, warnings = resolve_protection_rules(all_branches, pattern_rules, exact_rules) + + assert len(final_rules) == 0 + assert len(warnings) == 0 + + def test_resolve_rules_comprehensive_warnings(self): + """Test all warning scenarios in rule resolution""" + all_branches = { + "main": {"name": "main"}, + "feature/overlap": {"name": "feature/overlap"} + } + + # Multiple overlapping patterns + pattern_rules = { + "all-branches": { + "regex": re.compile(".*"), + "pattern_str": ".*", + "settings": {"required_signatures": True} + }, + "feature-branches": { + "regex": re.compile("feature/.*"), + "pattern_str": "feature/.*", + "settings": {"required_signatures": False} + }, + "no-match-pattern": { + "regex": re.compile("nonexistent/.*"), + "pattern_str": "nonexistent/.*", + "settings": {"required_signatures": True} + } + } + + # Exact rule that overrides pattern and one that doesn't match + exact_rules = { + "feature/overlap": {"required_signatures": True}, + "nonexistent-branch": {"required_signatures": True} + } + + final_rules, warnings = resolve_protection_rules(all_branches, pattern_rules, exact_rules) + + # Should generate all types of warnings + warning_types = { + "multiple patterns": False, + "overrides pattern": False, + "matches no branches": False, + "does not match any existing": False + } + + for warning in warnings: + for warning_type in warning_types: + if warning_type in warning: + warning_types[warning_type] = True + + # Verify all warning types were generated + for warning_type, found in warning_types.items(): + assert found, f"Missing warning type: {warning_type}" + + +class TestPerformanceAndComplexity: + """Test performance aspects and complex scenarios""" + + def test_large_branch_set_performance(self): + """Test performance with large number of branches""" + # Simulate a large repository with many branches + branches = [] + for i in range(1000): + branches.extend([ + f"feature/feature-{i}", + f"release/v{i//100}.{i%100}", + f"hotfix/fix-{i}", + f"users/user{i}/branch" + ]) + + pattern_rules = { + "feature-branches": { + "regex": re.compile("feature/.*"), + "pattern_str": "feature/.*", + "settings": {"required_signatures": True} + }, + "release-branches": { + "regex": re.compile(r"release/v\d+\.\d+"), + "pattern_str": r"release/v\d+\.\d+", + "settings": {"required_signatures": True} + } + } + + # This should complete quickly even with many branches + matches = match_branches_to_patterns(branches, pattern_rules) + + # Verify correct matches + assert len(matches) == 2000 # 1000 feature + 1000 release (only these patterns match) + + # Sample check + assert "feature/feature-0" in matches + assert "release/v0.0" in matches + assert len(matches["feature/feature-0"]) == 1 + assert matches["feature/feature-0"][0] == "feature-branches" + + def test_complex_regex_patterns(self): + """Test complex real-world regex patterns""" + complex_patterns = { + "semantic-versions": { + "regex": re.compile(r"release/v\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?"), + "pattern_str": r"release/v\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?", + "settings": {"required_signatures": True} + }, + "user-feature-branches": { + "regex": re.compile(r"users/[a-zA-Z][a-zA-Z0-9_-]*/feature/.*"), + "pattern_str": r"users/[a-zA-Z][a-zA-Z0-9_-]*/feature/.*", + "settings": {"required_pull_request_reviews": True} + }, + "date-based-branches": { + "regex": re.compile(r"sprint/\d{4}-\d{2}-\d{2}/.*"), + "pattern_str": r"sprint/\d{4}-\d{2}-\d{2}/.*", + "settings": {"required_linear_history": True} + } + } + + test_branches = [ + "release/v1.2.3", + "release/v1.2.3-beta", + "release/v1.2.3-rc1", + "users/john_doe/feature/auth", + "users/jane-smith/feature/payment", + "users/123invalid/feature/test", # Should not match (starts with digit) + "sprint/2024-01-15/planning", + "sprint/2024-12-31/retrospective", + "main", + "develop" + ] + + matches = match_branches_to_patterns(test_branches, complex_patterns) + + # Verify semantic version matches + assert "release/v1.2.3" in matches + assert "release/v1.2.3-beta" in matches + assert "release/v1.2.3-rc1" in matches + + # Verify user branch matches (valid usernames only) + assert "users/john_doe/feature/auth" in matches + assert "users/jane-smith/feature/payment" in matches + assert "users/123invalid/feature/test" not in matches # Invalid username + + # Verify date-based matches + assert "sprint/2024-01-15/planning" in matches + assert "sprint/2024-12-31/retrospective" in matches + + # Verify non-matches + assert "main" not in matches + assert "develop" not in matches