From 1319347dd93121a18fe81b2bcdcbdd42c0c54793 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Wed, 6 May 2026 17:14:43 -0700 Subject: [PATCH] feat: route Slack PR review requests (#1245) * feat: route Slack PR review requests Add a lightweight Slack review command path that starts the reviewer graph directly and gives the core agent a handoff tool when review requests are misrouted. * fix: harden Slack PR review routing * fix: validate Slack PR review URLs * fix: preserve malformed GitHub review routing --- agent/prompt.py | 2 + agent/server.py | 2 + agent/tools/__init__.py | 2 + agent/tools/request_pr_review.py | 17 ++ agent/utils/slack.py | 82 ++++++- agent/webapp.py | 163 ++++++++++++- tests/test_github_issue_webhook.py | 359 +++++++++++++++++++++++++++++ tests/test_slack_context.py | 68 ++++++ 8 files changed, 685 insertions(+), 10 deletions(-) create mode 100644 agent/tools/request_pr_review.py diff --git a/agent/prompt.py b/agent/prompt.py index 1b6d0afc..6726595b 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -114,6 +114,8 @@ If you make changes, communicate updates in the source channel: - For GitHub-triggered tasks, use `GH_TOKEN=dummy gh issue comment` or `GH_TOKEN=dummy gh pr comment` only after confirming the target issue or pull request. - If the task was not triggered from a known source (no Slack thread, no Linear ticket, no GitHub issue), skip the notification step. +If a Slack-triggered request is asking you to review a GitHub pull request, do not clone the repo, edit files, commit, push, or open a PR. Call `request_pr_review` once with the GitHub PR URL, then use `slack_thread_reply` to say whether the review was started or why it could not be started, and stop. + For tasks that require code changes, follow this order: 1. **Understand** — Read the issue/task carefully. Explore relevant files before making any changes. diff --git a/agent/server.py b/agent/server.py index 6404fe7d..b201c187 100644 --- a/agent/server.py +++ b/agent/server.py @@ -45,6 +45,7 @@ from .tools import ( linear_get_issue_comments, linear_list_teams, linear_update_issue, + request_pr_review, slack_read_thread_messages, slack_thread_reply, web_search, @@ -307,6 +308,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: linear_get_issue_comments, linear_list_teams, linear_update_issue, + request_pr_review, slack_read_thread_messages, slack_thread_reply, ], diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index c1857857..4a5baf86 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -7,6 +7,7 @@ from .linear_get_issue import linear_get_issue from .linear_get_issue_comments import linear_get_issue_comments from .linear_list_teams import linear_list_teams from .linear_update_issue import linear_update_issue +from .request_pr_review import request_pr_review from .slack_read_thread_messages import slack_read_thread_messages from .slack_thread_reply import slack_thread_reply from .web_search import web_search @@ -21,6 +22,7 @@ __all__ = [ "linear_get_issue_comments", "linear_list_teams", "linear_update_issue", + "request_pr_review", "slack_read_thread_messages", "slack_thread_reply", "web_search", diff --git a/agent/tools/request_pr_review.py b/agent/tools/request_pr_review.py new file mode 100644 index 00000000..d200009c --- /dev/null +++ b/agent/tools/request_pr_review.py @@ -0,0 +1,17 @@ +import asyncio +from typing import Any + +from agent.utils.slack import parse_github_pr_url +from agent.webapp import trigger_pr_review_from_ref + + +def request_pr_review(pr_url: str) -> dict[str, Any]: + """Start the reviewer agent for a GitHub pull request URL.""" + pr_ref = parse_github_pr_url(pr_url) + if not pr_ref: + return { + "success": False, + "error": "Expected a GitHub PR URL like https://github.com/OWNER/REPO/pull/NUMBER", + } + + return asyncio.run(trigger_pr_review_from_ref(pr_ref, source="slack")) diff --git a/agent/utils/slack.py b/agent/utils/slack.py index 93cc94f1..fefb3c61 100644 --- a/agent/utils/slack.py +++ b/agent/utils/slack.py @@ -9,7 +9,9 @@ import logging import os import re import time +from dataclasses import dataclass from typing import Any +from urllib.parse import urlparse import httpx @@ -19,6 +21,16 @@ logger = logging.getLogger(__name__) SLACK_API_BASE_URL = "https://slack.com/api" SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", "") +GITHUB_PR_URL_RE = re.compile(r"https?://(?:www\.)?github\.com/[^\s<>|]+/[^\s<>|]+/pull/\d+") +URL_RE = re.compile(r"https?://[^\s<>|]+") + + +@dataclass(frozen=True) +class GitHubPrRef: + owner: str + repo: str + number: int + url: str def _slack_headers() -> dict[str, str]: @@ -112,6 +124,66 @@ def strip_bot_mention(text: str, bot_user_id: str, bot_username: str = "") -> st return stripped.strip() +def parse_github_pr_url(url: str) -> GitHubPrRef | None: + cleaned_url = url.strip().strip("<>") + if "|" in cleaned_url: + cleaned_url = cleaned_url.split("|", 1)[0] + + parsed = urlparse(cleaned_url) + if parsed.scheme not in {"http", "https"}: + return None + if parsed.netloc.lower() not in {"github.com", "www.github.com"}: + return None + + path_parts = [part for part in parsed.path.split("/") if part] + if len(path_parts) < 4 or path_parts[2] != "pull": + return None + + try: + number = int(path_parts[3]) + except ValueError: + return None + + owner = path_parts[0] + repo = path_parts[1] + return GitHubPrRef( + owner=owner, + repo=repo, + number=number, + url=f"https://github.com/{owner}/{repo}/pull/{number}", + ) + + +def parse_slack_review_command(text: str) -> GitHubPrRef | None: + stripped = text.strip() + command_match = re.fullmatch(r"(?is)review\s+(.+)", stripped) + if not command_match: + return None + + rest = command_match.group(1).strip() + url_match = GITHUB_PR_URL_RE.search(rest) + if not url_match: + return None + + trailing_text = rest[url_match.end() :].strip() + if trailing_text and trailing_text != ">" and not trailing_text.startswith("|"): + return None + + return parse_github_pr_url(url_match.group(0)) + + +def looks_like_slack_pr_review_command(text: str) -> bool: + stripped = text.strip() + if not re.match(r"(?is)^review\b", stripped): + return False + for match in URL_RE.finditer(stripped): + parsed = urlparse(match.group(0).strip("<>")) + host = (parsed.hostname or "").lower() + if parsed.scheme in {"http", "https"} and host in {"github.com", "www.github.com"}: + return True + return False + + def select_slack_context_messages( messages: list[dict[str, Any]], current_message_ts: str, @@ -524,12 +596,12 @@ async def resolve_slack_links_in_context( return resolved_links_section, image_urls -async def post_slack_trace_reply(channel_id: str, thread_ts: str, thread_id: str) -> None: +async def post_slack_trace_reply( + channel_id: str, thread_ts: str, thread_id: str, message: str = "Working on it!" +) -> None: """Post a trace URL reply in a Slack thread.""" trace_url = get_langsmith_trace_url(thread_id) if trace_url: - await post_slack_thread_reply( - channel_id, thread_ts, f"Working on it! <{trace_url}|View trace>" - ) + await post_slack_thread_reply(channel_id, thread_ts, f"{message} <{trace_url}|View trace>") else: - await post_slack_thread_reply(channel_id, thread_ts, "Working on it!") + await post_slack_thread_reply(channel_id, thread_ts, message) diff --git a/agent/webapp.py b/agent/webapp.py index e84aa674..74646cef 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -44,11 +44,15 @@ from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block from .utils.repo import extract_repo_from_text from .utils.sandbox import validate_sandbox_startup_config from .utils.slack import ( + GitHubPrRef, add_slack_reaction, fetch_slack_thread_messages, format_slack_messages_for_prompt, get_slack_user_info, get_slack_user_names, + looks_like_slack_pr_review_command, + parse_slack_review_command, + post_slack_thread_reply, post_slack_trace_reply, resolve_slack_links_in_context, select_slack_context_messages, @@ -284,6 +288,11 @@ def generate_thread_id_from_slack_thread(channel_id: str, thread_id: str) -> str return str(uuid.UUID(hex=md5_hex)) +def generate_reviewer_thread_id(owner: str, repo: str, pr_number: int) -> str: + stable_key = f"{owner}/{repo}/pr/{pr_number}/reviewer" + return str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key)) + + def _extract_repo_config_from_thread(thread: dict[str, Any]) -> dict[str, str] | None: """Extract repo config from persisted thread data.""" metadata = thread.get("metadata") @@ -434,6 +443,17 @@ async def _thread_exists(thread_id: str) -> bool: return True +async def _ensure_thread_exists_for_metadata( + thread_id: str, langgraph_client: LangGraphClient +) -> bool: + try: + await langgraph_client.threads.create(thread_id=thread_id, if_exists="do_nothing") + return True + except Exception: + logger.exception("Failed to ensure thread %s exists before metadata update", thread_id) + return False + + async def queue_message_for_thread( thread_id: str, message_content: str | list[dict[str, Any]] | dict[str, Any] ) -> bool: @@ -864,6 +884,26 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st await post_slack_trace_reply(channel_id, thread_ts, thread_id) +async def process_slack_pr_review_request( + pr_ref: GitHubPrRef, channel_id: str, thread_ts: str +) -> None: + result = await trigger_pr_review_from_ref(pr_ref, source="slack") + if result.get("success"): + thread_id = result.get("thread_id") + if isinstance(thread_id, str) and thread_id: + await post_slack_trace_reply( + channel_id, thread_ts, thread_id, message="Taking a look..." + ) + return + + await post_slack_thread_reply( + channel_id, + thread_ts, + f"Could not start review for <{pr_ref.url}|{pr_ref.owner}/{pr_ref.repo}#{pr_ref.number}>: " + f"{result.get('error', 'unknown error')}.", + ) + + def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool: """Verify the Linear webhook signature. @@ -1090,6 +1130,23 @@ async def slack_webhook(request: Request, background_tasks: BackgroundTasks) -> if bot_user_id and user_id == bot_user_id: return {"status": "ignored", "reason": "Event from this bot user"} + clean_text = strip_bot_mention(text, bot_user_id, bot_username=SLACK_BOT_USERNAME) + pr_ref = parse_slack_review_command(clean_text) + if pr_ref: + if not _is_repo_allowed_for_reviewer({"owner": pr_ref.owner, "name": pr_ref.repo}): + return {"status": "ignored", "reason": "Repository not in reviewer allowlist"} + background_tasks.add_task(process_slack_pr_review_request, pr_ref, channel_id, thread_ts) + return {"status": "accepted", "message": "Slack PR review request queued"} + + if looks_like_slack_pr_review_command(clean_text): + background_tasks.add_task( + post_slack_thread_reply, + channel_id, + thread_ts, + "To request a PR review, use `@open-swe review https://github.com/OWNER/REPO/pull/NUMBER`.", + ) + return {"status": "ignored", "reason": "Malformed Slack PR review command"} + event_data = { "channel_id": channel_id, "thread_ts": thread_ts, @@ -1263,6 +1320,100 @@ def build_github_pr_review_prompt( ) +async def fetch_github_pr_metadata(pr_ref: GitHubPrRef, *, token: str) -> dict[str, Any] | None: + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + async with httpx.AsyncClient() as http_client: + try: + response = await http_client.get( + f"https://api.github.com/repos/{pr_ref.owner}/{pr_ref.repo}/pulls/{pr_ref.number}", + headers=headers, + ) + response.raise_for_status() + except httpx.HTTPError: + logger.exception( + "Failed to fetch PR metadata for %s/%s#%s", + pr_ref.owner, + pr_ref.repo, + pr_ref.number, + ) + return None + data = response.json() + return data if isinstance(data, dict) else None + + +async def trigger_pr_review_from_ref( + pr_ref: GitHubPrRef, + *, + source: str, + github_login: str = "", + github_user_id: int | None = None, +) -> dict[str, Any]: + repo_config = {"owner": pr_ref.owner, "name": pr_ref.repo} + if not _is_repo_allowed_for_reviewer(repo_config): + return {"success": False, "error": "Repository not allowed for reviewer"} + + app_token = await get_github_app_installation_token() + if not app_token: + logger.warning("No GitHub App token available for PR reviewer request") + return {"success": False, "error": "No GitHub App token available"} + + pr_metadata = await fetch_github_pr_metadata(pr_ref, token=app_token) + if not pr_metadata: + return {"success": False, "error": "Could not fetch pull request metadata"} + + base_sha = pr_metadata.get("base", {}).get("sha", "") + head = pr_metadata.get("head", {}) + head_sha = head.get("sha", "") + branch_name = head.get("ref", "") + pr_url = pr_metadata.get("html_url", "") or pr_ref.url + if not base_sha or not head_sha: + logger.warning("Missing base/head SHA for Slack PR review request") + return {"success": False, "error": "Pull request metadata is missing base/head SHA"} + + thread_id = generate_reviewer_thread_id(pr_ref.owner, pr_ref.repo, pr_ref.number) + langgraph_client = get_client(url=LANGGRAPH_URL) + if not await _ensure_thread_exists_for_metadata(thread_id, langgraph_client): + return {"success": False, "error": "Could not create reviewer thread"} + + try: + await persist_encrypted_github_token(thread_id, app_token) + except Exception: + logger.warning("Could not persist bot token for reviewer thread %s", thread_id) + return {"success": False, "error": "Could not persist reviewer token"} + + prompt = build_github_pr_review_prompt(repo_config, pr_ref.number, pr_url, base_sha, head_sha) + configurable: dict[str, Any] = { + "source": source, + "github_login": github_login, + "github_user_id": github_user_id, + "repo": repo_config, + "pr_number": pr_ref.number, + "review_requested": True, + } + if branch_name: + configurable["branch_name"] = branch_name + + thread_active = await is_thread_active(thread_id) + if thread_active: + logger.info("Reviewer thread %s is busy, queuing PR review request", thread_id) + queued = await queue_message_for_thread(thread_id, prompt) + return {"success": queued, "queued": queued, "thread_id": thread_id, "pr_url": pr_url} + + logger.info("Creating reviewer run for thread %s from %s PR review request", thread_id, source) + await langgraph_client.runs.create( + thread_id, + "reviewer", + input={"messages": [{"role": "user", "content": prompt}]}, + config={"configurable": configurable, "metadata": _AGENT_VERSION_METADATA}, + if_not_exists="create", + ) + return {"success": True, "queued": False, "thread_id": thread_id, "pr_url": pr_url} + + async def process_github_pr_review_request(payload: dict[str, Any]) -> None: """Trigger the reviewer agent when the Open SWE bot is requested on a PR.""" repo = payload.get("repository", {}) @@ -1283,16 +1434,19 @@ async def process_github_pr_review_request(payload: dict[str, Any]) -> None: logger.warning("Missing PR review request context, skipping reviewer run") return - owner = repo_config.get("owner", "") - name = repo_config.get("name", "") - stable_key = f"{owner}/{name}/pr/{pr_number}/reviewer" - thread_id = str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key)) + thread_id = generate_reviewer_thread_id( + repo_config.get("owner", ""), repo_config.get("name", ""), pr_number + ) app_token = await get_github_app_installation_token() if not app_token: logger.warning("No GitHub App token available for PR reviewer request") return + langgraph_client = get_client(url=LANGGRAPH_URL) + if not await _ensure_thread_exists_for_metadata(thread_id, langgraph_client): + return + try: await persist_encrypted_github_token(thread_id, app_token) except Exception: @@ -1319,7 +1473,6 @@ async def process_github_pr_review_request(payload: dict[str, Any]) -> None: return logger.info("Creating reviewer run for thread %s from GitHub PR review request", thread_id) - langgraph_client = get_client(url=LANGGRAPH_URL) await langgraph_client.runs.create( thread_id, "reviewer", diff --git a/tests/test_github_issue_webhook.py b/tests/test_github_issue_webhook.py index d92bc6fc..877d7667 100644 --- a/tests/test_github_issue_webhook.py +++ b/tests/test_github_issue_webhook.py @@ -3,14 +3,21 @@ from __future__ import annotations import asyncio import hashlib import hmac +import importlib import json from fastapi.testclient import TestClient from agent import webapp +from agent.tools import request_pr_review as request_pr_review_tool from agent.utils import github_comments +from agent.utils import slack as slack_utils +from agent.utils.slack import GitHubPrRef + +request_pr_review_module = importlib.import_module("agent.tools.request_pr_review") _TEST_WEBHOOK_SECRET = "test-secret-for-webhook" +_TEST_SLACK_SECRET = "test-slack-secret" def _sign_body(body: bytes, secret: str = _TEST_WEBHOOK_SECRET) -> str: @@ -33,6 +40,26 @@ def _post_github_webhook(client: TestClient, event_type: str, payload: dict) -> ) +def _sign_slack_body(body: bytes, timestamp: str = "1700000000") -> str: + base_string = f"v0:{timestamp}:{body.decode()}" + sig = hmac.new(_TEST_SLACK_SECRET.encode(), base_string.encode(), hashlib.sha256).hexdigest() + return f"v0={sig}" + + +def _post_slack_webhook(client: TestClient, payload: dict) -> object: + body = json.dumps(payload, separators=(",", ":")).encode() + timestamp = "1700000000" + return client.post( + "/webhooks/slack", + content=body, + headers={ + "X-Slack-Request-Timestamp": timestamp, + "X-Slack-Signature": _sign_slack_body(body, timestamp), + "Content-Type": "application/json", + }, + ) + + def test_generate_thread_id_from_github_issue_is_deterministic() -> None: first = webapp.generate_thread_id_from_github_issue("12345") second = webapp.generate_thread_id_from_github_issue("12345") @@ -302,6 +329,188 @@ def test_github_webhook_ignores_review_requested_for_other_reviewer(monkeypatch) assert called is False +def test_slack_webhook_routes_review_command_to_reviewer(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_process_slack_pr_review_request( + pr_ref: GitHubPrRef, channel_id: str, thread_ts: str + ) -> None: + captured["pr_ref"] = pr_ref + captured["channel_id"] = channel_id + captured["thread_ts"] = thread_ts + + monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET) + monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT") + monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe") + monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000) + monkeypatch.setattr(webapp, "ALLOWED_REVIEWER_GITHUB_REPOS", frozenset()) + monkeypatch.setattr(webapp, "ALLOWED_REVIEWER_GITHUB_ORGS", frozenset()) + monkeypatch.setattr( + webapp, "process_slack_pr_review_request", fake_process_slack_pr_review_request + ) + + client = TestClient(webapp.app) + response = _post_slack_webhook( + client, + { + "type": "event_callback", + "event": { + "type": "app_mention", + "channel": "C123", + "ts": "1700000000.000100", + "user": "U123", + "text": "<@UBOT> review https://github.com/langchain-ai/open-swe/pull/1244", + }, + }, + ) + + assert response.status_code == 200 + assert response.json()["message"] == "Slack PR review request queued" + 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 captured["channel_id"] == "C123" + assert captured["thread_ts"] == "1700000000.000100" + + +def test_slack_webhook_malformed_review_command_does_not_start_agent(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_process_slack_mention(*args, **kwargs) -> None: + captured["agent_started"] = True + + async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, text: str) -> bool: + captured["reply"] = text + return True + + monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET) + monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT") + monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe") + monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000) + monkeypatch.setattr(webapp, "process_slack_mention", fake_process_slack_mention) + monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_post_slack_thread_reply) + + client = TestClient(webapp.app) + response = _post_slack_webhook( + client, + { + "type": "event_callback", + "event": { + "type": "app_mention", + "channel": "C123", + "ts": "1700000000.000100", + "user": "U123", + "text": "<@UBOT> review https://github.com/langchain-ai/open-swe/issues/1244", + }, + }, + ) + + assert response.status_code == 200 + assert response.json()["reason"] == "Malformed Slack PR review command" + assert "agent_started" not in captured + assert "OWNER/REPO/pull/NUMBER" in captured["reply"] + + +def test_slack_webhook_non_pr_review_request_starts_agent(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_get_slack_repo_config( + text: str, channel_id: str, thread_ts: str + ) -> dict[str, str]: + captured["repo_config_request"] = { + "text": text, + "channel_id": channel_id, + "thread_ts": thread_ts, + } + return {"owner": "langchain-ai", "name": "open-swe"} + + async def fake_process_slack_mention( + event_data: dict[str, object], repo_config: dict[str, str] + ) -> None: + captured["event_data"] = event_data + captured["repo_config"] = repo_config + + monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET) + monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT") + monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe") + monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000) + monkeypatch.setattr(webapp, "get_slack_repo_config", fake_get_slack_repo_config) + monkeypatch.setattr(webapp, "process_slack_mention", fake_process_slack_mention) + + client = TestClient(webapp.app) + response = _post_slack_webhook( + client, + { + "type": "event_callback", + "event": { + "type": "app_mention", + "channel": "C123", + "ts": "1700000000.000100", + "user": "U123", + "text": "<@UBOT> review this branch", + }, + }, + ) + + assert response.status_code == 200 + assert response.json()["message"] == "Slack mention queued" + assert captured["repo_config"] == {"owner": "langchain-ai", "name": "open-swe"} + event_data = captured["event_data"] + assert isinstance(event_data, dict) + assert event_data["text"] == "<@UBOT> review this branch" + + +def test_process_slack_pr_review_request_posts_trace_reply(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_trigger_pr_review_from_ref( + pr_ref: GitHubPrRef, + *, + source: str, + github_login: str = "", + github_user_id: int | None = None, + ) -> dict[str, object]: + captured["pr_ref"] = pr_ref + captured["source"] = source + return {"success": True, "thread_id": "reviewer-thread-id", "pr_url": pr_ref.url} + + async def fake_post_slack_trace_reply( + channel_id: str, thread_ts: str, thread_id: str, message: str = "Working on it!" + ) -> None: + captured["trace_reply"] = { + "channel_id": channel_id, + "thread_ts": thread_ts, + "thread_id": thread_id, + "message": message, + } + + monkeypatch.setattr(webapp, "trigger_pr_review_from_ref", fake_trigger_pr_review_from_ref) + monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply) + + asyncio.run( + webapp.process_slack_pr_review_request( + GitHubPrRef( + owner="langchain-ai", + repo="open-swe", + number=1244, + url="https://github.com/langchain-ai/open-swe/pull/1244", + ), + "C123", + "1700000000.000100", + ) + ) + + assert captured["source"] == "slack" + assert captured["trace_reply"] == { + "channel_id": "C123", + "thread_ts": "1700000000.000100", + "thread_id": "reviewer-thread-id", + "message": "Taking a look...", + } + + def test_process_github_pr_review_request_creates_reviewer_run(monkeypatch) -> None: captured: dict[str, object] = {} @@ -323,8 +532,13 @@ def test_process_github_pr_review_request_creates_reviewer_run(monkeypatch) -> N captured["graph"] = graph captured["kwargs"] = kwargs + class _FakeThreadsClient: + async def create(self, **kwargs) -> None: + captured["thread_create_kwargs"] = kwargs + class _FakeLangGraphClient: runs = _FakeRunsClient() + threads = _FakeThreadsClient() monkeypatch.setattr( webapp, "get_github_app_installation_token", fake_get_github_app_installation_token @@ -357,6 +571,10 @@ def test_process_github_pr_review_request_creates_reviewer_run(monkeypatch) -> N config = kwargs["config"]["configurable"] assert captured["graph"] == "reviewer" + assert captured["thread_create_kwargs"] == { + "thread_id": captured["thread_id"], + "if_exists": "do_nothing", + } assert captured["persist_token"] == "app-token" assert captured["persist_thread_id"] == captured["thread_id"] assert "https://github.com/langchain-ai/open-swe/pull/1244" in prompt @@ -368,6 +586,147 @@ def test_process_github_pr_review_request_creates_reviewer_run(monkeypatch) -> N assert config["review_requested"] is True +def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_get_github_app_installation_token() -> str | None: + return "app-token" + + async def fake_fetch_github_pr_metadata( + pr_ref: GitHubPrRef, *, token: str + ) -> dict[str, object]: + captured["metadata_token"] = token + return { + "html_url": pr_ref.url, + "base": {"sha": "base-sha"}, + "head": {"sha": "head-sha", "ref": "feature-branch"}, + } + + async def fake_persist_encrypted_github_token(thread_id: str, token: str) -> str: + captured["persist_thread_id"] = thread_id + captured["persist_token"] = token + return "encrypted-token" + + async def fake_is_thread_active(thread_id: str) -> bool: + captured["active_thread_id"] = thread_id + return False + + class _FakeRunsClient: + async def create(self, thread_id: str, graph: str, **kwargs) -> None: + captured["thread_id"] = thread_id + captured["graph"] = graph + captured["kwargs"] = kwargs + + class _FakeThreadsClient: + async def create(self, **kwargs) -> None: + captured["thread_create_kwargs"] = kwargs + + class _FakeLangGraphClient: + runs = _FakeRunsClient() + threads = _FakeThreadsClient() + + monkeypatch.setattr( + webapp, "get_github_app_installation_token", fake_get_github_app_installation_token + ) + monkeypatch.setattr(webapp, "fetch_github_pr_metadata", fake_fetch_github_pr_metadata) + monkeypatch.setattr( + webapp, "persist_encrypted_github_token", fake_persist_encrypted_github_token + ) + monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active) + monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient()) + monkeypatch.setattr(webapp, "ALLOWED_REVIEWER_GITHUB_REPOS", frozenset()) + monkeypatch.setattr(webapp, "ALLOWED_REVIEWER_GITHUB_ORGS", frozenset()) + + result = asyncio.run( + webapp.trigger_pr_review_from_ref( + GitHubPrRef( + owner="langchain-ai", + repo="open-swe", + number=1244, + url="https://github.com/langchain-ai/open-swe/pull/1244", + ), + source="slack", + ) + ) + + kwargs = captured["kwargs"] + prompt = kwargs["input"]["messages"][0]["content"] + config = kwargs["config"]["configurable"] + assert result["success"] is True + assert captured["graph"] == "reviewer" + assert captured["thread_create_kwargs"] == { + "thread_id": captured["thread_id"], + "if_exists": "do_nothing", + } + assert captured["metadata_token"] == "app-token" + assert captured["persist_token"] == "app-token" + assert "Base SHA: base-sha" in prompt + assert "Head SHA: head-sha" in prompt + assert config["source"] == "slack" + assert config["repo"] == {"owner": "langchain-ai", "name": "open-swe"} + assert config["pr_number"] == 1244 + assert config["review_requested"] is True + + +def test_trigger_pr_review_from_ref_respects_reviewer_allowlist(monkeypatch) -> None: + called = False + + async def fake_get_github_app_installation_token() -> str | None: + nonlocal called + called = True + return "app-token" + + monkeypatch.setattr( + webapp, "get_github_app_installation_token", fake_get_github_app_installation_token + ) + monkeypatch.setattr( + webapp, "ALLOWED_REVIEWER_GITHUB_REPOS", frozenset({"langchain-ai/open-swe"}) + ) + monkeypatch.setattr(webapp, "ALLOWED_REVIEWER_GITHUB_ORGS", frozenset()) + + result = asyncio.run( + webapp.trigger_pr_review_from_ref( + GitHubPrRef( + owner="langchain-ai", + repo="blocked", + number=1, + url="https://github.com/langchain-ai/blocked/pull/1", + ), + source="slack", + ) + ) + + assert result == {"success": False, "error": "Repository not allowed for reviewer"} + assert called is False + + +def test_request_pr_review_tool_uses_shared_trigger(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_trigger_pr_review_from_ref( + pr_ref: GitHubPrRef, + *, + source: str, + github_login: str = "", + github_user_id: int | None = None, + ) -> dict[str, object]: + captured["pr_ref"] = pr_ref + captured["source"] = source + return {"success": True, "thread_id": "thread-id"} + + monkeypatch.setattr( + request_pr_review_module, "trigger_pr_review_from_ref", fake_trigger_pr_review_from_ref + ) + + result = request_pr_review_tool("https://github.com/langchain-ai/open-swe/pull/1244") + + pr_ref = captured["pr_ref"] + assert isinstance(pr_ref, GitHubPrRef) + assert pr_ref.number == 1244 + assert captured["source"] == "slack" + assert result["success"] is True + + def test_process_github_issue_uses_resolved_user_token_for_reaction(monkeypatch) -> None: captured: dict[str, object] = {} diff --git a/tests/test_slack_context.py b/tests/test_slack_context.py index 9c8ae449..30376c46 100644 --- a/tests/test_slack_context.py +++ b/tests/test_slack_context.py @@ -6,6 +6,9 @@ from agent import webapp from agent.utils.slack import ( convert_mentions_to_slack_format, format_slack_messages_for_prompt, + looks_like_slack_pr_review_command, + parse_github_pr_url, + parse_slack_review_command, replace_bot_mention_with_username, select_slack_context_messages, strip_bot_mention, @@ -130,6 +133,71 @@ def test_convert_mentions_to_slack_format_preserves_existing_slack_mentions() -> assert convert_mentions_to_slack_format(text) == text +def test_parse_github_pr_url_raw_url() -> None: + pr_ref = parse_github_pr_url("https://github.com/langchain-ai/open-swe/pull/1244") + + assert pr_ref is not None + 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" + + +def test_parse_github_pr_url_slack_formatted_link() -> None: + pr_ref = parse_github_pr_url("") + + assert pr_ref is not None + assert pr_ref.owner == "langchain-ai" + assert pr_ref.repo == "open-swe" + assert pr_ref.number == 1244 + + +def test_parse_slack_review_command_requires_exact_review_command() -> None: + pr_ref = parse_slack_review_command("review https://github.com/langchain-ai/open-swe/pull/1244") + + assert pr_ref is not None + assert pr_ref.owner == "langchain-ai" + assert pr_ref.repo == "open-swe" + assert pr_ref.number == 1244 + assert ( + parse_slack_review_command( + "please review https://github.com/langchain-ai/open-swe/pull/1244" + ) + is None + ) + assert ( + parse_slack_review_command("review https://github.com/langchain-ai/open-swe/issues/1244") + is None + ) + + +def test_parse_slack_review_command_supports_slack_link() -> None: + pr_ref = parse_slack_review_command( + "review " + ) + + assert pr_ref is not None + assert pr_ref.url == "https://github.com/langchain-ai/open-swe/pull/1244" + + +def test_parse_slack_review_command_supports_slack_wrapped_raw_link() -> None: + pr_ref = parse_slack_review_command( + "review " + ) + + assert pr_ref is not None + assert pr_ref.url == "https://github.com/langchain-ai/open-swe/pull/1244" + + +def test_looks_like_slack_pr_review_command_validates_github_host() -> None: + assert looks_like_slack_pr_review_command( + "review https://github.com/langchain-ai/open-swe/issues/1244" + ) + assert not looks_like_slack_pr_review_command( + "review https://example.com/redirect?next=https://github.com/langchain-ai/open-swe/pull/1244" + ) + + def test_format_slack_messages_for_prompt_uses_name_and_id() -> None: formatted = format_slack_messages_for_prompt( [{"ts": "1.0", "text": "hello", "user": "U123"}],