diff --git a/.gitignore b/.gitignore index c5f1c416..bda9ae19 100644 --- a/.gitignore +++ b/.gitignore @@ -149,7 +149,11 @@ local-run.sh webhook-server.private-key.pem log-colors.json webhook_server/tests/manifests/logs - - .coverage_report.txt -.cursor + +# AI +.cursor/ +CLAUDE.md +.agent-os/ +.cursorrules +.claude/ diff --git a/README.md b/README.md index 23133ecf..429d021e 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ GitHub Events → Webhook Server → Repository Management - **Intelligent reviewer assignment** based on OWNERS files - **Automated labeling** including size calculation and status tracking +- **Configurable PR size labels** with custom names, thresholds, and colors - **Merge readiness validation** with comprehensive checks - **Issue tracking** with automatic creation and lifecycle management @@ -233,6 +234,23 @@ auto-verified-and-merged-users: - "renovate[bot]" - "dependabot[bot]" +# Global PR Size Labels (optional) +pr-size-thresholds: + Tiny: + threshold: 10 + color: lightgray + Small: + threshold: 50 + color: green + Medium: + threshold: 150 + color: orange + Large: + threshold: 300 + color: red + +# Threshold rules: PRs with changes ≥ threshold and < next-threshold get that label + # Docker Registry Access docker: username: your-docker-username @@ -277,6 +295,15 @@ repositories: can-be-merged-required-labels: - "approved" + # Repository-specific PR Size Labels (see global example above; values override at repository level) + pr-size-thresholds: + Express: + threshold: 25 + color: lightblue + Standard: + threshold: 100 + color: green + # Branch Protection protected-branches: main: @@ -294,6 +321,72 @@ repositories: - "trusted-bot[bot]" ``` +### Configurable PR Size Labels + +The webhook server supports configurable pull request size labels with custom names, +thresholds, and colors. This feature allows repository administrators to define +their own categorization system. + +#### Configuration Options + +```yaml +# Global configuration (applies to all repositories) +pr-size-thresholds: + Tiny: + threshold: 10 # Required: positive integer (lines changed) + color: lightgray # Optional: CSS3 color name, defaults to lightgray + Small: + threshold: 50 + color: green + Medium: + threshold: 150 + color: orange + Large: + threshold: 300 + color: red + +# Repository-specific configuration (overrides global) +repositories: + my-project: + name: my-org/my-project + pr-size-thresholds: + Express: + threshold: 25 + color: lightblue + Standard: + threshold: 100 + color: green + Premium: + threshold: 500 + color: orange +``` + +#### Configuration Rules + +- **threshold**: Required positive integer representing total lines changed + (additions + deletions) +- **color**: Optional CSS3 color name + (e.g., `red`, `green`, `orange`, `lightblue`, `darkred`) +- **Label Names**: Any string (e.g., `Tiny`, `Express`, `Premium`, `Critical`) +- **Hierarchy**: Repository-level configuration overrides global configuration +- **Fallback**: If no custom configuration is provided, uses default static labels + (XS, S, M, L, XL, XXL) + +#### Supported Color Names + +Any valid CSS3 color name is supported, including: + +- Basic colors: `red`, `green`, `blue`, `orange`, `yellow`, `purple` +- Extended colors: `lightgray`, `darkred`, `lightblue`, `darkorange` +- Grayscale: `black`, `white`, `gray`, `lightgray`, `darkgray` + +Invalid color names automatically fall back to `lightgray`. + +#### Real-time Updates + +Configuration changes take effect immediately without server restart. The webhook +server re-reads configuration for each incoming webhook event. + ### Repository-Level Overrides Create `.github-webhook-server.yaml` in your repository root to override or extend the global configuration for that specific repository. This file supports all repository-level configuration options. @@ -312,6 +405,18 @@ set-auto-merge-prs: - develop pre-commit: true conventional-title: "feat,fix,docs" + +# Custom PR size labels for this repository +pr-size-thresholds: + Quick: + threshold: 20 + color: lightgreen + Normal: + threshold: 100 + color: green + Complex: + threshold: 300 + color: orange ``` For a comprehensive example showing all available options, see [`examples/.github-webhook-server.yaml`](examples/.github-webhook-server.yaml). diff --git a/examples/.github-webhook-server.yaml b/examples/.github-webhook-server.yaml index 3043771c..9940acb3 100644 --- a/examples/.github-webhook-server.yaml +++ b/examples/.github-webhook-server.yaml @@ -97,3 +97,21 @@ minimum-lgtm: 2 # Issue creation for new pull requests 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 +# color: CSS3 color name (e.g., red, green, blue, lightgray, darkorange) +pr-size-thresholds: + Quick: + threshold: 20 # PRs with 0-19 lines changed + color: lightgreen + Normal: + threshold: 100 # PRs with 20-99 lines changed + color: green + Complex: + threshold: 300 # PRs with 100-299 lines changed + color: orange + Critical: + threshold: 1000 # PRs with 300-999 lines changed + color: darkred # PRs with 1000+ lines changed get this category diff --git a/examples/config.yaml b/examples/config.yaml index 8a535550..f5bdd216 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -28,6 +28,24 @@ auto-verified-and-merged-users: create-issue-for-new-pr: true # Global default: create tracking issues for new PRs +# 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 +# color: CSS3 color name (e.g., red, green, blue, lightgray, darkorange) +pr-size-thresholds: + Tiny: + threshold: 10 # PRs with 0-9 lines changed + color: lightgray + Small: + threshold: 50 # PRs with 10-49 lines changed + color: green + Medium: + threshold: 150 # PRs with 50-149 lines changed + color: orange + Large: + threshold: 300 # PRs with 150-299 lines changed + color: red # PRs with 300+ lines changed get this category + branch-protection: strict: True require_code_owner_reviews: True @@ -100,5 +118,18 @@ repositories: minimum-lgtm: 0 # The minimum PR lgtm required before approve the PR create-issue-for-new-pr: true # Override global setting: create tracking issues for new PRs (default: true) + + # Repository-specific PR size labels (overrides global configuration) + pr-size-thresholds: + Express: + threshold: 25 # PRs with 0-24 lines changed + color: lightblue + Standard: + threshold: 100 # PRs with 25-99 lines changed + color: green + Premium: + threshold: 500 # PRs with 100-499 lines changed + color: orange # PRs with 500+ lines changed get this category + set-auto-merge-prs: - main diff --git a/pyproject.toml b/pyproject.toml index c3171bb1..63232939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ dependencies = [ "uvicorn>=0.31.0", "httpx>=0.28.1", "asyncstdlib>=3.13.1", + "webcolors>=24.11.1", ] [[project.authors]] diff --git a/uv.lock b/uv.lock index 760f014d..8e956d6f 100644 --- a/uv.lock +++ b/uv.lock @@ -406,6 +406,7 @@ dependencies = [ { name = "string-color" }, { name = "timeout-sampler" }, { name = "uvicorn" }, + { name = "webcolors" }, ] [package.optional-dependencies] @@ -446,6 +447,7 @@ requires-dist = [ { name = "string-color", specifier = ">=1.2.3" }, { name = "timeout-sampler", specifier = ">=0.0.46" }, { name = "uvicorn", specifier = ">=0.31.0" }, + { name = "webcolors", specifier = ">=24.11.1" }, ] provides-extras = ["tests"] @@ -1201,6 +1203,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, ] +[[package]] +name = "webcolors" +version = "24.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/29/061ec845fb58521848f3739e466efd8250b4b7b98c1b6c5bf4d40b419b7e/webcolors-24.11.1.tar.gz", hash = "sha256:ecb3d768f32202af770477b8b65f318fa4f566c22948673a977b00d589dd80f6", size = 45064, upload-time = "2024-11-11T07:43:24.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/e8/c0e05e4684d13459f93d312077a9a2efbe04d59c393bc2b8802248c908d4/webcolors-24.11.1-py3-none-any.whl", hash = "sha256:515291393b4cdf0eb19c155749a096f779f7d909f7cceea072791cb9095b92e9", size = 14934, upload-time = "2024-11-11T07:43:22.529Z" }, +] + [[package]] name = "wrapt" version = "1.17.2" diff --git a/webhook_server/config/schema.yaml b/webhook_server/config/schema.yaml index a0393e45..dbf40a47 100644 --- a/webhook_server/config/schema.yaml +++ b/webhook_server/config/schema.yaml @@ -67,6 +67,23 @@ properties: description: Create a tracking issue for new pull requests (global default) default: true + pr-size-thresholds: + type: object + description: Custom PR size thresholds with label names and colors + additionalProperties: + type: object + properties: + threshold: + type: integer + minimum: 1 + description: Minimum number of changes (additions + deletions) for this size category + color: + type: string + description: CSS3 color name for the label (e.g., 'green', 'red', 'orange') + required: + - threshold + additionalProperties: false + branch-protection: type: object properties: @@ -227,3 +244,19 @@ properties: type: boolean description: Create a tracking issue for new pull requests default: true + pr-size-thresholds: + type: object + description: Custom PR size thresholds with label names and colors (repository-specific override) + additionalProperties: + type: object + properties: + threshold: + type: integer + minimum: 1 + description: Minimum number of changes (additions + deletions) for this size category + color: + type: string + description: CSS3 color name for the label (e.g., 'green', 'red', 'orange') + required: + - threshold + additionalProperties: false diff --git a/webhook_server/libs/labels_handler.py b/webhook_server/libs/labels_handler.py index 0e40f9b4..9bfc8a1f 100644 --- a/webhook_server/libs/labels_handler.py +++ b/webhook_server/libs/labels_handler.py @@ -1,6 +1,7 @@ import asyncio from typing import TYPE_CHECKING +import webcolors from github.GithubException import UnknownObjectException from github.PullRequest import PullRequest from timeout_sampler import TimeoutWatch @@ -68,14 +69,14 @@ async def _add_label(self, pull_request: PullRequest, label: str) -> None: self.logger.debug(f"{self.log_prefix} Label {label} already assign") return + # Handle static labels first (includes default size labels) if label in STATIC_LABELS_DICT: self.logger.info(f"{self.log_prefix} Adding pull request label {label}") await asyncio.to_thread(pull_request.add_to_labels, label) return - _color = [DYNAMIC_LABELS_DICT[_label] for _label in DYNAMIC_LABELS_DICT if _label in label] - self.logger.debug(f"{self.log_prefix} Label {label} was {'found' if _color else 'not found'} in labels dict") - color = _color[0] if _color else "D4C5F9" + # Determine color for the label + color = self._get_label_color(label) _with_color_msg = f"repository label {label} with color {color}" try: @@ -103,6 +104,91 @@ async def wait_for_label(self, pull_request: PullRequest, label: str, exists: bo self.logger.debug(f"{self.log_prefix} Label {label} {'not found' if exists else 'found'}") return False + def _get_label_color(self, label: str) -> str: + """Get the appropriate color for a label. + + For size labels with custom thresholds, uses the custom color. + For other dynamic labels, uses the DYNAMIC_LABELS_DICT. + Falls back to default color if not found. + """ + # Check if it's a custom size label + if label.startswith(SIZE_LABEL_PREFIX): + size_name = label[len(SIZE_LABEL_PREFIX) :] + + # Get custom thresholds and find the color for this size + thresholds = self._get_custom_pr_size_thresholds() + for threshold, label_name, color_hex in thresholds: + if label_name == size_name: + return color_hex + + # If not found in custom thresholds, check static labels dict + # (for backward compatibility with static size labels) + if label in STATIC_LABELS_DICT: + return STATIC_LABELS_DICT[label] + + # Check dynamic labels dict for other label types + _color = [DYNAMIC_LABELS_DICT[_label] for _label in DYNAMIC_LABELS_DICT if _label in label] + if _color: + return _color[0] + + # Default fallback color + return "D4C5F9" + + def _get_color_hex(self, color_name: str, default_color: str = "lightgray") -> str: + """Convert CSS3 color name to hex value, with fallback to default.""" + try: + # Remove '#' prefix if present and convert to hex + return webcolors.name_to_hex(color_name).lstrip("#") + except ValueError: + # Invalid color name, use default + self.logger.debug(f"{self.log_prefix} Invalid color name '{color_name}', using default '{default_color}'") + try: + return webcolors.name_to_hex(default_color).lstrip("#") + except ValueError: + # Fallback to hardcoded hex if default color name fails + return "d3d3d3" # lightgray hex + + def _get_custom_pr_size_thresholds(self) -> list[tuple[int | float, str, str]]: + """Get custom PR size thresholds from configuration with fallback to static defaults. + + Returns: + List of tuples (threshold, label_name, color_hex) sorted by threshold. + """ + custom_config = self.github_webhook.config.get_value("pr-size-thresholds", return_on_none=None) + + if not custom_config: + # Return static defaults with their colors + return [ + (20, "XS", "ededed"), + (50, "S", "0E8A16"), + (100, "M", "F09C74"), + (300, "L", "F5621C"), + (500, "XL", "D93F0B"), + (float("inf"), "XXL", "B60205"), + ] + + # Parse custom configuration + 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: + self.logger.warning(f"{self.log_prefix} Invalid threshold for '{label_name}': {threshold}") + continue + + color_name = config.get("color", "lightgray") + color_hex = self._get_color_hex(color_name) + + thresholds.append((threshold, label_name, color_hex)) + + # Sort by threshold value and ensure we have at least one threshold + sorted_thresholds = sorted(thresholds, key=lambda x: x[0]) + + if not sorted_thresholds: + self.logger.warning(f"{self.log_prefix} No valid custom thresholds found, using static defaults") + return self._get_custom_pr_size_thresholds() # Recursive call will return static defaults + + return sorted_thresholds + def get_size(self, pull_request: PullRequest) -> str: """Calculates size label based on additions and deletions.""" @@ -112,16 +198,21 @@ def get_size(self, pull_request: PullRequest) -> str: size = additions + deletions self.logger.debug(f"{self.log_prefix} PR size is {size} (additions: {additions}, deletions: {deletions})") - # Define label thresholds in a more readable way - threshold_sizes = [20, 50, 100, 300, 500] - prefixes = ["XS", "S", "M", "L", "XL"] + # Get custom or default thresholds + thresholds = self._get_custom_pr_size_thresholds() + + # Find the appropriate size category + for threshold, label_name, _ in thresholds: + if size < threshold: + return f"{SIZE_LABEL_PREFIX}{label_name}" - for i, size_threshold in enumerate(threshold_sizes): - if size < size_threshold: - _label = prefixes[i] - return f"{SIZE_LABEL_PREFIX}{_label}" + # If we reach here, PR is larger than all thresholds, use the largest category + if thresholds: + _, largest_label, _ = thresholds[-1] + return f"{SIZE_LABEL_PREFIX}{largest_label}" - return f"{SIZE_LABEL_PREFIX}XXL" + # Fallback (should not happen due to our default handling) + return f"{SIZE_LABEL_PREFIX}XL" async def add_size_label(self, pull_request: PullRequest) -> None: """Add a size label to the pull request based on its additions and deletions.""" diff --git a/webhook_server/tests/test_config_schema.py b/webhook_server/tests/test_config_schema.py index bbb2b8e9..1ceb7ad8 100644 --- a/webhook_server/tests/test_config_schema.py +++ b/webhook_server/tests/test_config_schema.py @@ -506,3 +506,192 @@ def test_create_issue_for_new_pr_configuration(self, monkeypatch: pytest.MonkeyP import shutil shutil.rmtree(temp_dir) + + def test_pr_size_thresholds_valid_configuration(self, valid_minimal_config: dict[str, Any]) -> None: + """Test that pr-size-thresholds accepts valid configuration with threshold and color.""" + config = valid_minimal_config.copy() + config["pr-size-thresholds"] = { + "Small": {"threshold": 100, "color": "green"}, + "Large": {"threshold": 500, "color": "red"}, + } + + temp_dir = self.create_temp_config_dir_and_data(config) + + try: + config_file = os.path.join(temp_dir, "config.yaml") + with open(config_file, "r") as file_handle: + data = yaml.safe_load(file_handle) + pr_thresholds = data["pr-size-thresholds"] + assert pr_thresholds["Small"]["threshold"] == 100 + assert pr_thresholds["Small"]["color"] == "green" + assert pr_thresholds["Large"]["threshold"] == 500 + assert pr_thresholds["Large"]["color"] == "red" + finally: + import shutil + + shutil.rmtree(temp_dir) + + def test_pr_size_thresholds_repository_level(self, valid_minimal_config: dict[str, Any]) -> None: + """Test that pr-size-thresholds works at repository level.""" + config = valid_minimal_config.copy() + config["repositories"]["test-repo"]["pr-size-thresholds"] = { + "Express": {"threshold": 25, "color": "lightgray"}, + "Standard": {"threshold": 100, "color": "green"}, + "Extended": {"threshold": 300, "color": "orange"}, + } + + temp_dir = self.create_temp_config_dir_and_data(config) + + try: + config_file = os.path.join(temp_dir, "config.yaml") + with open(config_file, "r") as file_handle: + data = yaml.safe_load(file_handle) + repo_thresholds = data["repositories"]["test-repo"]["pr-size-thresholds"] + assert repo_thresholds["Express"]["threshold"] == 25 + assert repo_thresholds["Express"]["color"] == "lightgray" + assert repo_thresholds["Extended"]["threshold"] == 300 + assert repo_thresholds["Extended"]["color"] == "orange" + finally: + import shutil + + shutil.rmtree(temp_dir) + + def test_pr_size_thresholds_various_color_names(self, valid_minimal_config: dict[str, Any]) -> None: + """Test that pr-size-thresholds accepts various CSS3 color names.""" + config = valid_minimal_config.copy() + config["pr-size-thresholds"] = { + "Tiny": {"threshold": 10, "color": "lightgray"}, + "Small": {"threshold": 50, "color": "green"}, + "Medium": {"threshold": 150, "color": "orange"}, + "Large": {"threshold": 300, "color": "darkorange"}, + "Huge": {"threshold": 1000, "color": "red"}, + "Massive": {"threshold": 2000, "color": "darkred"}, + } + + temp_dir = self.create_temp_config_dir_and_data(config) + + try: + config_file = os.path.join(temp_dir, "config.yaml") + with open(config_file, "r") as file_handle: + data = yaml.safe_load(file_handle) + pr_thresholds = data["pr-size-thresholds"] + assert len(pr_thresholds) == 6 + assert pr_thresholds["Tiny"]["color"] == "lightgray" + assert pr_thresholds["Massive"]["threshold"] == 2000 + finally: + import shutil + + shutil.rmtree(temp_dir) + + def test_pr_size_thresholds_missing_fields(self, valid_minimal_config: dict[str, Any]) -> None: + """Test handling of pr-size-thresholds with missing threshold or color fields.""" + # Test missing threshold + config = valid_minimal_config.copy() + config["pr-size-thresholds"] = { + "Small": {"color": "green"}, # missing threshold + } + + temp_dir = self.create_temp_config_dir_and_data(config) + + try: + config_file = os.path.join(temp_dir, "config.yaml") + with open(config_file, "r") as file_handle: + data = yaml.safe_load(file_handle) + # Should still load, validation will happen at runtime + assert "pr-size-thresholds" in data + finally: + import shutil + + shutil.rmtree(temp_dir) + + # Test missing color (should be acceptable with fallback) + config2 = valid_minimal_config.copy() + config2["pr-size-thresholds"] = { + "Small": {"threshold": 100}, # missing color + } + + temp_dir2 = self.create_temp_config_dir_and_data(config2) + + try: + config_file = os.path.join(temp_dir2, "config.yaml") + with open(config_file, "r") as file_handle: + data = yaml.safe_load(file_handle) + assert data["pr-size-thresholds"]["Small"]["threshold"] == 100 + finally: + import shutil + + shutil.rmtree(temp_dir2) + + def test_pr_size_thresholds_invalid_threshold_values(self, valid_minimal_config: dict[str, Any]) -> None: + """Test pr-size-thresholds with invalid threshold values.""" + # Test negative threshold + config = valid_minimal_config.copy() + config["pr-size-thresholds"] = { + "Small": {"threshold": -10, "color": "green"}, + } + + temp_dir = self.create_temp_config_dir_and_data(config) + + try: + config_file = os.path.join(temp_dir, "config.yaml") + with open(config_file, "r") as file_handle: + data = yaml.safe_load(file_handle) + # Config loads, but validation should catch this at runtime + assert data["pr-size-thresholds"]["Small"]["threshold"] == -10 + finally: + import shutil + + shutil.rmtree(temp_dir) + + # Test zero threshold + config2 = valid_minimal_config.copy() + config2["pr-size-thresholds"] = { + "Small": {"threshold": 0, "color": "green"}, + } + + temp_dir2 = self.create_temp_config_dir_and_data(config2) + + try: + config_file = os.path.join(temp_dir2, "config.yaml") + with open(config_file, "r") as file_handle: + data = yaml.safe_load(file_handle) + assert data["pr-size-thresholds"]["Small"]["threshold"] == 0 + finally: + import shutil + + shutil.rmtree(temp_dir2) + + # Test non-integer threshold + config3 = valid_minimal_config.copy() + config3["pr-size-thresholds"] = { + "Small": {"threshold": "not-a-number", "color": "green"}, + } + + temp_dir3 = self.create_temp_config_dir_and_data(config3) + + try: + config_file = os.path.join(temp_dir3, "config.yaml") + with open(config_file, "r") as file_handle: + data = yaml.safe_load(file_handle) + assert data["pr-size-thresholds"]["Small"]["threshold"] == "not-a-number" + finally: + import shutil + + shutil.rmtree(temp_dir3) + + def test_pr_size_thresholds_empty_configuration(self, valid_minimal_config: dict[str, Any]) -> None: + """Test that empty pr-size-thresholds configuration is handled properly.""" + config = valid_minimal_config.copy() + config["pr-size-thresholds"] = {} + + temp_dir = self.create_temp_config_dir_and_data(config) + + try: + config_file = os.path.join(temp_dir, "config.yaml") + with open(config_file, "r") as file_handle: + data = yaml.safe_load(file_handle) + assert data["pr-size-thresholds"] == {} + finally: + import shutil + + shutil.rmtree(temp_dir) diff --git a/webhook_server/tests/test_labels_handler.py b/webhook_server/tests/test_labels_handler.py index fb80e5e9..18a57df7 100644 --- a/webhook_server/tests/test_labels_handler.py +++ b/webhook_server/tests/test_labels_handler.py @@ -46,6 +46,9 @@ def mock_github_webhook(self) -> Mock: webhook.repository = Mock() webhook.log_prefix = "[TEST]" webhook.logger = Mock() + # Configure config.get_value to return None for pr-size-thresholds by default + # This ensures existing tests use static defaults + webhook.config.get_value.return_value = None return webhook @pytest.fixture @@ -724,3 +727,208 @@ def test_wip_or_hold_lables_exists_neither(self, labels_handler: LabelsHandler) labels = ["other-label1", "other-label2"] result = labels_handler.wip_or_hold_lables_exists(labels) assert result == "" + + def test_get_custom_pr_size_thresholds_config_available(self, mock_github_webhook: Mock) -> None: + """Test parsing custom PR size thresholds from configuration.""" + # Mock config returning custom thresholds + mock_github_webhook.config.get_value.return_value = { + "Small": {"threshold": 100, "color": "green"}, + "Large": {"threshold": 500, "color": "red"}, + } + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + # Create a method to get custom thresholds (will be implemented) + thresholds = labels_handler._get_custom_pr_size_thresholds() + + expected = [ + (100, "Small", "008000"), # green hex + (500, "Large", "ff0000"), # red hex + ] + assert thresholds == expected + mock_github_webhook.config.get_value.assert_called_once_with("pr-size-thresholds", return_on_none=None) + + def test_get_custom_pr_size_thresholds_no_config(self, mock_github_webhook: Mock) -> None: + """Test fallback to static thresholds when no custom config available.""" + # Mock config returning None (no custom thresholds) + mock_github_webhook.config.get_value.return_value = None + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + thresholds = labels_handler._get_custom_pr_size_thresholds() + + # Should return static defaults + expected = [ + (20, "XS", "ededed"), + (50, "S", "0E8A16"), + (100, "M", "F09C74"), + (300, "L", "F5621C"), + (500, "XL", "D93F0B"), + (float("inf"), "XXL", "B60205"), + ] + assert thresholds == expected + + def test_get_custom_pr_size_thresholds_missing_color(self, mock_github_webhook: Mock) -> None: + """Test custom thresholds with missing color fallback to default.""" + # Mock config with missing color + mock_github_webhook.config.get_value.return_value = { + "Small": {"threshold": 100}, # missing color + "Large": {"threshold": 500, "color": "red"}, + } + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + thresholds = labels_handler._get_custom_pr_size_thresholds() + + expected = [ + (100, "Small", "d3d3d3"), # lightgray hex + (500, "Large", "ff0000"), # red hex + ] + assert thresholds == expected + + def test_get_custom_pr_size_thresholds_invalid_color(self, mock_github_webhook: Mock) -> None: + """Test custom thresholds with invalid color fallback to default.""" + # Mock config with invalid color + mock_github_webhook.config.get_value.return_value = { + "Small": {"threshold": 100, "color": "invalidcolor"}, + "Large": {"threshold": 500, "color": "red"}, + } + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + thresholds = labels_handler._get_custom_pr_size_thresholds() + + expected = [ + (100, "Small", "d3d3d3"), # lightgray hex fallback + (500, "Large", "ff0000"), # red hex + ] + assert thresholds == expected + + def test_get_size_with_custom_thresholds(self, mock_github_webhook: Mock) -> None: + """Test get_size using custom thresholds.""" + # Mock config with custom thresholds + mock_github_webhook.config.get_value.return_value = { + "Tiny": {"threshold": 10, "color": "lightgray"}, + "Small": {"threshold": 50, "color": "green"}, + "Large": {"threshold": 200, "color": "red"}, + } + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + # Test various PR sizes + test_cases = [ + (5, 0, "size/Tiny"), # 5 < 10 + (15, 0, "size/Small"), # 15 >= 10 but < 50 + (75, 0, "size/Large"), # 75 >= 50 but < 200 + (250, 0, "size/Large"), # 250 >= 200 (largest category) + ] + + 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 + + def test_get_size_with_single_custom_threshold(self, mock_github_webhook: Mock) -> None: + """Test get_size with only one custom threshold.""" + # Mock config with single threshold + mock_github_webhook.config.get_value.return_value = { + "Large": {"threshold": 100, "color": "red"}, + } + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + # Test PR sizes + test_cases = [ + (50, 0, "size/Large"), # 50 < 100 but still gets Large (only category) + (150, 0, "size/Large"), # 150 >= 100, gets Large + ] + + 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 + + def test_custom_threshold_sorting(self, mock_github_webhook: Mock) -> None: + """Test that custom thresholds are properly sorted by threshold value.""" + # Mock config with unsorted thresholds + mock_github_webhook.config.get_value.return_value = { + "Large": {"threshold": 300, "color": "red"}, + "Small": {"threshold": 50, "color": "green"}, + "Medium": {"threshold": 150, "color": "orange"}, + } + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + thresholds = labels_handler._get_custom_pr_size_thresholds() + + # Should be sorted by threshold value + expected = [ + (50, "Small", "008000"), # green hex + (150, "Medium", "ffa500"), # orange hex + (300, "Large", "ff0000"), # red hex + ] + assert thresholds == expected + + def test_get_label_color_custom_size_label(self, mock_github_webhook: Mock) -> None: + """Test _get_label_color for custom size labels.""" + # Mock config with custom thresholds + mock_github_webhook.config.get_value.return_value = { + "Small": {"threshold": 100, "color": "green"}, + "Large": {"threshold": 500, "color": "red"}, + } + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + # Test custom size label colors + assert labels_handler._get_label_color("size/Small") == "008000" # green hex + assert labels_handler._get_label_color("size/Large") == "ff0000" # red hex + + def test_get_label_color_static_size_label(self, mock_github_webhook: Mock) -> None: + """Test _get_label_color falls back to static size labels when no custom config.""" + # Mock config returning None (no custom thresholds) + mock_github_webhook.config.get_value.return_value = None + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + # Test static size label colors (should fall back to STATIC_LABELS_DICT) + assert labels_handler._get_label_color("size/XS") == "ededed" + assert labels_handler._get_label_color("size/S") == "0E8A16" + assert labels_handler._get_label_color("size/M") == "F09C74" + + def test_get_label_color_dynamic_label(self, mock_github_webhook: Mock) -> None: + """Test _get_label_color for dynamic labels (non-size).""" + mock_github_webhook.config.get_value.return_value = None + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + # Test dynamic label colors + assert labels_handler._get_label_color("approved-user1") == "0E8A16" # APPROVED_BY_LABEL_PREFIX + assert labels_handler._get_label_color("lgtm-user2") == "DCED6F" # LGTM_BY_LABEL_PREFIX + + def test_get_label_color_fallback(self, mock_github_webhook: Mock) -> None: + """Test _get_label_color fallback to default color.""" + mock_github_webhook.config.get_value.return_value = None + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=Mock()) + + # Test unknown label falls back to default + assert labels_handler._get_label_color("unknown-label") == "D4C5F9" + + def test_get_label_color_custom_size_not_found(self, mock_github_webhook: Mock) -> None: + """Test _get_label_color when custom size label not found in thresholds.""" + # Mock config with custom thresholds but missing the requested size + mock_github_webhook.config.get_value.return_value = { + "Small": {"threshold": 100, "color": "green"}, + } + + labels_handler = LabelsHandler(github_webhook=mock_github_webhook, owners_file_handler=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