Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/forge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/forge/integrations/github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
68 changes: 54 additions & 14 deletions src/forge/orchestrator/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
94 changes: 89 additions & 5 deletions src/forge/workflow/utils/automated_review_triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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("<!--") and prefix_stripped.endswith("-->"):
wrapped_prefix = prefix_stripped
prefixes_to_check = (prefix, prefix_stripped, wrapped_prefix)
else:
wrapped_prefix = f"<!-- {prefix_stripped} -->"
wrapped_prefix_no_space = f"<!--{prefix_stripped}-->"
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("<!--") and prefix_stripped.endswith("-->"):
wrapped_prefix = prefix_stripped
else:
wrapped_prefix = f"<!-- {prefix_stripped} -->"

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:
Expand Down
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading