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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

<h3 id="customsubject">Custom subject lines for GitHub events</h3>

You can customize the subject lines for GitHub events (issues and pull requests being opened, closed, and commented on) on a per-repository basis.
Expand Down
4 changes: 3 additions & 1 deletion asfyaml/feature/github/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
217 changes: 196 additions & 21 deletions asfyaml/feature/github/branch_protection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Loading
Loading