From adc14a684338420a111c47e60133019b474093d2 Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 14:00:49 +0000 Subject: [PATCH 01/13] [AISOS-2395] Configure Bot Signature Prefix in Pydantic Settings Detailed description: - Added forge_bot_comment_prefix str setting in Settings class inside src/forge/config.py, defaulting to an empty string. - Documented and exposed FORGE_BOT_COMMENT_PREFIX in .env.example under GitHub Configuration section. - Added comprehensive unit tests in tests/unit/test_config_bot_signature.py to verify defaults and loading from environment variables. Closes: AISOS-2395 --- .env.example | 2 + src/forge/config.py | 4 ++ tests/unit/test_config_bot_signature.py | 49 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 tests/unit/test_config_bot_signature.py diff --git a/.env.example b/.env.example index f1b1517b..03b8d912 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/src/forge/config.py b/src/forge/config.py index 3e624ade..4e76aee5 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/tests/unit/test_config_bot_signature.py b/tests/unit/test_config_bot_signature.py new file mode 100644 index 00000000..bcfafad5 --- /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(_env_file=None, **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] " From baea7c1948f1c1dda303140c136dad2d14448ca6 Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 14:06:00 +0000 Subject: [PATCH 02/13] [AISOS-2396] Implement prepend_bot_prefix Utility Function Detailed description: - Created the prepend_bot_prefix utility function in src/forge/workflow/utils/automated_review_triage.py to prepend bot signatures/prefixes to comment bodies. - Handled automatic wrapping of the prefix in HTML comments if it's not already wrapped. - Integrated fallback to settings.forge_bot_comment_prefix when no prefix parameter is supplied. - Avoided double-prepending by returning the comment body as-is if it already starts with the wrapped prefix. - Created robust unit tests covering fallback behavior, empty/whitespace prefixes, already-wrapped prefixes, and prefix overrides. Closes: AISOS-2396 --- .../workflow/utils/automated_review_triage.py | 43 +++++++-- .../utils/test_automated_review_triage.py | 89 +++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/src/forge/workflow/utils/automated_review_triage.py b/src/forge/workflow/utils/automated_review_triage.py index aea280d1..155bac78 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,44 @@ 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 prepend_bot_prefix(comment_body: str, prefix: str | None = None) -> str: + """Prepend a bot signature/comment prefix to the comment body.""" + 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/unit/workflow/utils/test_automated_review_triage.py b/tests/unit/workflow/utils/test_automated_review_triage.py index a219f773..ee106ee4 100644 --- a/tests/unit/workflow/utils/test_automated_review_triage.py +++ b/tests/unit/workflow/utils/test_automated_review_triage.py @@ -1,6 +1,11 @@ +from types import SimpleNamespace + +import pytest + from forge.workflow.utils.automated_review_triage import ( is_bot_sender, parse_automated_review_decision, + prepend_bot_prefix, ) @@ -25,3 +30,87 @@ 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" + ) From b5b1e0b2d4be3998337413dc1c7778fc2f34862c Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 14:11:13 +0000 Subject: [PATCH 03/13] [AISOS-2397] Integrate Outbound Comment Signing in GitHubClient Detailed description: - Integrated outbound comment signing prefix in all low-level comment creation and reply methods in the GitHubClient class (`create_review_comment`, `create_issue_comment`, and `reply_to_review_comment`). - Ensured comments are sent unaltered when `forge_bot_comment_prefix` is empty or disabled. - Added comprehensive unit tests in a new test file `tests/unit/integrations/github/test_outbound_signature.py` to assert the signature is correctly prepended or sent unaltered based on configuration settings. Closes: AISOS-2397 --- src/forge/integrations/github/client.py | 9 + .../github/test_outbound_signature.py | 188 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 tests/unit/integrations/github/test_outbound_signature.py diff --git a/src/forge/integrations/github/client.py b/src/forge/integrations/github/client.py index 8d8818ba..39f8eeb4 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/tests/unit/integrations/github/test_outbound_signature.py b/tests/unit/integrations/github/test_outbound_signature.py new file mode 100644 index 00000000..43d3ca35 --- /dev/null +++ b/tests/unit/integrations/github/test_outbound_signature.py @@ -0,0 +1,188 @@ +"""Tests for GitHub outbound comment signature/prefix integration.""" + +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: GitHubClient, mock_settings: Settings + ) -> 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: GitHubClient, mock_settings: Settings + ) -> 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: GitHubClient, mock_settings: Settings + ) -> 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: GitHubClient, mock_settings: Settings + ) -> 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: GitHubClient, mock_settings: Settings + ) -> 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: GitHubClient, mock_settings: Settings + ) -> 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." From 7637e76723eddecc33a7b3febb5ff021943bef66 Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 14:16:58 +0000 Subject: [PATCH 04/13] [AISOS-2398] Implement is_self_comment Core Logic Detailed description: - Implemented the `is_self_comment` core helper function in `src/forge/workflow/utils/automated_review_triage.py` to identify comments belonging to the bot using dual-check or legacy username logic. - Supported prefix/signature checks with O(1) startswith complexity. - Enabled immediate self-comment resolution for logins ending in `[bot]`. - Added comprehensive unit tests in `tests/unit/workflow/utils/test_automated_review_triage.py`. Closes: AISOS-2398 --- .../workflow/utils/automated_review_triage.py | 31 ++++++++++ .../utils/test_automated_review_triage.py | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/forge/workflow/utils/automated_review_triage.py b/src/forge/workflow/utils/automated_review_triage.py index 155bac78..09e82667 100644 --- a/src/forge/workflow/utils/automated_review_triage.py +++ b/src/forge/workflow/utils/automated_review_triage.py @@ -34,6 +34,37 @@ def is_bot_sender(payload: dict[str, Any]) -> bool: return bool(sender_type.lower() == "bot" or review_user_type.lower() == "bot") +def is_self_comment( + sender_login: str, + comment_body: str, + 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. + """ + if sender_login.lower().endswith("[bot]"): + return True + + if prefix and prefix.strip(): + if sender_login.lower() == bot_login.lower(): + prefix_stripped = prefix.strip() + if prefix_stripped.startswith(""): + wrapped_prefix = prefix_stripped + else: + wrapped_prefix = f"" + + prefixes_to_check = (prefix, prefix_stripped, wrapped_prefix) + return comment_body.startswith(prefixes_to_check) or comment_body.lstrip().startswith( + prefixes_to_check + ) + return False + + return sender_login.lower() == bot_login.lower() + + def prepend_bot_prefix(comment_body: str, prefix: str | None = None) -> str: """Prepend a bot signature/comment prefix to the comment body.""" if prefix is None: diff --git a/tests/unit/workflow/utils/test_automated_review_triage.py b/tests/unit/workflow/utils/test_automated_review_triage.py index ee106ee4..70cb6d0e 100644 --- a/tests/unit/workflow/utils/test_automated_review_triage.py +++ b/tests/unit/workflow/utils/test_automated_review_triage.py @@ -4,6 +4,7 @@ from forge.workflow.utils.automated_review_triage import ( is_bot_sender, + is_self_comment, parse_automated_review_decision, prepend_bot_prefix, ) @@ -114,3 +115,64 @@ def test_prepend_bot_prefix_settings_fallback(monkeypatch: pytest.MonkeyPatch) - 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 always identified as self-comments + assert is_self_comment("my-app[bot]", "Hello", "forge-bot", "some-prefix") is True + assert is_self_comment("my-app[BOT]", "Hello", "forge-bot", "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", "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 From aebd78f9148de633e5e94482e0a832c6e241be86 Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 14:20:21 +0000 Subject: [PATCH 05/13] chore: fix typing warnings and clean up Settings init in tests --- .../integrations/github/test_outbound_signature.py | 13 +++++++------ tests/unit/test_config_bot_signature.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/unit/integrations/github/test_outbound_signature.py b/tests/unit/integrations/github/test_outbound_signature.py index 43d3ca35..2b3b503f 100644 --- a/tests/unit/integrations/github/test_outbound_signature.py +++ b/tests/unit/integrations/github/test_outbound_signature.py @@ -1,5 +1,6 @@ """Tests for GitHub outbound comment signature/prefix integration.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock import httpx @@ -22,7 +23,7 @@ def github_client(mock_settings: Settings) -> GitHubClient: class TestGitHubOutboundCommentSigning: @pytest.mark.asyncio async def test_create_review_comment_with_prefix( - self, github_client: GitHubClient, mock_settings: Settings + self, github_client: Any, mock_settings: Any ) -> None: # 1. Enable setting mock_settings.forge_bot_comment_prefix = "ForgeBotSignature" @@ -54,7 +55,7 @@ async def test_create_review_comment_with_prefix( @pytest.mark.asyncio async def test_create_review_comment_no_prefix( - self, github_client: GitHubClient, mock_settings: Settings + self, github_client: Any, mock_settings: Any ) -> None: # 2. Disable setting (empty) mock_settings.forge_bot_comment_prefix = "" @@ -81,7 +82,7 @@ async def test_create_review_comment_no_prefix( @pytest.mark.asyncio async def test_create_issue_comment_with_prefix( - self, github_client: GitHubClient, mock_settings: Settings + self, github_client: Any, mock_settings: Any ) -> None: # 1. Enable setting mock_settings.forge_bot_comment_prefix = "ForgeBotSignature" @@ -110,7 +111,7 @@ async def test_create_issue_comment_with_prefix( @pytest.mark.asyncio async def test_create_issue_comment_no_prefix( - self, github_client: GitHubClient, mock_settings: Settings + self, github_client: Any, mock_settings: Any ) -> None: # 2. Disable setting (empty) mock_settings.forge_bot_comment_prefix = "" @@ -134,7 +135,7 @@ async def test_create_issue_comment_no_prefix( @pytest.mark.asyncio async def test_reply_to_review_comment_with_prefix( - self, github_client: GitHubClient, mock_settings: Settings + self, github_client: Any, mock_settings: Any ) -> None: # 1. Enable setting mock_settings.forge_bot_comment_prefix = "ForgeBotSignature" @@ -164,7 +165,7 @@ async def test_reply_to_review_comment_with_prefix( @pytest.mark.asyncio async def test_reply_to_review_comment_no_prefix( - self, github_client: GitHubClient, mock_settings: Settings + self, github_client: Any, mock_settings: Any ) -> None: # 2. Disable setting (empty) mock_settings.forge_bot_comment_prefix = "" diff --git a/tests/unit/test_config_bot_signature.py b/tests/unit/test_config_bot_signature.py index bcfafad5..d7028db7 100644 --- a/tests/unit/test_config_bot_signature.py +++ b/tests/unit/test_config_bot_signature.py @@ -31,7 +31,7 @@ def make_settings(**kwargs: Any) -> Settings: kwargs.setdefault("llm_backend", "vertex-ai") kwargs.setdefault("llm_model", "gemini-3.5-flash") kwargs.setdefault("google_cloud_project", "test-project") - return Settings(_env_file=None, **kwargs) + return Settings(**kwargs) class TestBotSignatureConfig: From 08ff99c81cd4d746d3b448c69a96400158620fdb Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 14:23:14 +0000 Subject: [PATCH 06/13] [AISOS-2398] Refine is_self_comment core logic for space-omitted wrappers and resolve type-check errors Detailed description: - Updated 'is_self_comment' in 'src/forge/workflow/utils/automated_review_triage.py' to check both '' (with space) and '' (without space) for prefix matching. - Annotated and typed 'prefixes_to_check' as 'tuple[str, ...]' to resolve mypy type validation errors. - Added corresponding assertions to 'tests/unit/workflow/utils/test_automated_review_triage.py' to verify the new suffix/wrapper logic and prevent regressions. Closes: AISOS-2398 --- src/forge/workflow/utils/automated_review_triage.py | 10 +++++++++- .../workflow/utils/test_automated_review_triage.py | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/forge/workflow/utils/automated_review_triage.py b/src/forge/workflow/utils/automated_review_triage.py index 09e82667..8644939f 100644 --- a/src/forge/workflow/utils/automated_review_triage.py +++ b/src/forge/workflow/utils/automated_review_triage.py @@ -51,12 +51,20 @@ def is_self_comment( if prefix and prefix.strip(): if sender_login.lower() == bot_login.lower(): 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, + ) - prefixes_to_check = (prefix, prefix_stripped, wrapped_prefix) return comment_body.startswith(prefixes_to_check) or comment_body.lstrip().startswith( prefixes_to_check ) diff --git a/tests/unit/workflow/utils/test_automated_review_triage.py b/tests/unit/workflow/utils/test_automated_review_triage.py index 70cb6d0e..81ddc424 100644 --- a/tests/unit/workflow/utils/test_automated_review_triage.py +++ b/tests/unit/workflow/utils/test_automated_review_triage.py @@ -132,6 +132,12 @@ def test_is_self_comment_prefix_matching() -> None: ) 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 From 7309ff3b1174386c511ceb9191bb9fcf999f56bd Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 14:26:55 +0000 Subject: [PATCH 07/13] [AISOS-2399] Create Unit Tests for is_self_comment Logic Detailed description: - Added comprehensive unit tests in tests/unit/workflow/utils/test_automated_review_triage.py for is_self_comment logic. - Implemented tests for SC-001 (prefix configured, body starts with prefix vs contains but not at start vs incorrect username with prefix). - Implemented tests for SC-002 (empty/disabled prefix, matching vs different username). - Implemented tests for SC-003 (configured prefix, matching username, body doesn't start with prefix). - Implemented tests for SC-004 (sender username ending in [bot] case-insensitively). - Formatted and linted the test file with Ruff. Closes: AISOS-2399 --- .../utils/test_automated_review_triage.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/unit/workflow/utils/test_automated_review_triage.py b/tests/unit/workflow/utils/test_automated_review_triage.py index 81ddc424..ddbd42ca 100644 --- a/tests/unit/workflow/utils/test_automated_review_triage.py +++ b/tests/unit/workflow/utils/test_automated_review_triage.py @@ -182,3 +182,81 @@ def test_is_self_comment_legacy_fallback() -> None: 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", "forge-bot", "some-prefix") is True + assert is_self_comment("my-app[BOT]", "Hello", "forge-bot", "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", "forge-bot", "") is True From 92ae74b0ae0bc5c569e2dcf788d8255e58c8b756 Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 14:43:33 +0000 Subject: [PATCH 08/13] [AISOS-2400] Integrate is_self_comment in Orchestrator Worker Event Handlers Detailed description: - Imported and integrated 'is_self_comment' in 'src/forge/orchestrator/worker.py' across all webhook resume logic endpoints (inline review comment replies, PRD issue comments, spec issue comments, PR review events, and proposal replies) to replace direct username equality checks. - Safely loaded 'forge_bot_comment_prefix' settings using 'getattr' fallback to maintain cross-branch configuration compatibility. - Fixed a bug in 'is_self_comment' in 'src/forge/workflow/utils/automated_review_triage.py' where other non-Forge bots ending in '[bot]' were mistakenly ignored as self-comments. Now properly validates they match the Forge bot name base. - Adjusted tests in 'tests/unit/workflow/utils/test_automated_review_triage.py' to align with the correct identity matching behavior. - Added comprehensive unit tests in 'tests/unit/orchestrator/test_worker_prd_pr.py' to cover the new signature-checking worker handlers. Closes: AISOS-2400 --- src/forge/orchestrator/worker.py | 68 +++++++++++++++---- .../workflow/utils/automated_review_triage.py | 16 ++++- tests/unit/orchestrator/test_worker_prd_pr.py | 62 +++++++++++++++++ .../utils/test_automated_review_triage.py | 12 ++-- 4 files changed, 135 insertions(+), 23 deletions(-) diff --git a/src/forge/orchestrator/worker.py b/src/forge/orchestrator/worker.py index 2ed39bbe..8b3dc360 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 8644939f..869372aa 100644 --- a/src/forge/workflow/utils/automated_review_triage.py +++ b/src/forge/workflow/utils/automated_review_triage.py @@ -45,11 +45,21 @@ def is_self_comment( Uses dual-check or legacy username logic with O(1) prefix match complexity and no external I/O overhead. """ - if sender_login.lower().endswith("[bot]"): + 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 sender_login.lower() == bot_login.lower(): + if is_same_bot: prefix_stripped = prefix.strip() prefixes_to_check: tuple[str, ...] if prefix_stripped.startswith(""): @@ -70,7 +80,7 @@ def is_self_comment( ) return False - return sender_login.lower() == bot_login.lower() + return is_same_bot def prepend_bot_prefix(comment_body: str, prefix: str | None = None) -> str: diff --git a/tests/unit/orchestrator/test_worker_prd_pr.py b/tests/unit/orchestrator/test_worker_prd_pr.py index 391e74f8..9f523198 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/workflow/utils/test_automated_review_triage.py b/tests/unit/workflow/utils/test_automated_review_triage.py index ddbd42ca..69b8b4fb 100644 --- a/tests/unit/workflow/utils/test_automated_review_triage.py +++ b/tests/unit/workflow/utils/test_automated_review_triage.py @@ -118,9 +118,9 @@ def test_prepend_bot_prefix_settings_fallback(monkeypatch: pytest.MonkeyPatch) - def test_is_self_comment_bot_suffix() -> None: - # Usernames ending in [bot] are always identified as self-comments - assert is_self_comment("my-app[bot]", "Hello", "forge-bot", "some-prefix") is True - assert is_self_comment("my-app[BOT]", "Hello", "forge-bot", "some-prefix") is True + # 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 @@ -256,7 +256,7 @@ def test_is_self_comment_sc003_prefix_configured_body_not_start_with_prefix() -> 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", "forge-bot", "some-prefix") is True - assert is_self_comment("my-app[BOT]", "Hello", "forge-bot", "some-prefix") is True + 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", "forge-bot", "") is True + assert is_self_comment("github-actions[bot]", "Any comment body", "github-actions", "") is True From 3fdc1c9bdfcd92a9a6d13a87d64e56182f75304b Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 14:59:32 +0000 Subject: [PATCH 09/13] [AISOS-2401] Implement Worker Loop and Webhook Integration Tests Detailed description: - Added comprehensive unit and integration tests inside tests/unit/orchestrator/test_worker.py to verify dual-check logic rules, legacy fallback, custom PAT account comments, and standard App bot suffix behaviors. - Added tests in tests/unit/api/routes/test_github_webhook.py to verify that standard App bot and custom dev PAT comment webhook deliveries are received, parsed, and successfully queued. - Resolved pre-existing linter warnings (SIM117) inside tests/unit/api/routes/test_github_webhook.py to ensure complete compliance. Closes: AISOS-2401 --- tests/unit/api/routes/test_github_webhook.py | 254 +++++++++++++----- tests/unit/orchestrator/test_worker.py | 261 +++++++++++++++++++ 2 files changed, 443 insertions(+), 72 deletions(-) diff --git a/tests/unit/api/routes/test_github_webhook.py b/tests/unit/api/routes/test_github_webhook.py index 2acf5d62..a9e52035 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/orchestrator/test_worker.py b/tests/unit/orchestrator/test_worker.py index e845fa70..f4a74384 100644 --- a/tests/unit/orchestrator/test_worker.py +++ b/tests/unit/orchestrator/test_worker.py @@ -2115,3 +2115,264 @@ 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 From dcf0087e666b02244fdd28a121a52ae477c04f7b Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 15:06:37 +0000 Subject: [PATCH 10/13] =?UTF-8?q?[AISOS-2392-review]=20Local=20code=20revi?= =?UTF-8?q?ew=20=E2=80=94=20fix=20breaking=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detailed description: - Handled potential None types for comment_body in is_self_comment and prepend_bot_prefix to prevent runtime AttributeError crashes during webhook processing. - Normalized comment_body to an empty string when it evaluates to falsy, ensuring robustness against null values. - Updated the type annotations for comment_body to str | None. Closes: AISOS-2392-review --- src/forge/workflow/utils/automated_review_triage.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/forge/workflow/utils/automated_review_triage.py b/src/forge/workflow/utils/automated_review_triage.py index 869372aa..278ca109 100644 --- a/src/forge/workflow/utils/automated_review_triage.py +++ b/src/forge/workflow/utils/automated_review_triage.py @@ -36,7 +36,7 @@ def is_bot_sender(payload: dict[str, Any]) -> bool: def is_self_comment( sender_login: str, - comment_body: str, + comment_body: str | None, bot_login: str, prefix: str | None = None, ) -> bool: @@ -45,6 +45,7 @@ def is_self_comment( 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() @@ -83,8 +84,9 @@ def is_self_comment( return is_same_bot -def prepend_bot_prefix(comment_body: str, prefix: str | None = None) -> str: +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 From 1913d7add3372708a686ffec37cf918fc708ff70 Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 11 Aug 2026 15:10:38 +0000 Subject: [PATCH 11/13] [AISOS-2392-docs] Update stale documentation for bot prefix config setting Detailed description: - Added description of FORGE_BOT_COMMENT_PREFIX environment variable to docs/reference/config.md under GitHub section. - This documents the new signature prefix settings for the bot comment signature and webhook self-comment filtering loop prevention logic. Closes: AISOS-2392-docs --- docs/reference/config.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/config.md b/docs/reference/config.md index f13fd5f2..6168e71a 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 | ### LLM From 2c226ecf81560b5095fb503ab540efdbffb7b039 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 12 Aug 2026 06:12:18 +0000 Subject: [PATCH 12/13] [AISOS-2392-review-fix] review: address PR feedback Detailed description: - Updated docs/reference/config.md to document the use case for FORGE_BOT_COMMENT_PREFIX. - Clarified that FORGE_BOT_COMMENT_PREFIX is intended to allow development and testing with the same user API keys that are used to comment to prevent webhook loops, and that it shouldn't be used in production. Closes: AISOS-2392-review-fix --- docs/reference/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/config.md b/docs/reference/config.md index 6168e71a..1576d194 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -19,7 +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 | +| `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 From a298fa7e4de512ce919bfa19f2981789060a5603 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 12 Aug 2026 06:18:07 +0000 Subject: [PATCH 13/13] [AISOS-2392-review-review-impl] Post-review code review and test initialization fix Detailed description: - Reviewed all prefix configuration and webhook filtering logic, confirming correctness. - Fixed a test initialization issue in tests/conftest.py by setting dummy environment variables so that importing from forge during pytest startup does not fail with Settings validation errors. - Formatted tests/unit/orchestrator/test_worker.py to ensure styling guidelines. Closes: AISOS-2392-review-review-impl --- tests/conftest.py | 8 ++++++++ tests/unit/orchestrator/test_worker.py | 12 +++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8555361e..d5a313d1 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/orchestrator/test_worker.py b/tests/unit/orchestrator/test_worker.py index f4a74384..b12b06c3 100644 --- a/tests/unit/orchestrator/test_worker.py +++ b/tests/unit/orchestrator/test_worker.py @@ -2155,9 +2155,7 @@ async def test_integration_bot_login_comment_without_prefix_processed_as_human_f settings = MagicMock(forge_bot_comment_prefix="my-signature") with ( - patch.object( - worker, "_get_forge_github_login", new=AsyncMock(return_value="dev-user") - ), + 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, ): @@ -2208,9 +2206,7 @@ async def test_integration_bot_login_comment_with_prefix_ignored_as_self_comment settings = MagicMock(forge_bot_comment_prefix="my-signature") with ( - patch.object( - worker, "_get_forge_github_login", new=AsyncMock(return_value="dev-user") - ), + 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, ): @@ -2362,9 +2358,7 @@ async def test_integration_legacy_fallback_no_prefix_ignored(self): settings = MagicMock(forge_bot_comment_prefix="") with ( - patch.object( - worker, "_get_forge_github_login", new=AsyncMock(return_value="dev-user") - ), + 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, ):