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