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
This commit is contained in:
Johannes du Plessis 2026-05-06 17:14:43 -07:00 • committed by GitHub
parent 65f6b4636b
commit 1319347dd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 685 additions and 10 deletions

View file

@ -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. - 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 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: For tasks that require code changes, follow this order:
1. **Understand** — Read the issue/task carefully. Explore relevant files before making any changes. 1. **Understand** — Read the issue/task carefully. Explore relevant files before making any changes.

View file

@ -45,6 +45,7 @@ from .tools import (
linear_get_issue_comments, linear_get_issue_comments,
linear_list_teams, linear_list_teams,
linear_update_issue, linear_update_issue,
request_pr_review,
slack_read_thread_messages, slack_read_thread_messages,
slack_thread_reply, slack_thread_reply,
web_search, web_search,
@ -307,6 +308,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
linear_get_issue_comments, linear_get_issue_comments,
linear_list_teams, linear_list_teams,
linear_update_issue, linear_update_issue,
request_pr_review,
slack_read_thread_messages, slack_read_thread_messages,
slack_thread_reply, slack_thread_reply,
], ],

View file

@ -7,6 +7,7 @@ from .linear_get_issue import linear_get_issue
from .linear_get_issue_comments import linear_get_issue_comments from .linear_get_issue_comments import linear_get_issue_comments
from .linear_list_teams import linear_list_teams from .linear_list_teams import linear_list_teams
from .linear_update_issue import linear_update_issue 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_read_thread_messages import slack_read_thread_messages
from .slack_thread_reply import slack_thread_reply from .slack_thread_reply import slack_thread_reply
from .web_search import web_search from .web_search import web_search
@ -21,6 +22,7 @@ __all__ = [
"linear_get_issue_comments", "linear_get_issue_comments",
"linear_list_teams", "linear_list_teams",
"linear_update_issue", "linear_update_issue",
"request_pr_review",
"slack_read_thread_messages", "slack_read_thread_messages",
"slack_thread_reply", "slack_thread_reply",
"web_search", "web_search",

View file

@ -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"))

View file

@ -9,7 +9,9 @@ import logging
import os import os
import re import re
import time import time
from dataclasses import dataclass
from typing import Any from typing import Any
from urllib.parse import urlparse
import httpx import httpx
@ -19,6 +21,16 @@ logger = logging.getLogger(__name__)
SLACK_API_BASE_URL = "https://slack.com/api" SLACK_API_BASE_URL = "https://slack.com/api"
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", "") 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]: 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() 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( def select_slack_context_messages(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
current_message_ts: str, current_message_ts: str,
@ -524,12 +596,12 @@ async def resolve_slack_links_in_context(
return resolved_links_section, image_urls 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.""" """Post a trace URL reply in a Slack thread."""
trace_url = get_langsmith_trace_url(thread_id) trace_url = get_langsmith_trace_url(thread_id)
if trace_url: if trace_url:
await post_slack_thread_reply( await post_slack_thread_reply(channel_id, thread_ts, f"{message} <{trace_url}|View trace>")
channel_id, thread_ts, f"Working on it! <{trace_url}|View trace>"
)
else: else:
await post_slack_thread_reply(channel_id, thread_ts, "Working on it!") await post_slack_thread_reply(channel_id, thread_ts, message)

View file

@ -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.repo import extract_repo_from_text
from .utils.sandbox import validate_sandbox_startup_config from .utils.sandbox import validate_sandbox_startup_config
from .utils.slack import ( from .utils.slack import (
GitHubPrRef,
add_slack_reaction, add_slack_reaction,
fetch_slack_thread_messages, fetch_slack_thread_messages,
format_slack_messages_for_prompt, format_slack_messages_for_prompt,
get_slack_user_info, get_slack_user_info,
get_slack_user_names, get_slack_user_names,
looks_like_slack_pr_review_command,
parse_slack_review_command,
post_slack_thread_reply,
post_slack_trace_reply, post_slack_trace_reply,
resolve_slack_links_in_context, resolve_slack_links_in_context,
select_slack_context_messages, 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)) 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: def _extract_repo_config_from_thread(thread: dict[str, Any]) -> dict[str, str] | None:
"""Extract repo config from persisted thread data.""" """Extract repo config from persisted thread data."""
metadata = thread.get("metadata") metadata = thread.get("metadata")
@ -434,6 +443,17 @@ async def _thread_exists(thread_id: str) -> bool:
return True 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( async def queue_message_for_thread(
thread_id: str, message_content: str | list[dict[str, Any]] | dict[str, Any] thread_id: str, message_content: str | list[dict[str, Any]] | dict[str, Any]
) -> bool: ) -> 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) 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: def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool:
"""Verify the Linear webhook signature. """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: if bot_user_id and user_id == bot_user_id:
return {"status": "ignored", "reason": "Event from this bot user"} 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 = { event_data = {
"channel_id": channel_id, "channel_id": channel_id,
"thread_ts": thread_ts, "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: 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.""" """Trigger the reviewer agent when the Open SWE bot is requested on a PR."""
repo = payload.get("repository", {}) 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") logger.warning("Missing PR review request context, skipping reviewer run")
return return
owner = repo_config.get("owner", "") thread_id = generate_reviewer_thread_id(
name = repo_config.get("name", "") repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
stable_key = f"{owner}/{name}/pr/{pr_number}/reviewer" )
thread_id = str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
app_token = await get_github_app_installation_token() app_token = await get_github_app_installation_token()
if not app_token: if not app_token:
logger.warning("No GitHub App token available for PR reviewer request") logger.warning("No GitHub App token available for PR reviewer request")
return return
langgraph_client = get_client(url=LANGGRAPH_URL)
if not await _ensure_thread_exists_for_metadata(thread_id, langgraph_client):
return
try: try:
await persist_encrypted_github_token(thread_id, app_token) await persist_encrypted_github_token(thread_id, app_token)
except Exception: except Exception:
@ -1319,7 +1473,6 @@ async def process_github_pr_review_request(payload: dict[str, Any]) -> None:
return return
logger.info("Creating reviewer run for thread %s from GitHub PR review request", thread_id) 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( await langgraph_client.runs.create(
thread_id, thread_id,
"reviewer", "reviewer",

View file

@ -3,14 +3,21 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import hmac import hmac
import importlib
import json import json
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from agent import webapp 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 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_WEBHOOK_SECRET = "test-secret-for-webhook"
_TEST_SLACK_SECRET = "test-slack-secret"
def _sign_body(body: bytes, secret: str = _TEST_WEBHOOK_SECRET) -> str: 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: def test_generate_thread_id_from_github_issue_is_deterministic() -> None:
first = webapp.generate_thread_id_from_github_issue("12345") first = webapp.generate_thread_id_from_github_issue("12345")
second = 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 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: def test_process_github_pr_review_request_creates_reviewer_run(monkeypatch) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}
@ -323,8 +532,13 @@ def test_process_github_pr_review_request_creates_reviewer_run(monkeypatch) -> N
captured["graph"] = graph captured["graph"] = graph
captured["kwargs"] = kwargs captured["kwargs"] = kwargs
class _FakeThreadsClient:
async def create(self, **kwargs) -> None:
captured["thread_create_kwargs"] = kwargs
class _FakeLangGraphClient: class _FakeLangGraphClient:
runs = _FakeRunsClient() runs = _FakeRunsClient()
threads = _FakeThreadsClient()
monkeypatch.setattr( monkeypatch.setattr(
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token 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"] config = kwargs["config"]["configurable"]
assert captured["graph"] == "reviewer" 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_token"] == "app-token"
assert captured["persist_thread_id"] == captured["thread_id"] assert captured["persist_thread_id"] == captured["thread_id"]
assert "https://github.com/langchain-ai/open-swe/pull/1244" in prompt 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 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: def test_process_github_issue_uses_resolved_user_token_for_reaction(monkeypatch) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}

View file

@ -6,6 +6,9 @@ from agent import webapp
from agent.utils.slack import ( from agent.utils.slack import (
convert_mentions_to_slack_format, convert_mentions_to_slack_format,
format_slack_messages_for_prompt, format_slack_messages_for_prompt,
looks_like_slack_pr_review_command,
parse_github_pr_url,
parse_slack_review_command,
replace_bot_mention_with_username, replace_bot_mention_with_username,
select_slack_context_messages, select_slack_context_messages,
strip_bot_mention, 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 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("<https://github.com/langchain-ai/open-swe/pull/1244|PR>")
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 <https://github.com/langchain-ai/open-swe/pull/1244|PR>"
)
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 <https://github.com/langchain-ai/open-swe/pull/1244>"
)
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: def test_format_slack_messages_for_prompt_uses_name_and_id() -> None:
formatted = format_slack_messages_for_prompt( formatted = format_slack_messages_for_prompt(
[{"ts": "1.0", "text": "hello", "user": "U123"}], [{"ts": "1.0", "text": "hello", "user": "U123"}],