diff --git a/AGENTS.md b/AGENTS.md index 994117c5d..3e82543f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ Handlers in `webhook_server/libs/handlers/` process events; config via YAML with See `docs/` for generated architecture docs (regenerate with docsfy — **NEVER edit `docs/` manually**). - Stack: Python 3.13, FastAPI, PyGithub, gql, aiohttp -- Internal APIs — no backward compat; only `config.yaml`, `.github-webhook-server.yaml`, and webhook payloads are stable +- Internal APIs — no backward compat; only `config.yaml`, `.github-webhook-server.yaml`, `.github-webhook-server-welcome-message.md`, and webhook payloads are stable - Config: `webhook_server/libs/config.py` (schema: `webhook_server/config/schema.yaml`) - GitHub API: `webhook_server/libs/github_api.py` — PyGithub REST v3, multi-token failover - Log viewer: `webhook_server/web/log_viewer.py` — WebSocket streaming @@ -61,7 +61,8 @@ pr.create_issue_comment("Comment") ### Anti-Defensive Programming: Fail-Fast ```python # ❌ WRONG — config is required, ALWAYS provided -if self.config: value = self.config.get_value("key") +if self.config: + value = self.config.get_value("key") # ✅ CORRECT — fail-fast; KeyError = legitimate bug value = self.config.get_value("key") diff --git a/examples/config.yaml b/examples/config.yaml index cd77362f0..23373f90f 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -35,6 +35,12 @@ create-issue-for-new-pr: true # Global default: create tracking issues for new P cherry-pick-assign-to-pr-author: true # Default: true - assign cherry-pick PRs to the original PR author +# Additional information to display at the end of PR welcome messages (markdown) +# welcome-extra-info: | +# **Note:** Please review the contribution guide before merging. +# - Ensure tests pass +# - Update documentation if needed + # Commands allowed on draft PRs (optional) # If not set: commands are blocked on draft PRs (default behavior) # If empty list []: all commands allowed on draft PRs diff --git a/webhook_server/config/schema.yaml b/webhook_server/config/schema.yaml index 815b99545..acaa37830 100644 --- a/webhook_server/config/schema.yaml +++ b/webhook_server/config/schema.yaml @@ -106,6 +106,13 @@ $defs: items: type: string additionalProperties: false + welcome-extra-info: + type: string + maxLength: 10240 + description: | + Additional information to display at the end of the PR welcome message. + Content is injected as-is (markdown). + An empty string explicitly clears any inherited value. type: object properties: log-level: @@ -251,6 +258,8 @@ properties: additionalProperties: false ai-features: $ref: '#/$defs/ai-features' + welcome-extra-info: + $ref: '#/$defs/welcome-extra-info' security-checks: $ref: '#/$defs/security-checks' labels: @@ -523,6 +532,8 @@ properties: items: type: string description: Required labels for PR to be marked as can-be-merged + welcome-extra-info: + $ref: '#/$defs/welcome-extra-info' conventional-title: type: string description: | diff --git a/webhook_server/libs/github_api.py b/webhook_server/libs/github_api.py index deef20fa4..38c72c12c 100644 --- a/webhook_server/libs/github_api.py +++ b/webhook_server/libs/github_api.py @@ -17,6 +17,7 @@ import httpx from github import GithubException from github.Commit import Commit +from github.GithubException import UnknownObjectException from github.PullRequest import PullRequest from github.Repository import Repository from starlette.datastructures import Headers @@ -62,6 +63,8 @@ ) _SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") +_WELCOME_EXTRA_INFO_MAX_BYTES: int = 10240 +_WELCOME_EXTRA_INFO_FILENAME: str = ".github-webhook-server-welcome-message.md" class CountingRequester: @@ -142,6 +145,7 @@ def __init__(self, hook_data: dict[Any, Any], headers: Headers, logger: logging. self.github_api: github.Github | None = None self.initial_rate_limit_remaining: int | None = None self.requester_wrapper: CountingRequester | None = None + self.welcome_extra_info: str = "" if not self.config.repository_data: raise RepositoryNotFoundInConfigError(f"Repository {self.repository_name} not found in config file") @@ -689,6 +693,12 @@ async def process(self) -> Any: github_api_call(lambda: pull_request.head.sha, logger=self.logger, log_prefix=self.log_prefix), ) + if self.github_event == "pull_request" and self.hook_data.get("action") in ( + "opened", + "ready_for_review", + ): + await self.load_welcome_extra_info_from_file() + # Clone repository for local file processing (OWNERS, changed files) # For check_run, status, and pull_request_review_thread events, # cloning happens later only when needed (inside their respective handlers) @@ -1109,6 +1119,23 @@ def _sanitize_item(item: Any) -> str: self.mask_sensitive = self.config.get_value("mask-sensitive-data", return_on_none=True) + self.welcome_extra_info = self.config.get_value( + "welcome-extra-info", return_on_none="", extra_dict=repository_config + ) + + _prefix = getattr(self, "log_prefix", "") + if not isinstance(self.welcome_extra_info, str): + _type = type(self.welcome_extra_info).__name__ + self.logger.warning(f"{_prefix} welcome-extra-info must be a string, got {_type}. Ignoring.") + self.welcome_extra_info = "" + else: + _byte_len = len(self.welcome_extra_info.encode("utf-8")) + if _byte_len > _WELCOME_EXTRA_INFO_MAX_BYTES: + _max = _WELCOME_EXTRA_INFO_MAX_BYTES + _msg = f"welcome-extra-info exceeds {_max}-byte limit ({_byte_len} bytes). Ignoring." + self.logger.warning(f"{_prefix} {_msg}") + self.welcome_extra_info = "" + async def _build_trusted_committers(self, api_users: list[str | None] | None = None) -> None: """Add dynamic entries to trusted-committers list. @@ -1142,6 +1169,50 @@ async def _build_trusted_committers(self, api_users: list[str | None] | None = N self.logger.debug(f"{self.log_prefix} Trusted committers: {self.security_trusted_committers}") + async def load_welcome_extra_info_from_file(self) -> None: + """Load welcome extra info from the repo-level welcome message file. + + This file takes priority over all config-based welcome-extra-info settings. + File must be UTF-8 and at most _WELCOME_EXTRA_INFO_MAX_BYTES bytes (10 KB). + """ + try: + _path = await github_api_call( + lambda: self.repository.get_contents(_WELCOME_EXTRA_INFO_FILENAME), + logger=self.logger, + log_prefix=self.log_prefix, + ) + content_file = _path[0] if isinstance(_path, list) else _path + + file_size = await github_api_call(lambda: content_file.size, logger=self.logger, log_prefix=self.log_prefix) + if file_size > _WELCOME_EXTRA_INFO_MAX_BYTES: + self.logger.warning( + f"{self.log_prefix} {_WELCOME_EXTRA_INFO_FILENAME} is too large " + f"({file_size} bytes, max {_WELCOME_EXTRA_INFO_MAX_BYTES}). Skipping file, using config value." + ) + return + + raw_content = await github_api_call( + lambda: content_file.decoded_content, logger=self.logger, log_prefix=self.log_prefix + ) + decoded = raw_content.decode("utf-8").strip() + self.welcome_extra_info = decoded + if decoded: + self.logger.info( + f"{self.log_prefix} Loaded welcome extra info from {_WELCOME_EXTRA_INFO_FILENAME} " + f"({len(decoded)} chars)" + ) + else: + self.logger.info( + f"{self.log_prefix} Empty {_WELCOME_EXTRA_INFO_FILENAME} found, " + "suppressing config-based welcome extra info" + ) + except UnknownObjectException: + self.logger.debug(f"{self.log_prefix} {_WELCOME_EXTRA_INFO_FILENAME} not found, using config value") + except UnicodeDecodeError: + self.logger.warning(f"{self.log_prefix} {_WELCOME_EXTRA_INFO_FILENAME} is not valid UTF-8, skipping file") + except Exception: + self.logger.exception(f"{self.log_prefix} Error loading {_WELCOME_EXTRA_INFO_FILENAME}, using config value") + async def get_pull_request(self, number: int | None = None) -> PullRequest | None: if number: self.logger.debug(f"{self.log_prefix} Attempting to get PR by number: {number}") diff --git a/webhook_server/libs/handlers/pull_request_handler.py b/webhook_server/libs/handlers/pull_request_handler.py index a511123a5..df9801471 100644 --- a/webhook_server/libs/handlers/pull_request_handler.py +++ b/webhook_server/libs/handlers/pull_request_handler.py @@ -527,6 +527,7 @@ def _prepare_welcome_comment(self) -> str: ### 💡 Tips {self._prepare_tips_section} +{self._prepare_extra_info_welcome_section}\ For more information, please refer to the project documentation or contact the maintainers. """ @@ -691,6 +692,19 @@ def _prepare_tips_section(self) -> str: return "\n".join(tips) + @property + def _prepare_extra_info_welcome_section(self) -> str: + """Prepare the Additional Information section for the welcome comment. + + Renders user-provided extra information from configuration or + .github-webhook-server-welcome-message.md file. + Content is injected as-is (markdown). + """ + if not self.github_webhook.welcome_extra_info: + return "" + + return f"\n### 📌 Additional Information\n\n{self.github_webhook.welcome_extra_info}\n" + @property def _prepare_ai_features_welcome_section(self) -> str: """Prepare the AI Features section for the welcome comment. @@ -1920,6 +1934,7 @@ async def regenerate_welcome_message(self, pull_request: PullRequest) -> None: If a welcome message exists, it will be updated. If no welcome message exists, a new one will be created. """ + await self.github_webhook.load_welcome_extra_info_from_file() welcome_msg = self._prepare_welcome_comment() def find_and_update_welcome_comment() -> bool: @@ -1959,6 +1974,8 @@ async def process_new_or_reprocess_pull_request(self, pull_request: PullRequest) # Add welcome message if it doesn't exist yet if not await self._welcome_comment_exists(pull_request=pull_request): + # Load file-based welcome extra info only when building the welcome message + await self.github_webhook.load_welcome_extra_info_from_file() self.logger.info(f"{self.log_prefix} Adding welcome message to PR") welcome_msg = self._prepare_welcome_comment() tasks.append( diff --git a/webhook_server/tests/test_config_schema.py b/webhook_server/tests/test_config_schema.py index 6a6002570..58ec99193 100644 --- a/webhook_server/tests/test_config_schema.py +++ b/webhook_server/tests/test_config_schema.py @@ -1006,3 +1006,68 @@ def test_ai_features_resolve_cherry_pick_conflicts_with_ai_repository_level( } finally: shutil.rmtree(temp_dir) + + def test_welcome_extra_info_global_valid( + self, valid_minimal_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test valid welcome-extra-info at global level.""" + config = valid_minimal_config.copy() + config["welcome-extra-info"] = "Please review guidelines." + + temp_dir = self.create_temp_config_dir_and_data(config) + + try: + monkeypatch.setenv("WEBHOOK_SERVER_DATA_DIR", temp_dir) + + config_obj = Config() + assert config_obj.root_data["welcome-extra-info"] == "Please review guidelines." + finally: + shutil.rmtree(temp_dir) + + def test_welcome_extra_info_schema_defines_string_type(self) -> None: + """Test that schema defines welcome-extra-info as type string via $ref.""" + schema_path = os.path.join(os.path.dirname(__file__), "..", "config", "schema.yaml") + with open(schema_path) as f: + schema = yaml.safe_load(f) + + # Verify $defs definition + defs = schema["$defs"] + assert "welcome-extra-info" in defs + assert defs["welcome-extra-info"]["type"] == "string" + + # Global level uses $ref + global_props = schema["properties"] + assert "welcome-extra-info" in global_props + assert global_props["welcome-extra-info"]["$ref"] == "#/$defs/welcome-extra-info" + + # Per-repo level uses $ref + repo_props = schema["properties"]["repositories"]["additionalProperties"]["properties"] + assert "welcome-extra-info" in repo_props + assert repo_props["welcome-extra-info"]["$ref"] == "#/$defs/welcome-extra-info" + + def test_welcome_extra_info_repository_level( + self, valid_minimal_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test valid welcome-extra-info at per-repo level.""" + config = valid_minimal_config.copy() + config["repositories"]["test-repo"]["welcome-extra-info"] = "Repo-specific welcome info." + + temp_dir = self.create_temp_config_dir_and_data(config) + + try: + monkeypatch.setenv("WEBHOOK_SERVER_DATA_DIR", temp_dir) + + config_obj = Config() + repo_data = config_obj.root_data["repositories"]["test-repo"] + assert repo_data["welcome-extra-info"] == "Repo-specific welcome info." + finally: + shutil.rmtree(temp_dir) + + def test_welcome_extra_info_schema_defines_max_length(self) -> None: + """Test that schema defines welcome-extra-info with maxLength 10240 in $defs.""" + schema_path = os.path.join(os.path.dirname(__file__), "..", "config", "schema.yaml") + with open(schema_path) as f: + schema = yaml.safe_load(f) + + # maxLength is in the $defs definition (both levels reference it via $ref) + assert schema["$defs"]["welcome-extra-info"]["maxLength"] == 10240 diff --git a/webhook_server/tests/test_github_api.py b/webhook_server/tests/test_github_api.py index fecbe9258..35ced39a7 100644 --- a/webhook_server/tests/test_github_api.py +++ b/webhook_server/tests/test_github_api.py @@ -1,13 +1,13 @@ import asyncio import os import tempfile -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Generator from pathlib import Path from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from github.GithubException import GithubException +from github.GithubException import GithubException, UnknownObjectException from simple_logger.logger import get_logger from starlette.datastructures import Headers @@ -2812,3 +2812,139 @@ def mock_get_value(value: str, return_on_none: Any = None, extra_dict: Any = Non "version": True, "title": True, } + + +class TestLoadWelcomeExtraInfoFromFile: + """Tests for load_welcome_extra_info_from_file.""" + + @pytest.fixture() + def github_webhook(self, process_github_webhook: GithubWebhook) -> GithubWebhook: + """Create a GithubWebhook instance for welcome extra info tests.""" + gw = process_github_webhook + gw.welcome_extra_info = "config value" + gw.repository = MagicMock() + return gw + + @pytest.fixture(autouse=True) + def patch_to_thread(self) -> Generator[None]: + async def mock_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any: + return func(*args, **kwargs) if callable(func) else func + + with patch("asyncio.to_thread", side_effect=mock_to_thread): + yield + + @pytest.mark.asyncio() + async def test_load_from_file_success(self, github_webhook: GithubWebhook) -> None: + """Test successful loading of welcome extra info from repository file.""" + content_file = MagicMock() + content_file.size = 100 + content_file.decoded_content = b"# Custom welcome\nSome info" + + github_webhook.repository.get_contents.return_value = content_file + await github_webhook.load_welcome_extra_info_from_file() + + assert github_webhook.welcome_extra_info == "# Custom welcome\nSome info" + + @pytest.mark.asyncio() + async def test_load_from_file_too_large(self, github_webhook: GithubWebhook) -> None: + """Test that oversized files are rejected with a warning.""" + content_file = MagicMock() + content_file.size = 20000 + + github_webhook.repository.get_contents.return_value = content_file + await github_webhook.load_welcome_extra_info_from_file() + + assert github_webhook.welcome_extra_info == "config value" + + @pytest.mark.asyncio() + async def test_load_from_file_not_found(self, github_webhook: GithubWebhook) -> None: + """Test graceful handling when welcome message file does not exist.""" + github_webhook.repository.get_contents.side_effect = UnknownObjectException(404, "Not found", None) + await github_webhook.load_welcome_extra_info_from_file() + + assert github_webhook.welcome_extra_info == "config value" + + @pytest.mark.asyncio() + async def test_load_from_file_exception(self, github_webhook: GithubWebhook) -> None: + """Test graceful handling of unexpected exceptions during file loading.""" + github_webhook.repository.get_contents.side_effect = Exception("API error") + await github_webhook.load_welcome_extra_info_from_file() + + assert github_webhook.welcome_extra_info == "config value" + + @pytest.mark.asyncio() + async def test_load_from_file_empty(self, github_webhook: GithubWebhook) -> None: + """Test that empty file content is handled correctly.""" + content_file = MagicMock() + content_file.size = 0 + content_file.decoded_content = b"" + + github_webhook.repository.get_contents.return_value = content_file + await github_webhook.load_welcome_extra_info_from_file() + + assert github_webhook.welcome_extra_info == "" + + @pytest.mark.asyncio() + async def test_load_from_file_list_response(self, github_webhook: GithubWebhook) -> None: + """Test handling of unexpected list response from GitHub API.""" + content_file = MagicMock() + content_file.size = 50 + content_file.decoded_content = b"List content" + + github_webhook.repository.get_contents.return_value = [content_file] + await github_webhook.load_welcome_extra_info_from_file() + + assert github_webhook.welcome_extra_info == "List content" + + @pytest.mark.asyncio() + async def test_load_from_file_unicode_error(self, github_webhook: GithubWebhook) -> None: + """Test handling of unicode decode errors in file content.""" + content_file = MagicMock() + content_file.size = 10 + content_file.decoded_content = b"\xff\xfe invalid" + + github_webhook.repository.get_contents.return_value = content_file + await github_webhook.load_welcome_extra_info_from_file() + + assert github_webhook.welcome_extra_info == "config value" + + +class TestWelcomeExtraInfoConfigGuard: + """Tests for welcome-extra-info runtime guard in _repo_data_from_config.""" + + @pytest.fixture() + def github_webhook(self, process_github_webhook: GithubWebhook) -> GithubWebhook: + """Create a GithubWebhook instance for config guard tests.""" + return process_github_webhook + + def test_config_welcome_extra_info_oversized(self, github_webhook: GithubWebhook) -> None: + """Test that oversized config value is cleared by the guard using byte length.""" + # Use 2-byte UTF-8 characters (é) so 5121 chars = 10242 bytes > 10240 limit + oversized_value = "é" * 5121 + github_webhook.config.get_value = MagicMock( + side_effect=lambda value="", return_on_none=None, extra_dict=None: ( + oversized_value if value == "welcome-extra-info" else MagicMock() + ) + ) + github_webhook._repo_data_from_config(repository_config={}) + assert github_webhook.welcome_extra_info == "" + + def test_config_welcome_extra_info_not_string(self, github_webhook: GithubWebhook) -> None: + """Test that non-string config value is cleared by the guard.""" + github_webhook.config.get_value = MagicMock( + side_effect=lambda value="", return_on_none=None, extra_dict=None: ( + 12345 if value == "welcome-extra-info" else MagicMock() + ) + ) + github_webhook._repo_data_from_config(repository_config={}) + assert github_webhook.welcome_extra_info == "" + + def test_config_welcome_extra_info_valid(self, github_webhook: GithubWebhook) -> None: + """Test that valid config value is kept.""" + github_webhook.config.get_value = MagicMock( + side_effect=lambda value="", return_on_none=None, extra_dict=None: ( + "Valid info" if value == "welcome-extra-info" else MagicMock() + ) + ) + github_webhook._repo_data_from_config(repository_config={}) + assert github_webhook.welcome_extra_info == "Valid info" diff --git a/webhook_server/tests/test_pull_request_handler.py b/webhook_server/tests/test_pull_request_handler.py index 4a7642a18..38490de95 100644 --- a/webhook_server/tests/test_pull_request_handler.py +++ b/webhook_server/tests/test_pull_request_handler.py @@ -47,71 +47,81 @@ def _owners_data_coroutine(return_value: dict | None = None) -> _AwaitableValue: return _AwaitableValue(return_value) +def _create_mock_github_webhook() -> Mock: + """Create a mock GithubWebhook instance for testing.""" + mock_webhook = Mock(spec=GithubWebhook) + mock_webhook.hook_data = { + "action": "opened", + "pull_request": {"number": 123, "merged": False, "title": "Test PR"}, + "sender": {"login": "test-user"}, + "label": {"name": "bug"}, + } + mock_webhook.logger = MagicMock() + mock_webhook.log_prefix = "[TEST]" + mock_webhook.repository_full_name = "test-org/test-repo" + mock_webhook.repository = Mock() + mock_webhook.issue_url_for_welcome_msg = "welcome-message-url" + mock_webhook.parent_committer = "test-user" + mock_webhook.auto_verified_and_merged_users = ["test-user"] + mock_webhook.create_issue_for_new_pr = True + mock_webhook.verified_job = True + mock_webhook.build_and_push_container = True + mock_webhook.container_repository_and_tag = Mock(return_value="test-repo:pr-123") + mock_webhook.can_be_merged_required_labels = [] + mock_webhook.set_auto_merge_prs = [] + mock_webhook.auto_merge_enabled = True + mock_webhook.container_repository = "docker.io/org/repo" + mock_webhook.conventional_title = False + mock_webhook.minimum_lgtm = 1 + mock_webhook.container_repository_username = "test-user" + mock_webhook.container_repository_password = "test-password" # pragma: allowlist secret + mock_webhook.github_api = Mock() + mock_webhook.tox = True + mock_webhook.pre_commit = True + mock_webhook.python_module_install = False + mock_webhook.pypi = False + mock_webhook.token = TEST_GITHUB_TOKEN + mock_webhook.auto_verify_cherry_picked_prs = True + mock_webhook.cherry_pick_assign_to_pr_author = True + mock_webhook.last_commit = Mock() + mock_webhook.ctx = None + mock_webhook.enabled_labels = None + mock_webhook.custom_check_runs = [] + mock_webhook.ai_features = None + mock_webhook.required_conversation_resolution = False + mock_webhook.security_suspicious_paths = [] + mock_webhook.security_committer_identity_check = True + mock_webhook.security_mandatory = True + mock_webhook.last_committer = "test-user" + mock_webhook.welcome_extra_info = "" + mock_webhook.config = Mock() + mock_webhook.config.get_value = Mock(return_value=None) + return mock_webhook + + +def _create_mock_owners_file_handler() -> Mock: + """Create a mock OwnersFileHandler instance for testing.""" + mock_handler = Mock(spec=OwnersFileHandler) + mock_handler.all_pull_request_approvers = ["approver1", "approver2"] + mock_handler.all_pull_request_reviewers = ["reviewer1", "reviewer2"] + mock_handler.root_approvers = ["root-approver"] + mock_handler.root_reviewers = ["root-reviewer"] + mock_handler.assign_reviewers = AsyncMock() + return mock_handler + + class TestPullRequestHandler: """Test suite for PullRequestHandler class.""" @pytest.fixture def mock_github_webhook(self) -> Mock: """Create a mock GithubWebhook instance.""" - mock_webhook = Mock(spec=GithubWebhook) - mock_webhook.hook_data = { - "action": "opened", - "pull_request": {"number": 123, "merged": False, "title": "Test PR"}, - "sender": {"login": "test-user"}, - "label": {"name": "bug"}, - } - mock_webhook.logger = MagicMock() - mock_webhook.log_prefix = "[TEST]" - mock_webhook.repository_full_name = "test-org/test-repo" - mock_webhook.repository = Mock() - mock_webhook.issue_url_for_welcome_msg = "welcome-message-url" - mock_webhook.parent_committer = "test-user" - mock_webhook.auto_verified_and_merged_users = ["test-user"] - mock_webhook.create_issue_for_new_pr = True - mock_webhook.verified_job = True - mock_webhook.build_and_push_container = True - mock_webhook.container_repository_and_tag = Mock(return_value="test-repo:pr-123") - mock_webhook.can_be_merged_required_labels = [] - mock_webhook.set_auto_merge_prs = [] - mock_webhook.auto_merge_enabled = True - mock_webhook.container_repository = "docker.io/org/repo" - # New attributes for coverage - mock_webhook.conventional_title = False - mock_webhook.minimum_lgtm = 1 - mock_webhook.container_repository_username = "test-user" - mock_webhook.container_repository_password = "test-password" # pragma: allowlist secret - mock_webhook.github_api = Mock() - mock_webhook.tox = True - mock_webhook.pre_commit = True - mock_webhook.python_module_install = False - mock_webhook.pypi = False - mock_webhook.token = TEST_GITHUB_TOKEN - mock_webhook.auto_verify_cherry_picked_prs = True - mock_webhook.cherry_pick_assign_to_pr_author = True - mock_webhook.last_commit = Mock() - mock_webhook.ctx = None - mock_webhook.enabled_labels = None # Default: all labels enabled - mock_webhook.custom_check_runs = [] - mock_webhook.ai_features = None - mock_webhook.required_conversation_resolution = False - mock_webhook.security_suspicious_paths = [] - mock_webhook.security_committer_identity_check = True - mock_webhook.security_mandatory = True - mock_webhook.last_committer = "test-user" - mock_webhook.config = Mock() - mock_webhook.config.get_value = Mock(return_value=None) - return mock_webhook + return _create_mock_github_webhook() @pytest.fixture def mock_owners_file_handler(self) -> Mock: """Create a mock OwnersFileHandler instance.""" - mock_handler = Mock(spec=OwnersFileHandler) - mock_handler.all_pull_request_approvers = ["approver1", "approver2"] - mock_handler.all_pull_request_reviewers = ["reviewer1", "reviewer2"] - mock_handler.root_approvers = ["root-approver"] - mock_handler.root_reviewers = ["root-reviewer"] - mock_handler.assign_reviewers = AsyncMock() - return mock_handler + return _create_mock_owners_file_handler() @pytest.fixture def pull_request_handler(self, mock_github_webhook: Mock, mock_owners_file_handler: Mock) -> PullRequestHandler: @@ -3295,3 +3305,52 @@ async def test_process_reopened_action_does_not_call_test_oracle( await pull_request_handler.process_pull_request_webhook_data(mock_pull_request) mock_test_oracle.assert_not_called() mock_create_task.assert_not_called() + + +class TestPrepareExtraInfoWelcomeSection: + """Tests for _prepare_extra_info_welcome_section.""" + + @pytest.fixture + def mock_github_webhook(self) -> Mock: + """Create a mock GithubWebhook instance.""" + return _create_mock_github_webhook() + + @pytest.fixture + def mock_owners_file_handler(self) -> Mock: + """Create a mock OwnersFileHandler instance.""" + return _create_mock_owners_file_handler() + + @pytest.fixture + def pull_request_handler(self, mock_github_webhook: Mock, mock_owners_file_handler: Mock) -> PullRequestHandler: + """Create a PullRequestHandler instance with mocked dependencies.""" + handler = PullRequestHandler(mock_github_webhook, mock_owners_file_handler) + handler.labels_handler = Mock() + handler.labels_handler.is_label_enabled = Mock(return_value=True) + return handler + + def test_extra_info_present(self, pull_request_handler: PullRequestHandler) -> None: + """Test extra info section is rendered when welcome_extra_info is set.""" + pull_request_handler.github_webhook.welcome_extra_info = "Custom info text" + result = pull_request_handler._prepare_extra_info_welcome_section + assert "### \U0001f4cc Additional Information\n\n" in result + assert "Custom info text" in result + + def test_extra_info_empty(self, pull_request_handler: PullRequestHandler) -> None: + """Test extra info section is empty when welcome_extra_info is empty.""" + pull_request_handler.github_webhook.welcome_extra_info = "" + result = pull_request_handler._prepare_extra_info_welcome_section + assert result == "" + + def test_extra_info_none(self, pull_request_handler: PullRequestHandler) -> None: + """Test extra info section is empty when welcome_extra_info is None.""" + pull_request_handler.github_webhook.welcome_extra_info = None + result = pull_request_handler._prepare_extra_info_welcome_section + assert result == "" + + def test_extra_info_multiline_markdown(self, pull_request_handler: PullRequestHandler) -> None: + """Test extra info section renders multiline markdown correctly.""" + pull_request_handler.github_webhook.welcome_extra_info = "**Bold** and *italic*\n- Item 1\n- Item 2" + result = pull_request_handler._prepare_extra_info_welcome_section + assert "### \U0001f4cc Additional Information\n\n" in result + assert "**Bold** and *italic*" in result + assert "- Item 1" in result