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.
- 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.

View file

@ -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,
],

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_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",

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

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.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",

View file

@ -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] = {}

View file

@ -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("<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:
formatted = format_slack_messages_for_prompt(
[{"ts": "1.0", "text": "hello", "user": "U123"}],