mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 13:53:15 +00:00
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:
parent
65f6b4636b
commit
1319347dd9
8 changed files with 685 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
17
agent/tools/request_pr_review.py
Normal file
17
agent/tools/request_pr_review.py
Normal 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"))
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
163
agent/webapp.py
163
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",
|
||||
|
|
|
|||
|
|
@ -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] = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue