mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
feat(open-swe): trigger reviewer from @open-swe review PR comment (#1259)
* feat(open-swe): trigger reviewer agent from `@open-swe review` PR comment Mirrors the Slack `@open-swe review` flow on GitHub: a comment containing `@open-swe review` (optionally followed by a PR URL) on a PR triggers the reviewer agent. Without a URL it reviews the commenting PR; with a URL it targets that PR. Works for `issue_comment`, `pull_request_review_comment`, and `pull_request_review` events, gated by the existing reviewer repo allowlist and reusing `trigger_pr_review_from_ref`. * fix(open-swe): require URL after `@open-swe review`, don't swallow trailing text The previous regex matched any non-whitespace token after `review`, including across newlines. Comments like `@open-swe review\nthanks!` parsed as `(True, "thanks!")`, which then failed PR-URL parsing and was silently dropped — the user got no review and the comment never reached the regular PR-comment handler. Restrict the optional URL token to `https?://\S+` so non-URL trailing text falls through to `process_github_pr_comment` instead of being eaten by the review-command branch. Adds regression tests for the multiline and trailing-word cases.
This commit is contained in:
parent
892347041f
commit
7e1746f420
3 changed files with 328 additions and 0 deletions
|
|
@ -16,6 +16,8 @@ from .github_user_email_map import GITHUB_USER_EMAIL_MAP
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPEN_SWE_TAGS = ("@openswe", "@open-swe", "@openswe-dev")
|
||||
_OPEN_SWE_MENTION_RE = re.compile(r"(?i)@(?:openswe-dev|open-swe|openswe)\b")
|
||||
_REVIEW_COMMAND_RE = re.compile(r"(?i)\Areview(?:\s+(https?://\S+))?\s*\Z")
|
||||
UNTRUSTED_GITHUB_COMMENT_OPEN_TAG = "<dangerous-external-untrusted-users-comment>"
|
||||
UNTRUSTED_GITHUB_COMMENT_CLOSE_TAG = "</dangerous-external-untrusted-users-comment>"
|
||||
_SANITIZED_UNTRUSTED_GITHUB_COMMENT_OPEN_TAG = "[blocked-untrusted-comment-tag-open]"
|
||||
|
|
@ -57,6 +59,23 @@ def get_thread_id_from_branch(branch_name: str) -> str | None:
|
|||
return match.group(0) if match else None
|
||||
|
||||
|
||||
def parse_github_review_command(body: str) -> tuple[bool, str | None]:
|
||||
"""Detect ``@open-swe review [URL]`` in a GitHub comment body.
|
||||
|
||||
Returns ``(is_review_command, optional_pr_url)``. When ``is_review_command``
|
||||
is True and the URL is None, the command applies to the PR being commented
|
||||
on. The URL — if present — is returned as-is so the caller can validate it
|
||||
with ``parse_github_pr_url``.
|
||||
"""
|
||||
if not body:
|
||||
return False, None
|
||||
stripped = _OPEN_SWE_MENTION_RE.sub("", body).strip()
|
||||
match = _REVIEW_COMMAND_RE.match(stripped)
|
||||
if not match:
|
||||
return False, None
|
||||
return True, match.group(1) or None
|
||||
|
||||
|
||||
def sanitize_github_comment_body(body: str) -> str:
|
||||
"""Strip reserved trust wrapper tags from raw GitHub comment bodies."""
|
||||
sanitized = body.replace(
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from .utils.github_comments import (
|
|||
fetch_pr_comments_since_last_tag,
|
||||
format_github_comment_body_for_prompt,
|
||||
get_thread_id_from_branch,
|
||||
parse_github_review_command,
|
||||
react_to_github_comment,
|
||||
sanitize_github_comment_body,
|
||||
verify_github_signature,
|
||||
|
|
@ -57,6 +58,7 @@ from .utils.slack import (
|
|||
get_slack_user_info,
|
||||
get_slack_user_names,
|
||||
looks_like_slack_pr_review_command,
|
||||
parse_github_pr_url,
|
||||
parse_slack_review_command,
|
||||
post_slack_thread_reply,
|
||||
post_slack_trace_reply,
|
||||
|
|
@ -1592,6 +1594,75 @@ async def process_github_pr_review_request(payload: dict[str, Any]) -> None:
|
|||
logger.info("Reviewer run created for thread %s from GitHub PR review request", thread_id)
|
||||
|
||||
|
||||
async def process_github_pr_review_command(
|
||||
payload: dict[str, Any],
|
||||
event_type: str,
|
||||
pr_url_override: str | None,
|
||||
) -> None:
|
||||
"""Trigger the reviewer when a PR comment contains ``@open-swe review``.
|
||||
|
||||
``pr_url_override`` is the optional URL token that followed ``review``. If
|
||||
set, the review targets that PR; otherwise the comment's own PR is used.
|
||||
"""
|
||||
repo = payload.get("repository", {})
|
||||
repo_config = {
|
||||
"owner": repo.get("owner", {}).get("login", ""),
|
||||
"name": repo.get("name", ""),
|
||||
}
|
||||
pr_data = payload.get("pull_request") or payload.get("issue", {})
|
||||
sender = payload.get("sender", {})
|
||||
github_login = sender.get("login", "")
|
||||
github_user_id = sender.get("id")
|
||||
|
||||
pr_ref: GitHubPrRef | None = None
|
||||
if pr_url_override:
|
||||
pr_ref = parse_github_pr_url(pr_url_override)
|
||||
if pr_ref is None:
|
||||
logger.info("Ignoring @open-swe review with unparseable URL %s", pr_url_override)
|
||||
return
|
||||
else:
|
||||
pr_number = pr_data.get("number")
|
||||
if not pr_number:
|
||||
logger.warning("@open-swe review command missing pr_number, skipping")
|
||||
return
|
||||
pr_ref = GitHubPrRef(
|
||||
owner=repo_config["owner"],
|
||||
repo=repo_config["name"],
|
||||
number=pr_number,
|
||||
url=pr_data.get("html_url", "") or pr_data.get("url", ""),
|
||||
)
|
||||
|
||||
comment = payload.get("comment") or payload.get("review", {})
|
||||
comment_id = comment.get("id")
|
||||
node_id = comment.get("node_id") if event_type == "pull_request_review" else None
|
||||
if comment_id:
|
||||
app_token = await get_github_app_installation_token()
|
||||
if app_token:
|
||||
await react_to_github_comment(
|
||||
repo_config,
|
||||
comment_id,
|
||||
event_type=event_type,
|
||||
token=app_token,
|
||||
pull_number=pr_data.get("number"),
|
||||
node_id=node_id,
|
||||
)
|
||||
|
||||
result = await trigger_pr_review_from_ref(
|
||||
pr_ref,
|
||||
source="github",
|
||||
github_login=github_login,
|
||||
github_user_id=github_user_id,
|
||||
)
|
||||
if not result.get("success"):
|
||||
logger.warning(
|
||||
"Failed to trigger reviewer from @open-swe review on %s/%s#%s: %s",
|
||||
pr_ref.owner,
|
||||
pr_ref.repo,
|
||||
pr_ref.number,
|
||||
result.get("error"),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_open_pr_for_branch(
|
||||
repo_config: dict[str, str], head_ref: str, *, token: str
|
||||
) -> dict[str, Any] | None:
|
||||
|
|
@ -2177,6 +2248,14 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) ->
|
|||
"pull_request_review_comment",
|
||||
"pull_request_review",
|
||||
}:
|
||||
is_review_command, pr_url_override = parse_github_review_command(comment_body)
|
||||
if is_review_command:
|
||||
if not _is_repo_allowed_for_reviewer(webhook_repo_config):
|
||||
return {"status": "ignored", "reason": "Repository not in reviewer allowlist"}
|
||||
background_tasks.add_task(
|
||||
process_github_pr_review_command, payload, event_type, pr_url_override
|
||||
)
|
||||
return {"status": "accepted", "message": "Processing GitHub PR review command"}
|
||||
background_tasks.add_task(process_github_pr_comment, payload, event_type)
|
||||
return {"status": "accepted", "message": f"Processing {event_type} event"}
|
||||
|
||||
|
|
|
|||
|
|
@ -1019,3 +1019,233 @@ def test_process_github_issue_existing_thread_uses_followup_prompt(monkeypatch)
|
|||
|
||||
assert captured["prompt"] == "**octocat:**\n@openswe please handle this"
|
||||
assert "## Repository" not in captured["prompt"]
|
||||
|
||||
|
||||
def test_parse_github_review_command_standalone() -> None:
|
||||
is_review, url = github_comments.parse_github_review_command("@open-swe review")
|
||||
assert is_review is True
|
||||
assert url is None
|
||||
|
||||
|
||||
def test_parse_github_review_command_with_url() -> None:
|
||||
is_review, url = github_comments.parse_github_review_command(
|
||||
"@open-swe review https://github.com/langchain-ai/open-swe/pull/1244"
|
||||
)
|
||||
assert is_review is True
|
||||
assert url == "https://github.com/langchain-ai/open-swe/pull/1244"
|
||||
|
||||
|
||||
def test_parse_github_review_command_case_insensitive_and_aliases() -> None:
|
||||
assert github_comments.parse_github_review_command("@OpenSWE Review") == (True, None)
|
||||
assert github_comments.parse_github_review_command("@openswe review") == (True, None)
|
||||
assert github_comments.parse_github_review_command("@openswe-dev review") == (True, None)
|
||||
|
||||
|
||||
def test_parse_github_review_command_freeform_does_not_match() -> None:
|
||||
assert github_comments.parse_github_review_command("@open-swe review my code") == (False, None)
|
||||
assert github_comments.parse_github_review_command("@open-swe please review") == (False, None)
|
||||
assert github_comments.parse_github_review_command("@open-swe fix this") == (False, None)
|
||||
assert github_comments.parse_github_review_command("") == (False, None)
|
||||
|
||||
|
||||
def test_parse_github_review_command_does_not_swallow_trailing_text() -> None:
|
||||
# Trailing text after `review` (on a new line, with punctuation, or extra
|
||||
# words) must NOT be parsed as a URL — otherwise the comment would be
|
||||
# silently dropped instead of falling through to the regular handler.
|
||||
assert github_comments.parse_github_review_command("@open-swe review\nthanks!") == (
|
||||
False,
|
||||
None,
|
||||
)
|
||||
assert github_comments.parse_github_review_command("@open-swe review please") == (False, None)
|
||||
assert github_comments.parse_github_review_command("@open-swe review now") == (False, None)
|
||||
# Non-http schemes are not URLs we can route to a PR.
|
||||
assert github_comments.parse_github_review_command("@open-swe review ftp://x/y/pull/1") == (
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def test_github_webhook_routes_pr_comment_review_to_reviewer(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_process_pr_comment(payload: dict[str, object], event_type: str) -> None:
|
||||
raise AssertionError("process_github_pr_comment should not be called for review command")
|
||||
|
||||
async def fake_process_review_command(
|
||||
payload: dict[str, object], event_type: str, pr_url_override: str | None
|
||||
) -> None:
|
||||
captured["payload"] = payload
|
||||
captured["event_type"] = event_type
|
||||
captured["pr_url_override"] = pr_url_override
|
||||
|
||||
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_pr_comment)
|
||||
monkeypatch.setattr(webapp, "process_github_pr_review_command", fake_process_review_command)
|
||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||
monkeypatch.setattr(
|
||||
webapp, "ALLOWED_REVIEWER_GITHUB_REPOS", frozenset({"langchain-ai/open-swe"})
|
||||
)
|
||||
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"}))
|
||||
|
||||
client = TestClient(webapp.app)
|
||||
response = _post_github_webhook(
|
||||
client,
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"issue": {
|
||||
"id": 12345,
|
||||
"number": 1244,
|
||||
"pull_request": {"url": "https://api.github.com/repos/x/y/pulls/1244"},
|
||||
},
|
||||
"comment": {"id": 9, "body": "@open-swe review"},
|
||||
"repository": {"owner": {"login": "langchain-ai"}, "name": "open-swe"},
|
||||
"sender": {"login": "octocat"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "accepted",
|
||||
"message": "Processing GitHub PR review command",
|
||||
}
|
||||
assert captured["event_type"] == "issue_comment"
|
||||
assert captured["pr_url_override"] is None
|
||||
|
||||
|
||||
def test_github_webhook_blocks_pr_review_command_outside_reviewer_allowlist(monkeypatch) -> None:
|
||||
async def fake_process_review_command(
|
||||
payload: dict[str, object], event_type: str, pr_url_override: str | None
|
||||
) -> None:
|
||||
raise AssertionError("process_github_pr_review_command should not run")
|
||||
|
||||
monkeypatch.setattr(webapp, "process_github_pr_review_command", fake_process_review_command)
|
||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||
monkeypatch.setattr(
|
||||
webapp, "ALLOWED_REVIEWER_GITHUB_REPOS", frozenset({"langchain-ai/open-swe"})
|
||||
)
|
||||
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"}))
|
||||
|
||||
client = TestClient(webapp.app)
|
||||
response = _post_github_webhook(
|
||||
client,
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"issue": {
|
||||
"id": 12345,
|
||||
"number": 1244,
|
||||
"pull_request": {"url": "https://api.github.com/repos/x/y/pulls/1244"},
|
||||
},
|
||||
"comment": {"id": 9, "body": "@open-swe review"},
|
||||
"repository": {"owner": {"login": "langchain-ai"}, "name": "public-demo"},
|
||||
"sender": {"login": "octocat"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "ignored",
|
||||
"reason": "Repository not in reviewer allowlist",
|
||||
}
|
||||
|
||||
|
||||
def test_process_github_pr_review_command_uses_payload_pr(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_get_app_token() -> str:
|
||||
return "app-token"
|
||||
|
||||
async def fake_react(*args, **kwargs) -> None:
|
||||
captured["reacted"] = True
|
||||
|
||||
async def fake_trigger(
|
||||
pr_ref: GitHubPrRef,
|
||||
*,
|
||||
source: str,
|
||||
github_login: str = "",
|
||||
github_user_id: int | None = None,
|
||||
slack_channel_id: str = "",
|
||||
slack_thread_ts: str = "",
|
||||
) -> dict[str, object]:
|
||||
captured["pr_ref"] = pr_ref
|
||||
captured["source"] = source
|
||||
captured["github_login"] = github_login
|
||||
captured["github_user_id"] = github_user_id
|
||||
return {"success": True, "thread_id": "tid", "pr_url": pr_ref.url}
|
||||
|
||||
monkeypatch.setattr(webapp, "get_github_app_installation_token", fake_get_app_token)
|
||||
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react)
|
||||
monkeypatch.setattr(webapp, "trigger_pr_review_from_ref", fake_trigger)
|
||||
|
||||
asyncio.run(
|
||||
webapp.process_github_pr_review_command(
|
||||
{
|
||||
"issue": {
|
||||
"number": 1244,
|
||||
"html_url": "https://github.com/langchain-ai/open-swe/pull/1244",
|
||||
"pull_request": {"url": "..."},
|
||||
},
|
||||
"comment": {"id": 9, "body": "@open-swe review"},
|
||||
"repository": {"owner": {"login": "langchain-ai"}, "name": "open-swe"},
|
||||
"sender": {"login": "octocat", "id": 123},
|
||||
},
|
||||
"issue_comment",
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
pr_ref = captured["pr_ref"]
|
||||
assert isinstance(pr_ref, GitHubPrRef)
|
||||
assert pr_ref.owner == "langchain-ai"
|
||||
assert pr_ref.repo == "open-swe"
|
||||
assert pr_ref.number == 1244
|
||||
assert pr_ref.url == "https://github.com/langchain-ai/open-swe/pull/1244"
|
||||
assert captured["source"] == "github"
|
||||
assert captured["github_login"] == "octocat"
|
||||
assert captured["github_user_id"] == 123
|
||||
assert captured.get("reacted") is True
|
||||
|
||||
|
||||
def test_process_github_pr_review_command_uses_url_override(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_get_app_token() -> str:
|
||||
return "app-token"
|
||||
|
||||
async def fake_react(*args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
async def fake_trigger(
|
||||
pr_ref: GitHubPrRef,
|
||||
*,
|
||||
source: str,
|
||||
github_login: str = "",
|
||||
github_user_id: int | None = None,
|
||||
slack_channel_id: str = "",
|
||||
slack_thread_ts: str = "",
|
||||
) -> dict[str, object]:
|
||||
captured["pr_ref"] = pr_ref
|
||||
return {"success": True, "thread_id": "tid", "pr_url": pr_ref.url}
|
||||
|
||||
monkeypatch.setattr(webapp, "get_github_app_installation_token", fake_get_app_token)
|
||||
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react)
|
||||
monkeypatch.setattr(webapp, "trigger_pr_review_from_ref", fake_trigger)
|
||||
|
||||
asyncio.run(
|
||||
webapp.process_github_pr_review_command(
|
||||
{
|
||||
"issue": {"number": 99, "pull_request": {"url": "..."}},
|
||||
"comment": {"id": 1, "body": "@open-swe review https://github.com/x/y/pull/7"},
|
||||
"repository": {"owner": {"login": "x"}, "name": "y"},
|
||||
"sender": {"login": "octocat", "id": 1},
|
||||
},
|
||||
"issue_comment",
|
||||
"https://github.com/x/y/pull/7",
|
||||
)
|
||||
)
|
||||
|
||||
pr_ref = captured["pr_ref"]
|
||||
assert isinstance(pr_ref, GitHubPrRef)
|
||||
assert pr_ref.number == 7
|
||||
assert pr_ref.owner == "x"
|
||||
assert pr_ref.repo == "y"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue