diff --git a/agent/utils/github_comments.py b/agent/utils/github_comments.py index be0c8e80..9966216b 100644 --- a/agent/utils/github_comments.py +++ b/agent/utils/github_comments.py @@ -16,6 +16,8 @@ from .github_user_email_map import GITHUB_USER_EMAIL_MAP logger = logging.getLogger(__name__) OPEN_SWE_TAGS = ("@openswe", "@open-swe", "@openswe-dev") +_OPEN_SWE_MENTION_RE = re.compile(r"(?i)@(?:openswe-dev|open-swe|openswe)\b") +_REVIEW_COMMAND_RE = re.compile(r"(?i)\Areview(?:\s+(https?://\S+))?\s*\Z") UNTRUSTED_GITHUB_COMMENT_OPEN_TAG = "" UNTRUSTED_GITHUB_COMMENT_CLOSE_TAG = "" _SANITIZED_UNTRUSTED_GITHUB_COMMENT_OPEN_TAG = "[blocked-untrusted-comment-tag-open]" @@ -57,6 +59,23 @@ def get_thread_id_from_branch(branch_name: str) -> str | None: return match.group(0) if match else None +def parse_github_review_command(body: str) -> tuple[bool, str | None]: + """Detect ``@open-swe review [URL]`` in a GitHub comment body. + + Returns ``(is_review_command, optional_pr_url)``. When ``is_review_command`` + is True and the URL is None, the command applies to the PR being commented + on. The URL — if present — is returned as-is so the caller can validate it + with ``parse_github_pr_url``. + """ + if not body: + return False, None + stripped = _OPEN_SWE_MENTION_RE.sub("", body).strip() + match = _REVIEW_COMMAND_RE.match(stripped) + if not match: + return False, None + return True, match.group(1) or None + + def sanitize_github_comment_body(body: str) -> str: """Strip reserved trust wrapper tags from raw GitHub comment bodies.""" sanitized = body.replace( diff --git a/agent/webapp.py b/agent/webapp.py index e671348c..38811595 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -38,6 +38,7 @@ from .utils.github_comments import ( fetch_pr_comments_since_last_tag, format_github_comment_body_for_prompt, get_thread_id_from_branch, + parse_github_review_command, react_to_github_comment, sanitize_github_comment_body, verify_github_signature, @@ -57,6 +58,7 @@ from .utils.slack import ( get_slack_user_info, get_slack_user_names, looks_like_slack_pr_review_command, + parse_github_pr_url, parse_slack_review_command, post_slack_thread_reply, post_slack_trace_reply, @@ -1592,6 +1594,75 @@ async def process_github_pr_review_request(payload: dict[str, Any]) -> None: logger.info("Reviewer run created for thread %s from GitHub PR review request", thread_id) +async def process_github_pr_review_command( + payload: dict[str, Any], + event_type: str, + pr_url_override: str | None, +) -> None: + """Trigger the reviewer when a PR comment contains ``@open-swe review``. + + ``pr_url_override`` is the optional URL token that followed ``review``. If + set, the review targets that PR; otherwise the comment's own PR is used. + """ + repo = payload.get("repository", {}) + repo_config = { + "owner": repo.get("owner", {}).get("login", ""), + "name": repo.get("name", ""), + } + pr_data = payload.get("pull_request") or payload.get("issue", {}) + sender = payload.get("sender", {}) + github_login = sender.get("login", "") + github_user_id = sender.get("id") + + pr_ref: GitHubPrRef | None = None + if pr_url_override: + pr_ref = parse_github_pr_url(pr_url_override) + if pr_ref is None: + logger.info("Ignoring @open-swe review with unparseable URL %s", pr_url_override) + return + else: + pr_number = pr_data.get("number") + if not pr_number: + logger.warning("@open-swe review command missing pr_number, skipping") + return + pr_ref = GitHubPrRef( + owner=repo_config["owner"], + repo=repo_config["name"], + number=pr_number, + url=pr_data.get("html_url", "") or pr_data.get("url", ""), + ) + + comment = payload.get("comment") or payload.get("review", {}) + comment_id = comment.get("id") + node_id = comment.get("node_id") if event_type == "pull_request_review" else None + if comment_id: + app_token = await get_github_app_installation_token() + if app_token: + await react_to_github_comment( + repo_config, + comment_id, + event_type=event_type, + token=app_token, + pull_number=pr_data.get("number"), + node_id=node_id, + ) + + result = await trigger_pr_review_from_ref( + pr_ref, + source="github", + github_login=github_login, + github_user_id=github_user_id, + ) + if not result.get("success"): + logger.warning( + "Failed to trigger reviewer from @open-swe review on %s/%s#%s: %s", + pr_ref.owner, + pr_ref.repo, + pr_ref.number, + result.get("error"), + ) + + async def _fetch_open_pr_for_branch( repo_config: dict[str, str], head_ref: str, *, token: str ) -> dict[str, Any] | None: @@ -2177,6 +2248,14 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) -> "pull_request_review_comment", "pull_request_review", }: + is_review_command, pr_url_override = parse_github_review_command(comment_body) + if is_review_command: + if not _is_repo_allowed_for_reviewer(webhook_repo_config): + return {"status": "ignored", "reason": "Repository not in reviewer allowlist"} + background_tasks.add_task( + process_github_pr_review_command, payload, event_type, pr_url_override + ) + return {"status": "accepted", "message": "Processing GitHub PR review command"} background_tasks.add_task(process_github_pr_comment, payload, event_type) return {"status": "accepted", "message": f"Processing {event_type} event"} diff --git a/tests/test_github_issue_webhook.py b/tests/test_github_issue_webhook.py index d2553acc..ad528f3a 100644 --- a/tests/test_github_issue_webhook.py +++ b/tests/test_github_issue_webhook.py @@ -1019,3 +1019,233 @@ def test_process_github_issue_existing_thread_uses_followup_prompt(monkeypatch) assert captured["prompt"] == "**octocat:**\n@openswe please handle this" assert "## Repository" not in captured["prompt"] + + +def test_parse_github_review_command_standalone() -> None: + is_review, url = github_comments.parse_github_review_command("@open-swe review") + assert is_review is True + assert url is None + + +def test_parse_github_review_command_with_url() -> None: + is_review, url = github_comments.parse_github_review_command( + "@open-swe review https://github.com/langchain-ai/open-swe/pull/1244" + ) + assert is_review is True + assert url == "https://github.com/langchain-ai/open-swe/pull/1244" + + +def test_parse_github_review_command_case_insensitive_and_aliases() -> None: + assert github_comments.parse_github_review_command("@OpenSWE Review") == (True, None) + assert github_comments.parse_github_review_command("@openswe review") == (True, None) + assert github_comments.parse_github_review_command("@openswe-dev review") == (True, None) + + +def test_parse_github_review_command_freeform_does_not_match() -> None: + assert github_comments.parse_github_review_command("@open-swe review my code") == (False, None) + assert github_comments.parse_github_review_command("@open-swe please review") == (False, None) + assert github_comments.parse_github_review_command("@open-swe fix this") == (False, None) + assert github_comments.parse_github_review_command("") == (False, None) + + +def test_parse_github_review_command_does_not_swallow_trailing_text() -> None: + # Trailing text after `review` (on a new line, with punctuation, or extra + # words) must NOT be parsed as a URL — otherwise the comment would be + # silently dropped instead of falling through to the regular handler. + assert github_comments.parse_github_review_command("@open-swe review\nthanks!") == ( + False, + None, + ) + assert github_comments.parse_github_review_command("@open-swe review please") == (False, None) + assert github_comments.parse_github_review_command("@open-swe review now") == (False, None) + # Non-http schemes are not URLs we can route to a PR. + assert github_comments.parse_github_review_command("@open-swe review ftp://x/y/pull/1") == ( + False, + None, + ) + + +def test_github_webhook_routes_pr_comment_review_to_reviewer(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_process_pr_comment(payload: dict[str, object], event_type: str) -> None: + raise AssertionError("process_github_pr_comment should not be called for review command") + + async def fake_process_review_command( + payload: dict[str, object], event_type: str, pr_url_override: str | None + ) -> None: + captured["payload"] = payload + captured["event_type"] = event_type + captured["pr_url_override"] = pr_url_override + + monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_pr_comment) + monkeypatch.setattr(webapp, "process_github_pr_review_command", fake_process_review_command) + monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET) + monkeypatch.setattr( + webapp, "ALLOWED_REVIEWER_GITHUB_REPOS", frozenset({"langchain-ai/open-swe"}) + ) + monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"})) + + client = TestClient(webapp.app) + response = _post_github_webhook( + client, + "issue_comment", + { + "action": "created", + "issue": { + "id": 12345, + "number": 1244, + "pull_request": {"url": "https://api.github.com/repos/x/y/pulls/1244"}, + }, + "comment": {"id": 9, "body": "@open-swe review"}, + "repository": {"owner": {"login": "langchain-ai"}, "name": "open-swe"}, + "sender": {"login": "octocat"}, + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "status": "accepted", + "message": "Processing GitHub PR review command", + } + assert captured["event_type"] == "issue_comment" + assert captured["pr_url_override"] is None + + +def test_github_webhook_blocks_pr_review_command_outside_reviewer_allowlist(monkeypatch) -> None: + async def fake_process_review_command( + payload: dict[str, object], event_type: str, pr_url_override: str | None + ) -> None: + raise AssertionError("process_github_pr_review_command should not run") + + monkeypatch.setattr(webapp, "process_github_pr_review_command", fake_process_review_command) + monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET) + monkeypatch.setattr( + webapp, "ALLOWED_REVIEWER_GITHUB_REPOS", frozenset({"langchain-ai/open-swe"}) + ) + monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"})) + + client = TestClient(webapp.app) + response = _post_github_webhook( + client, + "issue_comment", + { + "action": "created", + "issue": { + "id": 12345, + "number": 1244, + "pull_request": {"url": "https://api.github.com/repos/x/y/pulls/1244"}, + }, + "comment": {"id": 9, "body": "@open-swe review"}, + "repository": {"owner": {"login": "langchain-ai"}, "name": "public-demo"}, + "sender": {"login": "octocat"}, + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "status": "ignored", + "reason": "Repository not in reviewer allowlist", + } + + +def test_process_github_pr_review_command_uses_payload_pr(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_get_app_token() -> str: + return "app-token" + + async def fake_react(*args, **kwargs) -> None: + captured["reacted"] = True + + async def fake_trigger( + pr_ref: GitHubPrRef, + *, + source: str, + github_login: str = "", + github_user_id: int | None = None, + slack_channel_id: str = "", + slack_thread_ts: str = "", + ) -> dict[str, object]: + captured["pr_ref"] = pr_ref + captured["source"] = source + captured["github_login"] = github_login + captured["github_user_id"] = github_user_id + return {"success": True, "thread_id": "tid", "pr_url": pr_ref.url} + + monkeypatch.setattr(webapp, "get_github_app_installation_token", fake_get_app_token) + monkeypatch.setattr(webapp, "react_to_github_comment", fake_react) + monkeypatch.setattr(webapp, "trigger_pr_review_from_ref", fake_trigger) + + asyncio.run( + webapp.process_github_pr_review_command( + { + "issue": { + "number": 1244, + "html_url": "https://github.com/langchain-ai/open-swe/pull/1244", + "pull_request": {"url": "..."}, + }, + "comment": {"id": 9, "body": "@open-swe review"}, + "repository": {"owner": {"login": "langchain-ai"}, "name": "open-swe"}, + "sender": {"login": "octocat", "id": 123}, + }, + "issue_comment", + None, + ) + ) + + pr_ref = captured["pr_ref"] + assert isinstance(pr_ref, GitHubPrRef) + assert pr_ref.owner == "langchain-ai" + assert pr_ref.repo == "open-swe" + assert pr_ref.number == 1244 + assert pr_ref.url == "https://github.com/langchain-ai/open-swe/pull/1244" + assert captured["source"] == "github" + assert captured["github_login"] == "octocat" + assert captured["github_user_id"] == 123 + assert captured.get("reacted") is True + + +def test_process_github_pr_review_command_uses_url_override(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_get_app_token() -> str: + return "app-token" + + async def fake_react(*args, **kwargs) -> None: + return None + + async def fake_trigger( + pr_ref: GitHubPrRef, + *, + source: str, + github_login: str = "", + github_user_id: int | None = None, + slack_channel_id: str = "", + slack_thread_ts: str = "", + ) -> dict[str, object]: + captured["pr_ref"] = pr_ref + return {"success": True, "thread_id": "tid", "pr_url": pr_ref.url} + + monkeypatch.setattr(webapp, "get_github_app_installation_token", fake_get_app_token) + monkeypatch.setattr(webapp, "react_to_github_comment", fake_react) + monkeypatch.setattr(webapp, "trigger_pr_review_from_ref", fake_trigger) + + asyncio.run( + webapp.process_github_pr_review_command( + { + "issue": {"number": 99, "pull_request": {"url": "..."}}, + "comment": {"id": 1, "body": "@open-swe review https://github.com/x/y/pull/7"}, + "repository": {"owner": {"login": "x"}, "name": "y"}, + "sender": {"login": "octocat", "id": 1}, + }, + "issue_comment", + "https://github.com/x/y/pull/7", + ) + ) + + pr_ref = captured["pr_ref"] + assert isinstance(pr_ref, GitHubPrRef) + assert pr_ref.number == 7 + assert pr_ref.owner == "x" + assert pr_ref.repo == "y"