From d63780a77fc1b4540a3c9ace1c7c67a2604a568e Mon Sep 17 00:00:00 2001 From: "langsmith-forge-dev[bot]" <265965909+langsmith-forge-dev[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:01:13 -0700 Subject: [PATCH] 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 * u * u --------- Co-authored-by: Forge Agent Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Palash Shah Co-authored-by: Palash Shah <35114859+Palashio@users.noreply.github.com> Co-authored-by: open-swe[bot] --- agent/prompt.py | 5 +- agent/server.py | 2 + agent/tools/__init__.py | 2 + agent/tools/get_pr_review_comments.py | 96 +++++++++ tests/test_get_pr_review_comments.py | 289 ++++++++++++++++++++++++++ 5 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 agent/tools/get_pr_review_comments.py create mode 100644 tests/test_get_pr_review_comments.py diff --git a/agent/prompt.py b/agent/prompt.py index 6630a8b5..fa36eaed 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -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)`). #### `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 = """--- diff --git a/agent/server.py b/agent/server.py index 6de3d440..1ff3addf 100644 --- a/agent/server.py +++ b/agent/server.py @@ -40,6 +40,7 @@ from .tools import ( fetch_url, get_branch_name, get_pr_review, + get_pr_review_comments, github_comment, http_request, linear_comment, @@ -306,6 +307,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: slack_read_thread_messages, slack_thread_reply, github_comment, + get_pr_review_comments, list_pr_reviews, get_pr_review, create_pr_review, diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index d1351d54..fe60b64f 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -1,6 +1,7 @@ from .commit_and_open_pr import commit_and_open_pr from .fetch_url import fetch_url 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_review import ( create_pr_review, @@ -31,6 +32,7 @@ __all__ = [ "fetch_url", "get_branch_name", "get_pr_review", + "get_pr_review_comments", "github_comment", "http_request", "linear_comment", diff --git a/agent/tools/get_pr_review_comments.py b/agent/tools/get_pr_review_comments.py new file mode 100644 index 00000000..080cb493 --- /dev/null +++ b/agent/tools/get_pr_review_comments.py @@ -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} diff --git a/tests/test_get_pr_review_comments.py b/tests/test_get_pr_review_comments.py new file mode 100644 index 00000000..f175acfa --- /dev/null +++ b/tests/test_get_pr_review_comments.py @@ -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