From aedc711b6797fb686fb0e82234aa1c0537847266 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Tue, 29 Jul 2025 16:23:44 +0300 Subject: [PATCH 1/4] feat: add OMG PR size --- webhook_server/libs/labels_handler.py | 4 ++-- webhook_server/utils/constants.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/webhook_server/libs/labels_handler.py b/webhook_server/libs/labels_handler.py index 0e40f9b4..618c565a 100644 --- a/webhook_server/libs/labels_handler.py +++ b/webhook_server/libs/labels_handler.py @@ -113,7 +113,7 @@ def get_size(self, pull_request: PullRequest) -> str: 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] + threshold_sizes = [20, 50, 100, 300, 500, 1000] prefixes = ["XS", "S", "M", "L", "XL"] for i, size_threshold in enumerate(threshold_sizes): @@ -121,7 +121,7 @@ def get_size(self, pull_request: PullRequest) -> str: _label = prefixes[i] return f"{SIZE_LABEL_PREFIX}{_label}" - return f"{SIZE_LABEL_PREFIX}XXL" + return f"{SIZE_LABEL_PREFIX}OMG" 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/utils/constants.py b/webhook_server/utils/constants.py index ebd4d1ec..b2fe2b5b 100644 --- a/webhook_server/utils/constants.py +++ b/webhook_server/utils/constants.py @@ -56,6 +56,7 @@ f"{SIZE_LABEL_PREFIX}XL": "D93F0B", f"{SIZE_LABEL_PREFIX}XS": "ededed", f"{SIZE_LABEL_PREFIX}XXL": "B60205", + f"{SIZE_LABEL_PREFIX}OMG": "B60205", NEEDS_REBASE_LABEL_STR: "B60205", CAN_BE_MERGED_STR: "0E8A17", HAS_CONFLICTS_LABEL_STR: "B60205", From d9b9eccf5f56e0e18c2a42cfb04b54e29edfeaae Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Wed, 30 Jul 2025 13:01:25 +0300 Subject: [PATCH 2/4] feat: add configurable PR size labels with custom names, thresholds, and colors This feature allows repository administrators to define custom PR size categorization with flexible naming, threshold values, and colors. Key Features: - Custom label names (e.g., Tiny, Express, Premium instead of XS, S, M) - Configurable threshold values for lines changed (additions + deletions) - CSS3 color name support with automatic hex conversion via webcolors - 3-level configuration hierarchy (local > repository > global) - Real-time configuration updates without server restart - Fallback to static defaults when no custom config provided - Comprehensive test coverage for all functionality Technical Changes: - Added pr-size-thresholds schema validation for both global and repository levels - Implemented dynamic threshold processing with automatic sorting - Added webcolors dependency for CSS3 color name to hex conversion - Fixed type annotations to handle float('inf') with int | float union type - Converted all configuration keys to kebab-case for consistency - Updated existing tests to properly mock config.get_value return values - Added comprehensive test suite for configuration validation and functionality Documentation Updates: - Added configurable PR size labels section to README with examples - Updated example configuration files with pr-size-thresholds examples - Added configuration naming standards to decisions.md (DEC-004) - Added commit message standards to decisions.md (DEC-005) - Updated repository-level override examples Breaking Changes: None - fully backward compatible with existing static labels --- .gitignore | 10 +- README.md | 99 ++++++++++ examples/.github-webhook-server.yaml | 18 ++ examples/config.yaml | 31 +++ pyproject.toml | 1 + uv.lock | 11 ++ webhook_server/config/schema.yaml | 33 ++++ webhook_server/libs/labels_handler.py | 113 +++++++++-- webhook_server/tests/test_config_schema.py | 189 ++++++++++++++++++ webhook_server/tests/test_labels_handler.py | 208 ++++++++++++++++++++ webhook_server/utils/constants.py | 1 - 11 files changed, 699 insertions(+), 15 deletions(-) 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..4561fe55 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,21 @@ 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 + # Docker Registry Access docker: username: your-docker-username @@ -277,6 +293,18 @@ repositories: can-be-merged-required-labels: - "approved" + # Repository-specific PR Size Labels (overrides global) + pr-size-thresholds: + Express: + threshold: 25 + color: lightblue + Standard: + threshold: 100 + color: green + Premium: + threshold: 500 + color: orange + # Branch Protection protected-branches: main: @@ -294,6 +322,65 @@ 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 +399,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 618c565a..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, 1000] - 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}OMG" + # 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 diff --git a/webhook_server/utils/constants.py b/webhook_server/utils/constants.py index b2fe2b5b..ebd4d1ec 100644 --- a/webhook_server/utils/constants.py +++ b/webhook_server/utils/constants.py @@ -56,7 +56,6 @@ f"{SIZE_LABEL_PREFIX}XL": "D93F0B", f"{SIZE_LABEL_PREFIX}XS": "ededed", f"{SIZE_LABEL_PREFIX}XXL": "B60205", - f"{SIZE_LABEL_PREFIX}OMG": "B60205", NEEDS_REBASE_LABEL_STR: "B60205", CAN_BE_MERGED_STR: "0E8A17", HAS_CONFLICTS_LABEL_STR: "B60205", From ac2203731c0932fe9cb895b52c5c9108fe379332 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Wed, 30 Jul 2025 14:43:55 +0300 Subject: [PATCH 3/4] docs: improve README formatting for PR size labels section Break up longer lines in the configurable PR size labels documentation to improve readability while maintaining comprehensive coverage of: - Configuration syntax and rules - Supported CSS3 color names with examples - Hierarchy and fallback behavior - Real-time update semantics --- README.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4561fe55..ab6b008f 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,9 @@ repositories: ### 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. +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 @@ -362,11 +364,14 @@ repositories: #### Configuration Rules -- **threshold**: Required positive integer representing total lines changed (additions + deletions) -- **color**: Optional CSS3 color name (e.g., `red`, `green`, `orange`, `lightblue`, `darkred`) +- **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) +- **Fallback**: If no custom configuration is provided, uses default static labels + (XS, S, M, L, XL, XXL) #### Supported Color Names @@ -379,7 +384,8 @@ 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. +Configuration changes take effect immediately without server restart. The webhook +server re-reads configuration for each incoming webhook event. ### Repository-Level Overrides From 517cf8414852ff26bed0573766e75f50bda3e87f Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Wed, 30 Jul 2025 14:51:03 +0300 Subject: [PATCH 4/4] docs: improve README clarity and reduce duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add threshold semantics clarification: PRs with changes ≥ threshold and < next-threshold get that label - Reduce duplicate YAML example by referencing global configuration - Fix markdown-lint MD032 by adding blank line before list - Keep README lean while maintaining comprehensive information --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ab6b008f..429d021e 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,8 @@ pr-size-thresholds: threshold: 300 color: red +# Threshold rules: PRs with changes ≥ threshold and < next-threshold get that label + # Docker Registry Access docker: username: your-docker-username @@ -293,7 +295,7 @@ repositories: can-be-merged-required-labels: - "approved" - # Repository-specific PR Size Labels (overrides global) + # Repository-specific PR Size Labels (see global example above; values override at repository level) pr-size-thresholds: Express: threshold: 25 @@ -301,9 +303,6 @@ repositories: Standard: threshold: 100 color: green - Premium: - threshold: 500 - color: orange # Branch Protection protected-branches: @@ -376,6 +375,7 @@ repositories: #### 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`