diff --git a/README.md b/README.md index 4578eeb8f..48c2948b2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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 diff --git a/examples/.github-webhook-server.yaml b/examples/.github-webhook-server.yaml index 2b373e2d4..aa52a8757 100644 --- a/examples/.github-webhook-server.yaml +++ b/examples/.github-webhook-server.yaml @@ -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 @@ -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 diff --git a/examples/config.yaml b/examples/config.yaml index dca84256e..b04067284 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -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 @@ -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 diff --git a/webhook_server/config/schema.yaml b/webhook_server/config/schema.yaml index 56147a948..51f9631b5 100644 --- a/webhook_server/config/schema.yaml +++ b/webhook_server/config/schema.yaml @@ -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') @@ -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') diff --git a/webhook_server/libs/handlers/labels_handler.py b/webhook_server/libs/handlers/labels_handler.py index f9aa59578..27796b790 100644 --- a/webhook_server/libs/handlers/labels_handler.py +++ b/webhook_server/libs/handlers/labels_handler.py @@ -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 diff --git a/webhook_server/tests/test_labels_handler.py b/webhook_server/tests/test_labels_handler.py index 62ed697e4..9276bd841 100644 --- a/webhook_server/tests/test_labels_handler.py +++ b/webhook_server/tests/test_labels_handler.py @@ -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)