mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 13:59:05 +00:00
fix: add get_pr_review_comments tool to fetch PR review comments with auth (#1043)
* feat: add get_pr_review_comments tool for authenticated GitHub API access The agent was asking users to paste PR review comments because it had no tool to fetch them with auth. This adds get_pr_review_comments, which uses the GitHub App installation token to fetch all three comment types (thread comments, inline review comments, review submissions) from private repos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * u * u --------- Co-authored-by: Forge Agent <agent@forge.ai> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Palash Shah <palash@langchain.dev> Co-authored-by: Palash Shah <35114859+Palashio@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
016eef6875
commit
d63780a77f
5 changed files with 393 additions and 1 deletions
|
|
@ -153,7 +153,10 @@ Format messages using Slack's mrkdwn format, NOT standard Markdown.
|
||||||
To mention/tag a user, use `<@USER_ID>` (e.g. `<@U06KD8BFY95>`). You can find user IDs in the conversation context next to display names (e.g. `@Name(U06KD8BFY95)`).
|
To mention/tag a user, use `<@USER_ID>` (e.g. `<@U06KD8BFY95>`). You can find user IDs in the conversation context next to display names (e.g. `@Name(U06KD8BFY95)`).
|
||||||
|
|
||||||
#### `github_comment`
|
#### `github_comment`
|
||||||
Posts a comment to a GitHub issue or pull request. Provide the `issue_number` explicitly. Use this when the task was triggered from GitHub — to reply with updates, answers, or a summary after completing work."""
|
Posts a comment to a GitHub issue or pull request. Provide the `issue_number` explicitly. Use this when the task was triggered from GitHub — to reply with updates, answers, or a summary after completing work.
|
||||||
|
|
||||||
|
#### `get_pr_review_comments`
|
||||||
|
Fetches all review comments on a GitHub pull request (thread comments, inline review comments, and review submissions), sorted chronologically. Requires `pr_number`. Optionally accepts `repo_owner` and `repo_name` if different from the configured repo. Use this whenever you need to read PR feedback — do NOT ask users to paste comments."""
|
||||||
|
|
||||||
|
|
||||||
TOOL_BEST_PRACTICES_SECTION = """---
|
TOOL_BEST_PRACTICES_SECTION = """---
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ from .tools import (
|
||||||
fetch_url,
|
fetch_url,
|
||||||
get_branch_name,
|
get_branch_name,
|
||||||
get_pr_review,
|
get_pr_review,
|
||||||
|
get_pr_review_comments,
|
||||||
github_comment,
|
github_comment,
|
||||||
http_request,
|
http_request,
|
||||||
linear_comment,
|
linear_comment,
|
||||||
|
|
@ -306,6 +307,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
||||||
slack_read_thread_messages,
|
slack_read_thread_messages,
|
||||||
slack_thread_reply,
|
slack_thread_reply,
|
||||||
github_comment,
|
github_comment,
|
||||||
|
get_pr_review_comments,
|
||||||
list_pr_reviews,
|
list_pr_reviews,
|
||||||
get_pr_review,
|
get_pr_review,
|
||||||
create_pr_review,
|
create_pr_review,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from .commit_and_open_pr import commit_and_open_pr
|
from .commit_and_open_pr import commit_and_open_pr
|
||||||
from .fetch_url import fetch_url
|
from .fetch_url import fetch_url
|
||||||
from .get_branch_name import get_branch_name
|
from .get_branch_name import get_branch_name
|
||||||
|
from .get_pr_review_comments import get_pr_review_comments
|
||||||
from .github_comment import github_comment
|
from .github_comment import github_comment
|
||||||
from .github_review import (
|
from .github_review import (
|
||||||
create_pr_review,
|
create_pr_review,
|
||||||
|
|
@ -31,6 +32,7 @@ __all__ = [
|
||||||
"fetch_url",
|
"fetch_url",
|
||||||
"get_branch_name",
|
"get_branch_name",
|
||||||
"get_pr_review",
|
"get_pr_review",
|
||||||
|
"get_pr_review_comments",
|
||||||
"github_comment",
|
"github_comment",
|
||||||
"http_request",
|
"http_request",
|
||||||
"linear_comment",
|
"linear_comment",
|
||||||
|
|
|
||||||
96
agent/tools/get_pr_review_comments.py
Normal file
96
agent/tools/get_pr_review_comments.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langgraph.config import get_config
|
||||||
|
|
||||||
|
from ..utils.github_app import get_github_app_installation_token
|
||||||
|
from ..utils.github_comments import _fetch_paginated
|
||||||
|
|
||||||
|
|
||||||
|
def get_pr_review_comments(
|
||||||
|
pr_number: int,
|
||||||
|
repo_owner: str | None = None,
|
||||||
|
repo_name: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fetch all review comments for a GitHub pull request.
|
||||||
|
|
||||||
|
Returns thread comments, inline review comments, and review submissions
|
||||||
|
sorted chronologically.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
configurable = config.get("configurable", {})
|
||||||
|
repo_config = configurable.get("repo", {})
|
||||||
|
|
||||||
|
owner = repo_owner or repo_config.get("owner", "")
|
||||||
|
repo = repo_name or repo_config.get("name", "")
|
||||||
|
|
||||||
|
if not owner or not repo:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "No repo config found — provide repo_owner/repo_name or set repo in config",
|
||||||
|
}
|
||||||
|
|
||||||
|
token = asyncio.run(get_github_app_installation_token())
|
||||||
|
if not token:
|
||||||
|
return {"success": False, "error": "Failed to get GitHub App installation token"}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
}
|
||||||
|
|
||||||
|
base = f"https://api.github.com/repos/{owner}/{repo}"
|
||||||
|
|
||||||
|
async def _fetch_all() -> list[dict[str, Any]]:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
pr_comments, review_comments, reviews = await asyncio.gather(
|
||||||
|
_fetch_paginated(http_client, f"{base}/issues/{pr_number}/comments", headers),
|
||||||
|
_fetch_paginated(http_client, f"{base}/pulls/{pr_number}/comments", headers),
|
||||||
|
_fetch_paginated(http_client, f"{base}/pulls/{pr_number}/reviews", headers),
|
||||||
|
)
|
||||||
|
all_comments: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for c in pr_comments:
|
||||||
|
all_comments.append(
|
||||||
|
{
|
||||||
|
"body": c.get("body", ""),
|
||||||
|
"author": c.get("user", {}).get("login", "unknown"),
|
||||||
|
"created_at": c.get("created_at", ""),
|
||||||
|
"type": "pr_comment",
|
||||||
|
"comment_id": c.get("id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for c in review_comments:
|
||||||
|
all_comments.append(
|
||||||
|
{
|
||||||
|
"body": c.get("body", ""),
|
||||||
|
"author": c.get("user", {}).get("login", "unknown"),
|
||||||
|
"created_at": c.get("created_at", ""),
|
||||||
|
"type": "review_comment",
|
||||||
|
"comment_id": c.get("id"),
|
||||||
|
"path": c.get("path", ""),
|
||||||
|
"line": c.get("line") or c.get("original_line"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for r in reviews:
|
||||||
|
body = r.get("body", "")
|
||||||
|
if not body:
|
||||||
|
continue
|
||||||
|
all_comments.append(
|
||||||
|
{
|
||||||
|
"body": body,
|
||||||
|
"author": r.get("user", {}).get("login", "unknown"),
|
||||||
|
"created_at": r.get("submitted_at", ""),
|
||||||
|
"type": "review",
|
||||||
|
"comment_id": r.get("id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
all_comments.sort(key=lambda c: c.get("created_at", ""))
|
||||||
|
return all_comments
|
||||||
|
|
||||||
|
comments = asyncio.run(_fetch_all())
|
||||||
|
return {"success": True, "pr_number": pr_number, "total": len(comments), "comments": comments}
|
||||||
289
tests/test_get_pr_review_comments.py
Normal file
289
tests/test_get_pr_review_comments.py
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
"""Tests for get_pr_review_comments tool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent.tools.get_pr_review_comments import get_pr_review_comments
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def repo_config() -> dict[str, str]:
|
||||||
|
return {"owner": "langchain-ai", "name": "open-swe"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def mock_langgraph_config(repo_config: dict[str, str]) -> MagicMock:
|
||||||
|
config = MagicMock()
|
||||||
|
config.get.return_value = {"repo": repo_config}
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pr_comment(
|
||||||
|
body: str = "LGTM",
|
||||||
|
login: str = "reviewer",
|
||||||
|
created_at: str = "2026-03-01T10:00:00Z",
|
||||||
|
comment_id: int = 1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"body": body,
|
||||||
|
"user": {"login": login},
|
||||||
|
"created_at": created_at,
|
||||||
|
"id": comment_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_review_comment(
|
||||||
|
body: str = "Nit: rename this",
|
||||||
|
login: str = "reviewer",
|
||||||
|
created_at: str = "2026-03-01T11:00:00Z",
|
||||||
|
comment_id: int = 2,
|
||||||
|
path: str = "src/foo.py",
|
||||||
|
line: int = 42,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"body": body,
|
||||||
|
"user": {"login": login},
|
||||||
|
"created_at": created_at,
|
||||||
|
"id": comment_id,
|
||||||
|
"path": path,
|
||||||
|
"line": line,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_review(
|
||||||
|
body: str = "Looks good, minor nit",
|
||||||
|
login: str = "reviewer",
|
||||||
|
submitted_at: str = "2026-03-01T12:00:00Z",
|
||||||
|
review_id: int = 3,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"body": body,
|
||||||
|
"user": {"login": login},
|
||||||
|
"submitted_at": submitted_at,
|
||||||
|
"id": review_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPrReviewComments:
|
||||||
|
"""Tests for the get_pr_review_comments tool."""
|
||||||
|
|
||||||
|
def test_returns_formatted_comments_from_all_three_sources(
|
||||||
|
self, mock_langgraph_config: MagicMock, repo_config: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
pr_comments = [_make_pr_comment(body="Thread comment", comment_id=10)]
|
||||||
|
review_comments = [_make_review_comment(body="Inline comment", comment_id=20)]
|
||||||
|
reviews = [_make_review(body="Review body", review_id=30)]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="ghs_token123",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments._fetch_paginated",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=[pr_comments, review_comments, reviews],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = get_pr_review_comments(pr_number=42)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "comments" in result
|
||||||
|
assert len(result["comments"]) == 3
|
||||||
|
bodies = [c["body"] for c in result["comments"]]
|
||||||
|
assert "Thread comment" in bodies
|
||||||
|
assert "Inline comment" in bodies
|
||||||
|
assert "Review body" in bodies
|
||||||
|
|
||||||
|
def test_inline_review_comments_include_path_and_line(
|
||||||
|
self, mock_langgraph_config: MagicMock
|
||||||
|
) -> None:
|
||||||
|
review_comments = [_make_review_comment(path="agent/tools/foo.py", line=17)]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="ghs_token123",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments._fetch_paginated",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=[[], review_comments, []],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = get_pr_review_comments(pr_number=42)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert len(result["comments"]) == 1
|
||||||
|
comment = result["comments"][0]
|
||||||
|
assert comment["path"] == "agent/tools/foo.py"
|
||||||
|
assert comment["line"] == 17
|
||||||
|
assert comment["type"] == "review_comment"
|
||||||
|
|
||||||
|
def test_uses_repo_config_from_get_config_when_no_owner_name_provided(
|
||||||
|
self, mock_langgraph_config: MagicMock
|
||||||
|
) -> None:
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
|
||||||
|
) as mock_cfg,
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="ghs_token123",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments._fetch_paginated",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=[[], [], []],
|
||||||
|
) as mock_fetch,
|
||||||
|
):
|
||||||
|
get_pr_review_comments(pr_number=5)
|
||||||
|
|
||||||
|
mock_cfg.assert_called_once()
|
||||||
|
# Verify the URLs passed to _fetch_paginated use the configured owner/repo
|
||||||
|
called_urls = [call.args[1] for call in mock_fetch.call_args_list]
|
||||||
|
assert all("langchain-ai/open-swe" in url for url in called_urls)
|
||||||
|
|
||||||
|
def test_accepts_explicit_repo_owner_and_name(self) -> None:
|
||||||
|
config = MagicMock()
|
||||||
|
config.get.return_value = {"repo": {"owner": "other-org", "name": "other-repo"}}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.tools.get_pr_review_comments.get_config", return_value=config),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="ghs_token123",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments._fetch_paginated",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=[[], [], []],
|
||||||
|
) as mock_fetch,
|
||||||
|
):
|
||||||
|
result = get_pr_review_comments(
|
||||||
|
pr_number=7, repo_owner="explicit-org", repo_name="explicit-repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
called_urls = [call.args[1] for call in mock_fetch.call_args_list]
|
||||||
|
assert all("explicit-org/explicit-repo" in url for url in called_urls)
|
||||||
|
|
||||||
|
def test_handles_auth_failure_gracefully(self, mock_langgraph_config: MagicMock) -> None:
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = get_pr_review_comments(pr_number=42)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "token" in result["error"].lower()
|
||||||
|
|
||||||
|
def test_handles_missing_repo_config(self) -> None:
|
||||||
|
config = MagicMock()
|
||||||
|
config.get.return_value = {} # no "repo" key
|
||||||
|
|
||||||
|
with patch("agent.tools.get_pr_review_comments.get_config", return_value=config):
|
||||||
|
result = get_pr_review_comments(pr_number=42)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "repo" in result["error"].lower()
|
||||||
|
|
||||||
|
def test_skips_reviews_with_empty_body(self, mock_langgraph_config: MagicMock) -> None:
|
||||||
|
reviews = [
|
||||||
|
_make_review(body="", review_id=1),
|
||||||
|
_make_review(body="Approved", review_id=2),
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="ghs_token123",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments._fetch_paginated",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=[[], [], reviews],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = get_pr_review_comments(pr_number=42)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert len(result["comments"]) == 1
|
||||||
|
assert result["comments"][0]["body"] == "Approved"
|
||||||
|
|
||||||
|
def test_comments_sorted_chronologically(self, mock_langgraph_config: MagicMock) -> None:
|
||||||
|
pr_comments = [
|
||||||
|
_make_pr_comment(body="Third", created_at="2026-03-01T13:00:00Z", comment_id=3)
|
||||||
|
]
|
||||||
|
review_comments = [
|
||||||
|
_make_review_comment(body="First", created_at="2026-03-01T10:00:00Z", comment_id=1)
|
||||||
|
]
|
||||||
|
reviews = [_make_review(body="Second", submitted_at="2026-03-01T12:00:00Z", review_id=2)]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="ghs_token123",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments._fetch_paginated",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=[pr_comments, review_comments, reviews],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = get_pr_review_comments(pr_number=42)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
bodies = [c["body"] for c in result["comments"]]
|
||||||
|
assert bodies == ["First", "Second", "Third"]
|
||||||
|
|
||||||
|
def test_returns_total_count(self, mock_langgraph_config: MagicMock) -> None:
|
||||||
|
pr_comments = [_make_pr_comment(comment_id=1), _make_pr_comment(comment_id=2)]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="ghs_token123",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.tools.get_pr_review_comments._fetch_paginated",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=[pr_comments, [], []],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = get_pr_review_comments(pr_number=42)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["total"] == 2
|
||||||
Loading…
Add table
Reference in a new issue