Skip to content
Merged
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
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ their own categorization system.
# Global configuration (applies to all repositories)
pr-size-thresholds:
Tiny:
threshold: 10 # Required: positive integer (lines changed)
threshold: 10 # Required: positive integer or 'inf' for unbounded category
color: lightgray # Optional: CSS3 color name, defaults to lightgray
Small:
threshold: 50
Expand All @@ -353,6 +353,9 @@ pr-size-thresholds:
Large:
threshold: 300
color: red
Massive:
threshold: inf # Infinity: captures all PRs >= 300 lines (unbounded largest category)
color: darkred

# Repository-specific configuration (overrides global)
repositories:
Expand All @@ -368,18 +371,24 @@ repositories:
Premium:
threshold: 500
color: orange
Ultimate:
threshold: inf # Optional: ensures all PRs beyond 500 lines are captured
color: crimson
```

#### Configuration Rules

- **threshold**: Required positive integer representing total lines changed
(additions + deletions)
- **threshold**: Required positive integer or string `'inf'` for infinity
- Positive integers represent minimum lines changed (additions + deletions)
- Use `inf` for an unbounded largest category (always sorted last)
- Infinity ensures all PRs beyond the largest finite threshold are captured
- **color**: Optional CSS3 color name
(e.g., `red`, `green`, `orange`, `lightblue`, `darkred`)
- **Label Names**: Any string (e.g., `Tiny`, `Express`, `Premium`, `Critical`)
(e.g., `red`, `green`, `orange`, `lightblue`, `darkred`, `crimson`)
- **Label Names**: Any string (e.g., `Tiny`, `Express`, `Premium`, `Critical`, `Massive`)
- **Hierarchy**: Repository-level configuration overrides global configuration
- **Fallback**: If no custom configuration is provided, uses default static labels
(XS, S, M, L, XL, XXL)
- **Backward Compatibility**: Existing configurations with integer-only thresholds continue to work

#### Supported Color Names

Expand Down
9 changes: 7 additions & 2 deletions examples/.github-webhook-server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ create-issue-for-new-pr: true # Create tracking issues for new PRs

# Custom PR size labels for this repository (overrides global configuration)
# Define custom categories based on total lines changed (additions + deletions)
# threshold: positive integer representing minimum lines changed for this category
# threshold: positive integer or 'inf' for unbounded largest category
# color: CSS3 color name (e.g., red, green, blue, lightgray, darkorange)
# Infinity behavior: 'inf' ensures all PRs beyond largest finite threshold are captured
# Always sorted last, regardless of definition order
pr-size-thresholds:
Quick:
threshold: 20 # PRs with 0-19 lines changed
Expand All @@ -118,4 +120,7 @@ pr-size-thresholds:
color: orange
Critical:
threshold: 1000 # PRs with 300-999 lines changed
color: darkred # PRs with 1000+ lines changed get this category
color: darkred
Extreme:
threshold: inf # PRs with 1000+ lines changed (unbounded largest category)
color: black # 'inf' means no upper limit - catches all PRs above 1000 lines
9 changes: 7 additions & 2 deletions examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ create-issue-for-new-pr: true # Global default: create tracking issues for new

# Global PR size label configuration (optional)
# Define custom categories based on total lines changed (additions + deletions)
# threshold: positive integer representing minimum lines changed for this category
# threshold: positive integer or 'inf' for unbounded largest category
# color: CSS3 color name (e.g., red, green, blue, lightgray, darkorange)
# Infinity behavior: 'inf' ensures all PRs beyond largest finite threshold are captured
# Always sorted last, regardless of definition order
pr-size-thresholds:
Tiny:
threshold: 10 # PRs with 0-9 lines changed
Expand All @@ -47,7 +49,10 @@ pr-size-thresholds:
color: orange
Large:
threshold: 300 # PRs with 150-299 lines changed
color: red # PRs with 300+ lines changed get this category
color: red
Massive:
threshold: inf # PRs with 300+ lines changed (unbounded largest category)
color: darkred # 'inf' means no upper limit - catches all PRs above 300 lines

branch-protection:
strict: True
Expand Down
18 changes: 12 additions & 6 deletions webhook_server/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,12 @@ properties:
type: object
properties:
threshold:
type: integer
minimum: 1
description: Minimum number of changes (additions + deletions) for this size category
oneOf:
- type: integer
minimum: 1
- type: string
enum: ["inf"]
description: Minimum number of changes (additions + deletions) for this size category, or 'inf' for unbounded largest category
color:
type: string
description: CSS3 color name for the label (e.g., 'green', 'red', 'orange')
Expand Down Expand Up @@ -267,9 +270,12 @@ properties:
type: object
properties:
threshold:
type: integer
minimum: 1
description: Minimum number of changes (additions + deletions) for this size category
oneOf:
- type: integer
minimum: 1
- type: string
enum: ["inf"]
description: Minimum number of changes (additions + deletions) for this size category, or 'inf' for unbounded largest category
color:
type: string
description: CSS3 color name for the label (e.g., 'green', 'red', 'orange')
Expand Down
8 changes: 7 additions & 1 deletion webhook_server/libs/handlers/labels_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,13 @@ def _get_custom_pr_size_thresholds(self) -> list[tuple[int | float, str, str]]:
thresholds = []
for label_name, config in custom_config.items():
threshold = config.get("threshold")
if threshold is None or not isinstance(threshold, int) or threshold <= 0:

# Convert string "inf" to float("inf") for YAML compatibility
if isinstance(threshold, str) and threshold.lower() == "inf":
threshold = float("inf")

# Accept both int and float types, validate > 0
if threshold is None or not isinstance(threshold, int | float) or threshold <= 0:
self.logger.warning(f"{self.log_prefix} Invalid threshold for '{label_name}': {threshold}")
continue

Expand Down
120 changes: 120 additions & 0 deletions webhook_server/tests/test_labels_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1021,3 +1021,123 @@ def test_get_label_color_custom_size_not_found(self, mock_github_webhook: Mock)
# Test size label not in custom config - should fall back to static if exists
# This would be the case where user has custom config but requests a static size
assert labels_handler._get_label_color("size/XL") == "D93F0B" # Falls back to STATIC_LABELS_DICT

def test_get_custom_pr_size_thresholds_with_infinity_float(self, mock_github_webhook: Mock) -> None:
"""Test custom PR size thresholds with float('inf') threshold."""
# Mock config with float("inf") threshold
mock_github_webhook.config.get_value.return_value = {
"S": {"threshold": 100, "color": "green"},
"M": {"threshold": 300, "color": "orange"},
"XXL": {"threshold": float("inf"), "color": "red"},
}

labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock())

thresholds = labels_handler._get_custom_pr_size_thresholds()

# Verify infinity threshold is accepted and sorted correctly (should be last)
expected = [
(100, "S", "008000"), # green hex
(300, "M", "ffa500"), # orange hex
(float("inf"), "XXL", "ff0000"), # red hex - infinity should be last
]
assert thresholds == expected
# Verify sorting: infinity should be last
assert thresholds[-1][0] == float("inf")
assert thresholds[-1][1] == "XXL"

def test_get_custom_pr_size_thresholds_with_infinity_string(self, mock_github_webhook: Mock) -> None:
"""Test custom PR size thresholds with string 'inf' threshold (YAML compatibility)."""
# Mock config with string "inf" threshold (YAML compatibility)
mock_github_webhook.config.get_value.return_value = {
"S": {"threshold": 50, "color": "green"},
"L": {"threshold": 200, "color": "orange"},
"XXL": {"threshold": "inf", "color": "red"},
}

labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock())

thresholds = labels_handler._get_custom_pr_size_thresholds()

# Verify string "inf" is converted to float("inf")
expected = [
(50, "S", "008000"), # green hex
(200, "L", "ffa500"), # orange hex
(float("inf"), "XXL", "ff0000"), # red hex - converted from string
]
assert thresholds == expected
# Verify string "inf" was converted to float("inf")
assert thresholds[-1][0] == float("inf")
assert isinstance(thresholds[-1][0], float)

def test_get_custom_pr_size_thresholds_mixed_with_infinity(self, mock_github_webhook: Mock) -> None:
"""Test custom PR size thresholds with mixed integers and infinity."""
# Mock config with mixed thresholds (integers + infinity)
mock_github_webhook.config.get_value.return_value = {
"XS": {"threshold": 20, "color": "lightgray"},
"S": {"threshold": 100, "color": "green"},
"XXL": {"threshold": float("inf"), "color": "red"},
"M": {"threshold": 300, "color": "orange"},
}

labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock())

thresholds = labels_handler._get_custom_pr_size_thresholds()

# Verify proper sorting (infinity should be last)
expected = [
(20, "XS", "d3d3d3"), # lightgray hex
(100, "S", "008000"), # green hex
(300, "M", "ffa500"), # orange hex
(float("inf"), "XXL", "ff0000"), # red hex - infinity should be last
]
assert thresholds == expected
# Verify sorting correctness: numeric values first, then infinity
for i in range(len(thresholds) - 1):
assert thresholds[i][0] < thresholds[i + 1][0]

def test_get_size_with_infinity_threshold(self, mock_github_webhook: Mock) -> None:
"""Test get_size() method with custom infinity threshold."""
# Mock config with infinity threshold
mock_github_webhook.config.get_value.return_value = {
"S": {"threshold": 100, "color": "green"},
"M": {"threshold": 300, "color": "orange"},
"XXL": {"threshold": float("inf"), "color": "red"},
}

labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock())

# Test various PR sizes
test_cases = [
(50, 0, "size/S"), # 50 < 100
(150, 0, "size/M"), # 150 >= 100 but < 300
(400, 0, "size/XXL"), # 400 >= 300 but < inf
(1000, 500, "size/XXL"), # 1500 >= 300 but < inf (extremely large PR)
(10000, 5000, "size/XXL"), # 15000 >= 300 but < inf (huge PR)
]

for additions, deletions, expected in test_cases:
pull_request = Mock(spec=PullRequest)
pull_request.additions = additions
pull_request.deletions = deletions

result = labels_handler.get_size(pull_request=pull_request)
assert result == expected, (
f"Failed for {additions}+{deletions}={additions + deletions}, expected {expected}"
)

def test_get_label_color_with_infinity_threshold(self, mock_github_webhook: Mock) -> None:
"""Test _get_label_color() with custom infinity threshold."""
# Mock config with infinity threshold
mock_github_webhook.config.get_value.return_value = {
"S": {"threshold": 100, "color": "green"},
"M": {"threshold": 300, "color": "orange"},
"XXL": {"threshold": float("inf"), "color": "red"},
}

labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock())

# Verify color is returned correctly for infinity category
assert labels_handler._get_label_color("size/S") == "008000" # green hex
assert labels_handler._get_label_color("size/M") == "ffa500" # orange hex
assert labels_handler._get_label_color("size/XXL") == "ff0000" # red hex (infinity category)