diff --git a/.env.example b/.env.example index f1b1517b1..03b8d9121 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,8 @@ GITHUB_TOKEN=your-github-personal-access-token GITHUB_WEBHOOK_SECRET=your-github-webhook-secret # GitHub account/org where forks are created (leave empty to fork as the authenticated user) # GITHUB_FORK_OWNER=your-org +# Prefix to add to all comments made by the Forge bot (e.g., signature or identifier) +# FORGE_BOT_COMMENT_PREFIX= # ----------------------------------------------------------------------------- # Repository configuration — two options, pick one: diff --git a/docs/reference/config.md b/docs/reference/config.md index f13fd5f29..1576d1942 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -19,6 +19,7 @@ All configuration is via environment variables in `.env`. See `.env.example` in |----------|-------------| | `GITHUB_TOKEN` | Personal Access Token with `repo` and `read:org` scopes | | `GITHUB_WEBHOOK_SECRET` | Secret for validating GitHub webhook signatures | +| `FORGE_BOT_COMMENT_PREFIX` | Prefix to add to all comments made by the Forge bot (e.g., signature or identifier), also used for webhook self-comment filtering to prevent loops. Note: This configuration is intended to allow development and testing with the same user API keys that are used to comment (to prevent webhook loops), and it should not be used in production. | ### LLM diff --git a/src/forge/config.py b/src/forge/config.py index 3e624ade5..4e76aee54 100644 --- a/src/forge/config.py +++ b/src/forge/config.py @@ -103,6 +103,10 @@ def atlassian_auth_base64(self) -> str: default="", description="GitHub account/org where forks are created (defaults to authenticated user if empty)", ) + forge_bot_comment_prefix: str = Field( + default="", + description="Prefix to use for all comments made by the Forge bot", + ) git_user_name: str = Field( default="Forge", description="Git user name for commits made by Forge", diff --git a/src/forge/integrations/github/client.py b/src/forge/integrations/github/client.py index 8d8818ba2..39f8eeb45 100644 --- a/src/forge/integrations/github/client.py +++ b/src/forge/integrations/github/client.py @@ -194,6 +194,9 @@ async def create_review_comment( Returns: API response with comment details. """ + from forge.workflow.utils.automated_review_triage import prepend_bot_prefix + + body = prepend_bot_prefix(body, self.settings.forge_bot_comment_prefix) client = await self._get_client() response = await client.post( f"/repos/{owner}/{repo}/pulls/{pr_number}/comments", @@ -217,6 +220,9 @@ async def reply_to_review_comment( body: str, ) -> dict[str, Any]: """Reply in the review thread containing ``comment_id``.""" + from forge.workflow.utils.automated_review_triage import prepend_bot_prefix + + body = prepend_bot_prefix(body, self.settings.forge_bot_comment_prefix) client = await self._get_client() response = await client.post( f"/repos/{owner}/{repo}/pulls/{pr_number}/comments/{comment_id}/replies", @@ -437,6 +443,9 @@ async def create_issue_comment( Returns: API response with comment details. """ + from forge.workflow.utils.automated_review_triage import prepend_bot_prefix + + body = prepend_bot_prefix(body, self.settings.forge_bot_comment_prefix) client = await self._get_client() response = await client.post( f"/repos/{owner}/{repo}/issues/{issue_number}/comments", diff --git a/src/forge/orchestrator/worker.py b/src/forge/orchestrator/worker.py index 2ed39bbe5..8b3dc360d 100644 --- a/src/forge/orchestrator/worker.py +++ b/src/forge/orchestrator/worker.py @@ -40,6 +40,7 @@ from forge.workflow.router import WorkflowRouter from forge.workflow.utils.automated_review_triage import ( is_bot_sender, + is_self_comment, triage_automated_review, ) from forge.workflow.utils.comment_classifier import CommentType, classify_comment @@ -567,10 +568,18 @@ async def _handle_resume_event( reply = payload.get("comment", {}) replied_to = reply.get("in_reply_to_id") sender_login = payload.get("sender", {}).get("login", "") - forge_login = await self._get_forge_github_login() - if sender_login and sender_login == forge_login: - logger.debug("Ignoring Forge's own inline review comment") - return current_state + if sender_login: + forge_login = await self._get_forge_github_login() + settings = get_settings() + forge_bot_comment_prefix = getattr(settings, "forge_bot_comment_prefix", None) + if is_self_comment( + sender_login=sender_login, + comment_body=reply.get("body", ""), + bot_login=forge_login, + prefix=forge_bot_comment_prefix, + ): + logger.debug("Ignoring Forge's own inline review comment") + return current_state if replied_to: contested = current_state.get("contested_comments", []) remaining = [ @@ -975,10 +984,18 @@ async def _handle_resume_event( reply = payload.get("comment", {}) replied_to = reply.get("in_reply_to_id") if is_proposal_reply: - forge_login = await self._get_forge_github_login() sender_login = payload.get("sender", {}).get("login", "") - if sender_login and sender_login == forge_login: - return current_state + if sender_login: + forge_login = await self._get_forge_github_login() + settings = get_settings() + forge_bot_comment_prefix = getattr(settings, "forge_bot_comment_prefix", None) + if is_self_comment( + sender_login=sender_login, + comment_body=reply.get("body", ""), + bot_login=forge_login, + prefix=forge_bot_comment_prefix, + ): + return current_state if is_proposal_reply and replied_to: previous = current_state.get("proposal_review_decisions", []) matching = next( @@ -1115,7 +1132,14 @@ async def _handle_resume_event( finally: await gh.close() - if sender_login == forge_login: + settings = get_settings() + forge_bot_comment_prefix = getattr(settings, "forge_bot_comment_prefix", None) + if is_self_comment( + sender_login=sender_login, + comment_body=comment_body, + bot_login=forge_login, + prefix=forge_bot_comment_prefix, + ): logger.debug(f"Ignoring self-comment on PRD PR for {message.ticket_key}") return current_state @@ -1246,7 +1270,14 @@ async def _handle_resume_event( finally: await gh.close() - if sender_login == forge_login: + settings = get_settings() + forge_bot_comment_prefix = getattr(settings, "forge_bot_comment_prefix", None) + if is_self_comment( + sender_login=sender_login, + comment_body=comment_body, + bot_login=forge_login, + prefix=forge_bot_comment_prefix, + ): logger.debug(f"Ignoring self-comment on spec PR for {message.ticket_key}") return current_state @@ -1394,13 +1425,22 @@ async def _handle_resume_event( and current_state.get("is_paused", True) ): sender_login = payload.get("sender", {}).get("login", "") - if sender_login and sender_login == await self._get_forge_github_login(): - logger.debug("Ignoring Forge's own pull request review") - return current_state + review = payload.get("review", {}) or {} + review_body = review.get("body", "") or "" + if sender_login: + forge_login = await self._get_forge_github_login() + settings = get_settings() + forge_bot_comment_prefix = getattr(settings, "forge_bot_comment_prefix", None) + if is_self_comment( + sender_login=sender_login, + comment_body=review_body, + bot_login=forge_login, + prefix=forge_bot_comment_prefix, + ): + logger.debug("Ignoring Forge's own pull request review") + return current_state - review = payload.get("review", {}) review_state = review.get("state", "").lower() - review_body = review.get("body", "") or "" if review_state == "approved": if targets_implementation_pr: diff --git a/src/forge/workflow/utils/automated_review_triage.py b/src/forge/workflow/utils/automated_review_triage.py index aea280d1e..278ca109d 100644 --- a/src/forge/workflow/utils/automated_review_triage.py +++ b/src/forge/workflow/utils/automated_review_triage.py @@ -4,7 +4,7 @@ import logging import re from dataclasses import dataclass -from typing import Literal +from typing import Any, Literal from forge.prompts import load_prompt @@ -22,11 +22,95 @@ class AutomatedReviewDecision: reason: str = "" -def is_bot_sender(payload: dict) -> bool: +def is_bot_sender(payload: dict[str, Any]) -> bool: """Return whether a GitHub webhook was sent by a bot account.""" - sender = payload.get("sender", {}) - review_user = payload.get("review", {}).get("user", {}) - return sender.get("type", "").lower() == "bot" or review_user.get("type", "").lower() == "bot" + sender: dict[str, Any] = payload.get("sender", {}) or {} + review: dict[str, Any] = payload.get("review", {}) or {} + review_user: dict[str, Any] = review.get("user", {}) or {} + + sender_type = str(sender.get("type", "")) + review_user_type = str(review_user.get("type", "")) + + return bool(sender_type.lower() == "bot" or review_user_type.lower() == "bot") + + +def is_self_comment( + sender_login: str, + comment_body: str | None, + bot_login: str, + prefix: str | None = None, +) -> bool: + """Determine if an incoming comment or review belongs to the bot itself. + + Uses dual-check or legacy username logic with O(1) prefix match complexity + and no external I/O overhead. + """ + comment_body = comment_body or "" + sender_lower = sender_login.lower() + bot_lower = bot_login.lower() + + # Check if the sender is our bot or matches our bot suffix + is_same_bot = ( + sender_lower == bot_lower + or sender_lower == f"{bot_lower}[bot]" + or (sender_lower.endswith("[bot]") and sender_lower[:-5] == bot_lower) + ) + + if sender_lower.endswith("[bot]") and is_same_bot: + return True + + if prefix and prefix.strip(): + if is_same_bot: + prefix_stripped = prefix.strip() + prefixes_to_check: tuple[str, ...] + if prefix_stripped.startswith(""): + wrapped_prefix = prefix_stripped + prefixes_to_check = (prefix, prefix_stripped, wrapped_prefix) + else: + wrapped_prefix = f"" + wrapped_prefix_no_space = f"" + prefixes_to_check = ( + prefix, + prefix_stripped, + wrapped_prefix, + wrapped_prefix_no_space, + ) + + return comment_body.startswith(prefixes_to_check) or comment_body.lstrip().startswith( + prefixes_to_check + ) + return False + + return is_same_bot + + +def prepend_bot_prefix(comment_body: str | None, prefix: str | None = None) -> str: + """Prepend a bot signature/comment prefix to the comment body.""" + comment_body = comment_body or "" + if prefix is None: + from forge.config import get_settings + + prefix = get_settings().forge_bot_comment_prefix + + if not prefix: + return comment_body + + prefix_stripped = prefix.strip() + if not prefix_stripped: + return comment_body + + if prefix_stripped.startswith(""): + wrapped_prefix = prefix_stripped + else: + wrapped_prefix = f"" + + if comment_body.startswith(wrapped_prefix) or comment_body.lstrip().startswith(wrapped_prefix): + return comment_body + + if not comment_body: + return wrapped_prefix + + return f"{wrapped_prefix}\n\n{comment_body}" def parse_automated_review_decision(output: str) -> AutomatedReviewDecision: diff --git a/tests/conftest.py b/tests/conftest.py index 8555361ea..d5a313d1e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,13 @@ """Shared test fixtures for Forge test suite.""" +import os + +# Set dummy environment variables for Pydantic Settings validation during test initialization +os.environ.setdefault("JIRA_BASE_URL", "https://test.atlassian.net") +os.environ.setdefault("JIRA_API_TOKEN", "test-token") +os.environ.setdefault("JIRA_USER_EMAIL", "test@example.com") +os.environ.setdefault("GITHUB_TOKEN", "test-github-token") + from collections.abc import AsyncGenerator, Generator from pathlib import Path from unittest.mock import AsyncMock, MagicMock diff --git a/tests/unit/api/routes/test_github_webhook.py b/tests/unit/api/routes/test_github_webhook.py index 2acf5d623..a9e520356 100644 --- a/tests/unit/api/routes/test_github_webhook.py +++ b/tests/unit/api/routes/test_github_webhook.py @@ -8,14 +8,14 @@ import pytest from httpx import ASGITransport, AsyncClient from pydantic import SecretStr + +from forge.main import app from tests.fixtures.github_payloads import ( WEBHOOK_CHECK_RUN_COMPLETED_FAILURE, WEBHOOK_CHECK_RUN_COMPLETED_SUCCESS, WEBHOOK_PULL_REQUEST_REVIEW_APPROVED, ) -from forge.main import app - def compute_signature(payload: bytes, secret: str) -> str: """Compute GitHub webhook signature with sha256= prefix.""" @@ -43,22 +43,23 @@ async def test_valid_webhook_returns_202(self): mock_producer = MagicMock() mock_producer.publish_once = AsyncMock() - with patch("forge.api.routes.github.get_settings", return_value=mock_settings): - with patch("forge.api.routes.github.QueueProducer", return_value=mock_producer): - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test" - ) as client: - response = await client.post( - "/api/v1/webhooks/github", - content=payload, - headers={ - "Content-Type": "application/json", - "X-Hub-Signature-256": signature, - "X-GitHub-Event": "check_run", - "X-GitHub-Delivery": "delivery-123", - }, - ) + with ( + patch("forge.api.routes.github.get_settings", return_value=mock_settings), + patch("forge.api.routes.github.QueueProducer", return_value=mock_producer), + ): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + "X-GitHub-Event": "check_run", + "X-GitHub-Delivery": "delivery-123", + }, + ) assert response.status_code == 202 @@ -72,8 +73,7 @@ async def test_invalid_signature_returns_401(self): with patch("forge.api.routes.github.get_settings", return_value=mock_settings): async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test" + transport=ASGITransport(app=app), base_url="http://test" ) as client: response = await client.post( "/api/v1/webhooks/github", @@ -97,8 +97,7 @@ async def test_missing_signature_returns_401(self): with patch("forge.api.routes.github.get_settings", return_value=mock_settings): async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test" + transport=ASGITransport(app=app), base_url="http://test" ) as client: response = await client.post( "/api/v1/webhooks/github", @@ -124,22 +123,23 @@ async def test_check_run_success_published(self): mock_producer = MagicMock() mock_producer.publish_once = AsyncMock() - with patch("forge.api.routes.github.get_settings", return_value=mock_settings): - with patch("forge.api.routes.github.QueueProducer", return_value=mock_producer): - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test" - ) as client: - response = await client.post( - "/api/v1/webhooks/github", - content=payload, - headers={ - "Content-Type": "application/json", - "X-Hub-Signature-256": signature, - "X-GitHub-Event": "check_run", - "X-GitHub-Delivery": "delivery-123", - }, - ) + with ( + patch("forge.api.routes.github.get_settings", return_value=mock_settings), + patch("forge.api.routes.github.QueueProducer", return_value=mock_producer), + ): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + "X-GitHub-Event": "check_run", + "X-GitHub-Delivery": "delivery-123", + }, + ) assert response.status_code == 202 mock_producer.publish_once.assert_called_once() @@ -157,22 +157,23 @@ async def test_check_run_failure_published(self): mock_producer = MagicMock() mock_producer.publish_once = AsyncMock() - with patch("forge.api.routes.github.get_settings", return_value=mock_settings): - with patch("forge.api.routes.github.QueueProducer", return_value=mock_producer): - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test" - ) as client: - response = await client.post( - "/api/v1/webhooks/github", - content=payload, - headers={ - "Content-Type": "application/json", - "X-Hub-Signature-256": signature, - "X-GitHub-Event": "check_run", - "X-GitHub-Delivery": "delivery-123", - }, - ) + with ( + patch("forge.api.routes.github.get_settings", return_value=mock_settings), + patch("forge.api.routes.github.QueueProducer", return_value=mock_producer), + ): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + "X-GitHub-Event": "check_run", + "X-GitHub-Delivery": "delivery-123", + }, + ) assert response.status_code == 202 mock_producer.publish_once.assert_called_once() @@ -190,25 +191,130 @@ async def test_pr_review_approved_published(self): mock_producer = MagicMock() mock_producer.publish_once = AsyncMock() - with patch("forge.api.routes.github.get_settings", return_value=mock_settings): - with patch("forge.api.routes.github.QueueProducer", return_value=mock_producer): - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test" - ) as client: - response = await client.post( - "/api/v1/webhooks/github", - content=payload, - headers={ - "Content-Type": "application/json", - "X-Hub-Signature-256": signature, - "X-GitHub-Event": "pull_request_review", - "X-GitHub-Delivery": "delivery-123", - }, - ) + with ( + patch("forge.api.routes.github.get_settings", return_value=mock_settings), + patch("forge.api.routes.github.QueueProducer", return_value=mock_producer), + ): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + "X-GitHub-Event": "pull_request_review", + "X-GitHub-Delivery": "delivery-123", + }, + ) assert response.status_code == 202 + @pytest.mark.asyncio + async def test_webhook_delivery_comment_from_app_bot(self): + """Standard App bot comment webhook delivery is received and queued successfully.""" + comment_payload = { + "action": "created", + "issue": { + "number": 42, + "pull_request": {"url": "https://api.github.com/repos/org/repo/pulls/42"}, + }, + "comment": { + "id": 999, + "body": "Some comment body from App bot", + "user": {"login": "forge-bot[bot]", "type": "Bot"}, + }, + "repository": { + "id": 123456, + "name": "repo", + "full_name": "org/repo", + }, + "sender": {"login": "forge-bot[bot]", "type": "Bot"}, + } + payload = json.dumps(comment_payload).encode() + secret = "test-github-webhook-secret" + signature = compute_signature(payload, secret) + + mock_settings = MagicMock() + mock_settings.github_webhook_secret = SecretStr(secret) + + mock_producer = MagicMock() + mock_producer.publish_once = AsyncMock() + + with ( + patch("forge.api.routes.github.get_settings", return_value=mock_settings), + patch("forge.api.routes.github.QueueProducer", return_value=mock_producer), + ): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + "X-GitHub-Event": "issue_comment", + "X-GitHub-Delivery": "delivery-comment-bot", + }, + ) + + assert response.status_code == 202 + mock_producer.publish_once.assert_called_once() + + @pytest.mark.asyncio + async def test_webhook_delivery_comment_from_custom_dev_pat(self): + """Custom dev PAT user comment webhook delivery is received and queued successfully.""" + comment_payload = { + "action": "created", + "issue": { + "number": 42, + "pull_request": {"url": "https://api.github.com/repos/org/repo/pulls/42"}, + }, + "comment": { + "id": 1000, + "body": "!This is human feedback or custom dev PAT comment.", + "user": {"login": "dev-user", "type": "User"}, + }, + "repository": { + "id": 123456, + "name": "repo", + "full_name": "org/repo", + }, + "sender": {"login": "dev-user", "type": "User"}, + } + payload = json.dumps(comment_payload).encode() + secret = "test-github-webhook-secret" + signature = compute_signature(payload, secret) + + mock_settings = MagicMock() + mock_settings.github_webhook_secret = SecretStr(secret) + + mock_producer = MagicMock() + mock_producer.publish_once = AsyncMock() + + with ( + patch("forge.api.routes.github.get_settings", return_value=mock_settings), + patch("forge.api.routes.github.QueueProducer", return_value=mock_producer), + ): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + "X-GitHub-Event": "issue_comment", + "X-GitHub-Delivery": "delivery-comment-pat", + }, + ) + + assert response.status_code == 202 + mock_producer.publish_once.assert_called_once() + class TestGitHubWebhookParsing: """Tests for GitHub webhook payload parsing via parse_github_webhook.""" @@ -224,8 +330,12 @@ def test_extract_check_conclusion(self): """Extract check run conclusion.""" from forge.integrations.github.webhooks import parse_github_webhook - success_data = parse_github_webhook(WEBHOOK_CHECK_RUN_COMPLETED_SUCCESS, "check_run", "evt-001") - failure_data = parse_github_webhook(WEBHOOK_CHECK_RUN_COMPLETED_FAILURE, "check_run", "evt-002") + success_data = parse_github_webhook( + WEBHOOK_CHECK_RUN_COMPLETED_SUCCESS, "check_run", "evt-001" + ) + failure_data = parse_github_webhook( + WEBHOOK_CHECK_RUN_COMPLETED_FAILURE, "check_run", "evt-002" + ) assert success_data.check_conclusion == "success" assert failure_data.check_conclusion == "failure" diff --git a/tests/unit/integrations/github/test_outbound_signature.py b/tests/unit/integrations/github/test_outbound_signature.py new file mode 100644 index 000000000..2b3b503f2 --- /dev/null +++ b/tests/unit/integrations/github/test_outbound_signature.py @@ -0,0 +1,189 @@ +"""Tests for GitHub outbound comment signature/prefix integration.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from forge.config import Settings +from forge.integrations.github.client import GitHubClient + + +@pytest.fixture +def github_client(mock_settings: Settings) -> GitHubClient: + # Set the default prefix to empty first + mock_settings.forge_bot_comment_prefix = "" + client = GitHubClient(settings=mock_settings) + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + return client + + +class TestGitHubOutboundCommentSigning: + @pytest.mark.asyncio + async def test_create_review_comment_with_prefix( + self, github_client: Any, mock_settings: Any + ) -> None: + # 1. Enable setting + mock_settings.forge_bot_comment_prefix = "ForgeBotSignature" + + mock_response = MagicMock() + mock_response.json.return_value = {"id": 123} + mock_response.raise_for_status = MagicMock() + github_client._client.post = AsyncMock(return_value=mock_response) + + result = await github_client.create_review_comment( + owner="owner", + repo="repo", + pr_number=45, + body="Nice change!", + commit_id="abc123", + path="main.py", + line=10, + ) + + assert result == {"id": 123} + github_client._client.post.assert_called_once() + call_args = github_client._client.post.call_args + assert call_args[0][0] == "/repos/owner/repo/pulls/45/comments" + + # Verify body contains the prefix wrapped correctly + body_sent = call_args[1]["json"]["body"] + assert "" in body_sent + assert "Nice change!" in body_sent + + @pytest.mark.asyncio + async def test_create_review_comment_no_prefix( + self, github_client: Any, mock_settings: Any + ) -> None: + # 2. Disable setting (empty) + mock_settings.forge_bot_comment_prefix = "" + + mock_response = MagicMock() + mock_response.json.return_value = {"id": 123} + mock_response.raise_for_status = MagicMock() + github_client._client.post = AsyncMock(return_value=mock_response) + + result = await github_client.create_review_comment( + owner="owner", + repo="repo", + pr_number=45, + body="Nice change!", + commit_id="abc123", + path="main.py", + line=10, + ) + + assert result == {"id": 123} + call_args = github_client._client.post.call_args + body_sent = call_args[1]["json"]["body"] + assert body_sent == "Nice change!" + + @pytest.mark.asyncio + async def test_create_issue_comment_with_prefix( + self, github_client: Any, mock_settings: Any + ) -> None: + # 1. Enable setting + mock_settings.forge_bot_comment_prefix = "ForgeBotSignature" + + mock_response = MagicMock() + mock_response.json.return_value = {"id": 456} + mock_response.raise_for_status = MagicMock() + github_client._client.post = AsyncMock(return_value=mock_response) + + result = await github_client.create_issue_comment( + owner="owner", + repo="repo", + issue_number=12, + body="An issue comment.", + ) + + assert result == {"id": 456} + github_client._client.post.assert_called_once() + call_args = github_client._client.post.call_args + assert call_args[0][0] == "/repos/owner/repo/issues/12/comments" + + # Verify body contains the prefix wrapped correctly + body_sent = call_args[1]["json"]["body"] + assert "" in body_sent + assert "An issue comment." in body_sent + + @pytest.mark.asyncio + async def test_create_issue_comment_no_prefix( + self, github_client: Any, mock_settings: Any + ) -> None: + # 2. Disable setting (empty) + mock_settings.forge_bot_comment_prefix = "" + + mock_response = MagicMock() + mock_response.json.return_value = {"id": 456} + mock_response.raise_for_status = MagicMock() + github_client._client.post = AsyncMock(return_value=mock_response) + + result = await github_client.create_issue_comment( + owner="owner", + repo="repo", + issue_number=12, + body="An issue comment.", + ) + + assert result == {"id": 456} + call_args = github_client._client.post.call_args + body_sent = call_args[1]["json"]["body"] + assert body_sent == "An issue comment." + + @pytest.mark.asyncio + async def test_reply_to_review_comment_with_prefix( + self, github_client: Any, mock_settings: Any + ) -> None: + # 1. Enable setting + mock_settings.forge_bot_comment_prefix = "ForgeBotSignature" + + mock_response = MagicMock() + mock_response.json.return_value = {"id": 789} + mock_response.raise_for_status = MagicMock() + github_client._client.post = AsyncMock(return_value=mock_response) + + result = await github_client.reply_to_review_comment( + owner="owner", + repo="repo", + pr_number=9, + comment_id=77, + body="Addressing.", + ) + + assert result == {"id": 789} + github_client._client.post.assert_called_once() + call_args = github_client._client.post.call_args + assert call_args[0][0] == "/repos/owner/repo/pulls/9/comments/77/replies" + + # Verify body contains the prefix wrapped correctly + body_sent = call_args[1]["json"]["body"] + assert "" in body_sent + assert "Addressing." in body_sent + + @pytest.mark.asyncio + async def test_reply_to_review_comment_no_prefix( + self, github_client: Any, mock_settings: Any + ) -> None: + # 2. Disable setting (empty) + mock_settings.forge_bot_comment_prefix = "" + + mock_response = MagicMock() + mock_response.json.return_value = {"id": 789} + mock_response.raise_for_status = MagicMock() + github_client._client.post = AsyncMock(return_value=mock_response) + + result = await github_client.reply_to_review_comment( + owner="owner", + repo="repo", + pr_number=9, + comment_id=77, + body="Addressing.", + ) + + assert result == {"id": 789} + call_args = github_client._client.post.call_args + body_sent = call_args[1]["json"]["body"] + assert body_sent == "Addressing." diff --git a/tests/unit/orchestrator/test_worker.py b/tests/unit/orchestrator/test_worker.py index e845fa708..b12b06c37 100644 --- a/tests/unit/orchestrator/test_worker.py +++ b/tests/unit/orchestrator/test_worker.py @@ -2115,3 +2115,258 @@ async def test_review_response_gate_resume_state_routes_to_implement_review( result = await worker._handle_resume_event(message, state) assert route_review_response(result) == "implement_review" + + +class TestWorkerWebhookCommentFiltering: + """Tests confirming worker webhook filter/processing dual-check logic.""" + + @pytest.mark.asyncio + async def test_integration_bot_login_comment_without_prefix_processed_as_human_feedback(self): + """Integration test confirms that a bot-login comment without a configured prefix signature is processed as human feedback.""" + worker = OrchestratorWorker(consumer_name="test-worker") + state = { + "ticket_key": "TEST-123", + "current_node": "human_review_gate", + "current_repo": "owner/repo", + "current_pr_number": 42, + "is_paused": True, + "context": {}, + } + # Sender matches the bot login 'dev-user', but body does NOT contain the prefix signature + message = QueueMessage( + message_id="msg-123", + event_id="evt-123", + source=EventSource.GITHUB, + event_type="pull_request_review:submitted", + ticket_key="TEST-123", + payload={ + "review": { + "id": 100, + "state": "changes_requested", + "body": "!This is a human review comment without signature prefix.", + "user": {"login": "dev-user", "type": "User"}, + }, + "pull_request": {"number": 42}, + "repository": {"full_name": "owner/repo"}, + "sender": {"login": "dev-user", "type": "User"}, + }, + ) + + settings = MagicMock(forge_bot_comment_prefix="my-signature") + + with ( + patch.object(worker, "_get_forge_github_login", new=AsyncMock(return_value="dev-user")), + patch("forge.orchestrator.worker.get_settings", return_value=settings), + patch("forge.orchestrator.worker.GitHubClient") as MockGH, + ): + mock_gh = AsyncMock() + mock_gh.get_review_comments.return_value = [] + MockGH.return_value = mock_gh + + result = await worker._handle_resume_event(message, state) + + # It should be processed (not ignored), so state will have updated to resume (is_paused becomes False) + assert result is not state + assert result.get("is_paused") is False + assert result.get("revision_requested") is True + assert "!This is a human review comment" in result.get("feedback_comment", "") + + @pytest.mark.asyncio + async def test_integration_bot_login_comment_with_prefix_ignored_as_self_comment(self): + """Integration test confirms that a bot-login comment with a configured prefix signature is ignored as a self-comment.""" + worker = OrchestratorWorker(consumer_name="test-worker") + state = { + "ticket_key": "TEST-123", + "current_node": "human_review_gate", + "current_repo": "owner/repo", + "current_pr_number": 42, + "is_paused": True, + "context": {}, + } + # Sender matches the bot login 'dev-user', and body contains the prefix signature + message = QueueMessage( + message_id="msg-123", + event_id="evt-123", + source=EventSource.GITHUB, + event_type="pull_request_review:submitted", + ticket_key="TEST-123", + payload={ + "review": { + "id": 100, + "state": "changes_requested", + "body": "\n\nThis is an automated comment with signature.", + "user": {"login": "dev-user", "type": "User"}, + }, + "pull_request": {"number": 42}, + "repository": {"full_name": "owner/repo"}, + "sender": {"login": "dev-user", "type": "User"}, + }, + ) + + settings = MagicMock(forge_bot_comment_prefix="my-signature") + + with ( + patch.object(worker, "_get_forge_github_login", new=AsyncMock(return_value="dev-user")), + patch("forge.orchestrator.worker.get_settings", return_value=settings), + patch("forge.orchestrator.worker.GitHubClient") as MockGH, + ): + mock_gh = AsyncMock() + MockGH.return_value = mock_gh + + result = await worker._handle_resume_event(message, state) + + # It should be ignored (is_self_comment is True), so returns unchanged state + assert result is state + assert result.get("is_paused") is True + + @pytest.mark.asyncio + async def test_integration_app_bot_comment_ending_in_bot_ignored_as_self_comment(self): + """Integration test confirms that standard App bot comments ending in [bot] are ignored as self-comments.""" + worker = OrchestratorWorker(consumer_name="test-worker") + state = { + "ticket_key": "TEST-123", + "current_node": "human_review_gate", + "current_repo": "owner/repo", + "current_pr_number": 42, + "is_paused": True, + "context": {}, + } + # Sender is an App bot (ends with [bot]) and matches the bot login + message = QueueMessage( + message_id="msg-123", + event_id="evt-123", + source=EventSource.GITHUB, + event_type="pull_request_review:submitted", + ticket_key="TEST-123", + payload={ + "review": { + "id": 100, + "state": "changes_requested", + "body": "Some comment body from app bot", + "user": {"login": "forge-bot[bot]", "type": "Bot"}, + }, + "pull_request": {"number": 42}, + "repository": {"full_name": "owner/repo"}, + "sender": {"login": "forge-bot[bot]", "type": "Bot"}, + }, + ) + + settings = MagicMock(forge_bot_comment_prefix="my-signature") + + with ( + patch.object( + worker, "_get_forge_github_login", new=AsyncMock(return_value="forge-bot") + ), + patch("forge.orchestrator.worker.get_settings", return_value=settings), + patch("forge.orchestrator.worker.GitHubClient") as MockGH, + ): + mock_gh = AsyncMock() + MockGH.return_value = mock_gh + + result = await worker._handle_resume_event(message, state) + + # It should be ignored because of the App bot suffix matching our bot login + assert result is state + assert result.get("is_paused") is True + + @pytest.mark.asyncio + async def test_integration_other_app_bot_comment_ending_in_bot_is_not_ignored(self): + """Confirm that external App bot reviews/comments (not our bot) are not ignored and are processed.""" + worker = OrchestratorWorker(consumer_name="test-worker") + state = { + "ticket_key": "TEST-123", + "current_node": "human_review_gate", + "current_repo": "owner/repo", + "current_pr_number": 42, + "is_paused": True, + "context": {}, + } + # Sender is another App bot (ends with [bot], e.g., 'coderabbitai[bot]') + message = QueueMessage( + message_id="msg-123", + event_id="evt-123", + source=EventSource.GITHUB, + event_type="pull_request_review:submitted", + ticket_key="TEST-123", + payload={ + "review": { + "id": 100, + "state": "changes_requested", + "body": "!This is an external bot review comment.", + "user": {"login": "coderabbitai[bot]", "type": "Bot"}, + }, + "pull_request": {"number": 42}, + "repository": {"full_name": "owner/repo"}, + "sender": {"login": "coderabbitai[bot]", "type": "Bot"}, + }, + ) + + settings = MagicMock(forge_bot_comment_prefix="my-signature") + + with ( + patch.object( + worker, "_get_forge_github_login", new=AsyncMock(return_value="forge-bot") + ), + patch("forge.orchestrator.worker.get_settings", return_value=settings), + patch("forge.orchestrator.worker.GitHubClient") as MockGH, + ): + mock_gh = AsyncMock() + mock_gh.get_review_comments.return_value = [] + MockGH.return_value = mock_gh + + result = await worker._handle_resume_event(message, state) + + # It should be processed (not ignored) + assert result is not state + assert result.get("is_paused") is False + assert result.get("revision_requested") is True + assert "!This is an external bot review comment." in result.get("feedback_comment", "") + + @pytest.mark.asyncio + async def test_integration_legacy_fallback_no_prefix_ignored(self): + """Integration test confirms that when no prefix is configured, matching bot-login comments are always ignored (legacy fallback).""" + worker = OrchestratorWorker(consumer_name="test-worker") + state = { + "ticket_key": "TEST-123", + "current_node": "human_review_gate", + "current_repo": "owner/repo", + "current_pr_number": 42, + "is_paused": True, + "context": {}, + } + # Sender matches bot login, prefix is not configured + message = QueueMessage( + message_id="msg-123", + event_id="evt-123", + source=EventSource.GITHUB, + event_type="pull_request_review:submitted", + ticket_key="TEST-123", + payload={ + "review": { + "id": 100, + "state": "changes_requested", + "body": "Some body without signature", + "user": {"login": "dev-user", "type": "User"}, + }, + "pull_request": {"number": 42}, + "repository": {"full_name": "owner/repo"}, + "sender": {"login": "dev-user", "type": "User"}, + }, + ) + + # Prefix is empty/None/disabled + settings = MagicMock(forge_bot_comment_prefix="") + + with ( + patch.object(worker, "_get_forge_github_login", new=AsyncMock(return_value="dev-user")), + patch("forge.orchestrator.worker.get_settings", return_value=settings), + patch("forge.orchestrator.worker.GitHubClient") as MockGH, + ): + mock_gh = AsyncMock() + MockGH.return_value = mock_gh + + result = await worker._handle_resume_event(message, state) + + # It should be ignored under the legacy fallback because prefix is empty + assert result is state + assert result.get("is_paused") is True diff --git a/tests/unit/orchestrator/test_worker_prd_pr.py b/tests/unit/orchestrator/test_worker_prd_pr.py index 391e74f87..9f523198f 100644 --- a/tests/unit/orchestrator/test_worker_prd_pr.py +++ b/tests/unit/orchestrator/test_worker_prd_pr.py @@ -341,6 +341,68 @@ async def test_self_comment_is_ignored(self, worker): # Should remain paused -- self-comment ignored assert result.get("is_paused", True) is True + @pytest.mark.asyncio + async def test_self_comment_with_signature_is_ignored(self, worker): + msg = _make_message( + "issue_comment:created", + { + "repository": {"full_name": "org/proposals"}, + "issue": {"number": 7}, + "comment": { + "body": "\n\nSome automated message.", + "user": {"login": "forge-bot"}, + }, + "sender": {"login": "forge-bot"}, + }, + ) + state = _prd_gate_state() + settings = MagicMock(forge_bot_comment_prefix="my-signature") + + with ( + patch("forge.orchestrator.worker.GitHubClient") as MockGH, + patch("forge.orchestrator.worker.get_settings", return_value=settings), + ): + mock_gh = MagicMock() + mock_gh.get_authenticated_user = AsyncMock(return_value={"login": "forge-bot"}) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = await worker._handle_resume_event(msg, state) + + # Should remain paused -- self-comment with signature ignored + assert result.get("is_paused", True) is True + + @pytest.mark.asyncio + async def test_own_comment_without_signature_is_not_ignored(self, worker): + msg = _make_message( + "issue_comment:created", + { + "repository": {"full_name": "org/proposals"}, + "issue": {"number": 7}, + "comment": { + "body": "!This is a comment without signature, treated as human comment.", + "user": {"login": "forge-bot"}, + }, + "sender": {"login": "forge-bot"}, + }, + ) + state = _prd_gate_state() + settings = MagicMock(forge_bot_comment_prefix="my-signature") + + with ( + patch("forge.orchestrator.worker.GitHubClient") as MockGH, + patch("forge.orchestrator.worker.get_settings", return_value=settings), + ): + mock_gh = MagicMock() + mock_gh.get_authenticated_user = AsyncMock(return_value={"login": "forge-bot"}) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = await worker._handle_resume_event(msg, state) + + # Should be processed and no longer paused + assert result.get("is_paused") is False + @pytest.mark.asyncio async def test_question_comment_sets_question_flag(self, worker): msg = _make_message( diff --git a/tests/unit/test_config_bot_signature.py b/tests/unit/test_config_bot_signature.py new file mode 100644 index 000000000..d7028db7e --- /dev/null +++ b/tests/unit/test_config_bot_signature.py @@ -0,0 +1,49 @@ +"""Tests for bot signature/comment prefix configuration.""" + +from typing import Any + +import pytest + +from forge.config import Settings + + +@pytest.fixture(autouse=True) +def clear_bot_prefix_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FORGE_BOT_COMMENT_PREFIX", raising=False) + monkeypatch.delenv("LLM_BACKEND", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False) + monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.delenv("CONTAINER_LLM_MODEL", raising=False) + monkeypatch.delenv("MODEL_CONNECTIONS", raising=False) + monkeypatch.delenv("MODEL_DEFAULT", raising=False) + monkeypatch.delenv("MODEL_POLICY", raising=False) + + +def make_settings(**kwargs: Any) -> Settings: + # Use dummy values for required settings so that Settings can instantiate + kwargs.setdefault("jira_base_url", "https://test.atlassian.net") + kwargs.setdefault("jira_api_token", "test-token") + kwargs.setdefault("jira_user_email", "test@example.com") + kwargs.setdefault("github_token", "test-github-token") + kwargs.setdefault("llm_backend", "vertex-ai") + kwargs.setdefault("llm_model", "gemini-3.5-flash") + kwargs.setdefault("google_cloud_project", "test-project") + return Settings(**kwargs) + + +class TestBotSignatureConfig: + def test_default_bot_comment_prefix_is_empty(self) -> None: + settings = make_settings() + assert settings.forge_bot_comment_prefix == "" + + def test_bot_comment_prefix_can_be_set_via_init(self) -> None: + settings = make_settings(forge_bot_comment_prefix="[BOT-SIG] ") + assert settings.forge_bot_comment_prefix == "[BOT-SIG] " + + def test_bot_comment_prefix_is_loaded_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FORGE_BOT_COMMENT_PREFIX", "[FORGE] ") + settings = make_settings() + assert settings.forge_bot_comment_prefix == "[FORGE] " diff --git a/tests/unit/workflow/utils/test_automated_review_triage.py b/tests/unit/workflow/utils/test_automated_review_triage.py index a219f773c..69b8b4fbf 100644 --- a/tests/unit/workflow/utils/test_automated_review_triage.py +++ b/tests/unit/workflow/utils/test_automated_review_triage.py @@ -1,6 +1,12 @@ +from types import SimpleNamespace + +import pytest + from forge.workflow.utils.automated_review_triage import ( is_bot_sender, + is_self_comment, parse_automated_review_decision, + prepend_bot_prefix, ) @@ -25,3 +31,232 @@ def test_parse_failure_is_uncertain() -> None: ).verdict == "uncertain" ) + + +def test_prepend_bot_prefix_empty_prefix(monkeypatch: pytest.MonkeyPatch) -> None: + # 1. Fallback to settings with empty prefix + mock_settings = SimpleNamespace(forge_bot_comment_prefix="") + import forge.config + + monkeypatch.setattr(forge.config, "get_settings", lambda: mock_settings) + + # Empty prefix in settings, and prefix parameter is None/omitted + assert prepend_bot_prefix("This is a comment", prefix=None) == "This is a comment" + assert prepend_bot_prefix("This is a comment") == "This is a comment" + + # 2. Empty prefix via parameter override + assert prepend_bot_prefix("This is a comment", prefix="") == "This is a comment" + assert prepend_bot_prefix("This is a comment", prefix=" ") == "This is a comment" + + +def test_prepend_bot_prefix_normal_prefix() -> None: + # Prefix not wrapped + assert ( + prepend_bot_prefix("This is a comment", prefix="my-prefix") + == "\n\nThis is a comment" + ) + # When comment body is empty + assert prepend_bot_prefix("", prefix="my-prefix") == "" + + +def test_prepend_bot_prefix_already_wrapped_prefix() -> None: + # Prefix already wrapped with spaces + assert ( + prepend_bot_prefix("This is a comment", prefix="") + == "\n\nThis is a comment" + ) + # Prefix already wrapped without internal spaces + assert ( + prepend_bot_prefix("This is a comment", prefix="") + == "\n\nThis is a comment" + ) + + +def test_prepend_bot_prefix_already_prepended_comment() -> None: + # Comment already starts with the wrapped prefix (exact) + comment = "\n\nThis is a comment" + assert prepend_bot_prefix(comment, prefix="my-prefix") == comment + + # Comment already starts with the wrapped prefix, but exact match of the prefix itself + comment_only_prefix = "" + assert prepend_bot_prefix(comment_only_prefix, prefix="my-prefix") == comment_only_prefix + + # Comment already starts with wrapped prefix with leading/trailing whitespaces in comment + comment_with_whitespace = " \n \n\nThis is a comment" + assert ( + prepend_bot_prefix(comment_with_whitespace, prefix="my-prefix") == comment_with_whitespace + ) + + +def test_prepend_bot_prefix_parameter_override(monkeypatch: pytest.MonkeyPatch) -> None: + mock_settings = SimpleNamespace(forge_bot_comment_prefix="settings-prefix") + import forge.config + + monkeypatch.setattr(forge.config, "get_settings", lambda: mock_settings) + + # When prefix parameter is explicitly passed, it should override settings-prefix + assert ( + prepend_bot_prefix("This is a comment", prefix="param-prefix") + == "\n\nThis is a comment" + ) + + +def test_prepend_bot_prefix_settings_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + mock_settings = SimpleNamespace(forge_bot_comment_prefix="settings-prefix") + import forge.config + + monkeypatch.setattr(forge.config, "get_settings", lambda: mock_settings) + + # Fallback when prefix is None or omitted + assert ( + prepend_bot_prefix("This is a comment", prefix=None) + == "\n\nThis is a comment" + ) + assert ( + prepend_bot_prefix("This is a comment") == "\n\nThis is a comment" + ) + + +def test_is_self_comment_bot_suffix() -> None: + # Usernames ending in [bot] are identified as self-comments if they match the bot login base + assert is_self_comment("my-app[bot]", "Hello", "my-app", "some-prefix") is True + assert is_self_comment("my-app[BOT]", "Hello", "my-app", "some-prefix") is True + assert is_self_comment("forge-bot[bot]", "Some text", "forge-bot", None) is True + + +def test_is_self_comment_prefix_matching() -> None: + # Configured prefix matching correctly identifies self-comments when the comment body starts with the prefix + assert ( + is_self_comment( + "forge-bot", " This is bot comment", "forge-bot", "my-prefix" + ) + is True + ) + assert ( + is_self_comment( + "forge-bot", " This is bot comment", "forge-bot", "my-prefix" + ) + is True + ) + assert ( + is_self_comment("forge-bot", "my-prefix This is bot comment", "forge-bot", "my-prefix") + is True + ) + assert ( + is_self_comment( + "FORGE-BOT", " This is bot comment", "forge-bot", "my-prefix" + ) + is True + ) + # Check leading whitespace handling + assert ( + is_self_comment( + "forge-bot", " \n This is bot comment", "forge-bot", "my-prefix" + ) + is True + ) + + +def test_is_self_comment_prefix_matching_returns_false_if_no_match() -> None: + # Configured prefix matching returns False when the comment is from the bot login but the body does not start with the prefix + assert is_self_comment("forge-bot", "This is human comment", "forge-bot", "my-prefix") is False + assert ( + is_self_comment("forge-bot", "Some prefix-like text but not it", "forge-bot", "my-prefix") + is False + ) + + +def test_is_self_comment_prefix_matching_returns_false_if_sender_mismatch() -> None: + assert ( + is_self_comment( + "other-user", " This is bot comment", "forge-bot", "my-prefix" + ) + is False + ) + + +def test_is_self_comment_legacy_fallback() -> None: + # Legacy fallback (empty/unset prefix) matches exactly by username (case-insensitive) + assert is_self_comment("forge-bot", "Hello", "forge-bot", None) is True + assert is_self_comment("forge-bot", "Hello", "forge-bot", "") is True + assert is_self_comment("forge-bot", "Hello", "forge-bot", " ") is True + assert is_self_comment("FORGE-bot", "Hello", "forge-bot", "") is True + assert is_self_comment("other-user", "Hello", "forge-bot", None) is False + + +def test_is_self_comment_sc001_prefix_configured() -> None: + # 1. Body starts with prefix (exact, wrapped with space, wrapped without space) + assert ( + is_self_comment( + "forge-bot", " This is bot comment", "forge-bot", "my-prefix" + ) + is True + ) + assert ( + is_self_comment( + "forge-bot", " This is bot comment", "forge-bot", "my-prefix" + ) + is True + ) + assert ( + is_self_comment("forge-bot", "my-prefix This is bot comment", "forge-bot", "my-prefix") + is True + ) + + # 2. Body contains prefix but not at start + assert ( + is_self_comment( + "forge-bot", + "This is bot comment but is in middle", + "forge-bot", + "my-prefix", + ) + is False + ) + assert ( + is_self_comment( + "forge-bot", + "Some text, then my-prefix", + "forge-bot", + "my-prefix", + ) + is False + ) + + # 3. Incorrect username with prefix (even if body starts with prefix, sender mismatch should return False) + assert ( + is_self_comment( + "other-user", " This is bot comment", "forge-bot", "my-prefix" + ) + is False + ) + + +def test_is_self_comment_sc002_prefix_empty_disabled() -> None: + # 1. Matching username (should fallback to username match and return True) + assert is_self_comment("forge-bot", "Hello", "forge-bot", None) is True + assert is_self_comment("forge-bot", "Hello", "forge-bot", "") is True + assert is_self_comment("forge-bot", "Hello", "forge-bot", " ") is True + assert is_self_comment("FORGE-bot", "Hello", "forge-bot", None) is True + + # 2. Different username (should fallback to username match and return False) + assert is_self_comment("other-user", "Hello", "forge-bot", None) is False + assert is_self_comment("other-user", "Hello", "forge-bot", "") is False + assert is_self_comment("other-user", "Hello", "forge-bot", " ") is False + + +def test_is_self_comment_sc003_prefix_configured_body_not_start_with_prefix() -> None: + # Prefix configured, matching username, body does NOT start with prefix + assert is_self_comment("forge-bot", "This is human comment", "forge-bot", "my-prefix") is False + assert ( + is_self_comment("forge-bot", "Some prefix-like text but not it", "forge-bot", "my-prefix") + is False + ) + + +def test_is_self_comment_sc004_sender_username_bot_suffix() -> None: + # Sender username ending in [bot] (case-insensitive) + assert is_self_comment("my-app[bot]", "Hello", "my-app", "some-prefix") is True + assert is_self_comment("my-app[BOT]", "Hello", "my-app", "some-prefix") is True + assert is_self_comment("forge-bot[bot]", "Some text", "forge-bot", None) is True + assert is_self_comment("github-actions[bot]", "Any comment body", "github-actions", "") is True