feat: add github pr review tools [closes OPE-24] (#1104)

* Add GitHub PR review tools (list, get, create, update, dismiss, submit, list comments) and bind them to the agent

* format n lint

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Aran Yogesh <yogesh.mahendran@langchain.dev>
This commit is contained in:
Brace Sproul 2026-03-23 14:37:54 -07:00 • committed by GitHub
parent 91348aeb7c
commit 267b2fd011
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 330 additions and 0 deletions

View file

@ -35,7 +35,10 @@ from .middleware import (
from .prompt import construct_system_prompt
from .tools import (
commit_and_open_pr,
create_pr_review,
dismiss_pr_review,
fetch_url,
get_pr_review,
github_comment,
http_request,
linear_comment,
@ -45,7 +48,11 @@ from .tools import (
linear_get_issue_comments,
linear_list_teams,
linear_update_issue,
list_pr_review_comments,
list_pr_reviews,
slack_thread_reply,
submit_pr_review,
update_pr_review,
)
from .utils.auth import resolve_github_token
from .utils.model import make_model
@ -413,6 +420,13 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
linear_update_issue,
slack_thread_reply,
github_comment,
list_pr_reviews,
get_pr_review,
create_pr_review,
update_pr_review,
dismiss_pr_review,
submit_pr_review,
list_pr_review_comments,
],
backend=sandbox_backend,
middleware=[

View file

@ -1,6 +1,15 @@
from .commit_and_open_pr import commit_and_open_pr
from .fetch_url import fetch_url
from .github_comment import github_comment
from .github_review import (
create_pr_review,
dismiss_pr_review,
get_pr_review,
list_pr_review_comments,
list_pr_reviews,
submit_pr_review,
update_pr_review,
)
from .http_request import http_request
from .linear_comment import linear_comment
from .linear_create_issue import linear_create_issue
@ -13,10 +22,15 @@ from .slack_thread_reply import slack_thread_reply
__all__ = [
"commit_and_open_pr",
"create_pr_review",
"dismiss_pr_review",
"fetch_url",
"get_pr_review",
"github_comment",
"http_request",
"linear_comment",
"list_pr_review_comments",
"list_pr_reviews",
"linear_create_issue",
"linear_delete_issue",
"linear_get_issue",
@ -24,4 +38,6 @@ __all__ = [
"linear_list_teams",
"linear_update_issue",
"slack_thread_reply",
"submit_pr_review",
"update_pr_review",
]

View file

@ -0,0 +1,300 @@
import asyncio
from typing import Any
import httpx
from langgraph.config import get_config
from ..utils.github_app import get_github_app_installation_token
GITHUB_API_BASE = "https://api.github.com"
def _get_repo_config() -> dict[str, str]:
config = get_config()
return config.get("configurable", {}).get("repo", {})
def _github_headers(token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async def _get_token() -> str | None:
return await get_github_app_installation_token()
def _repo_url(repo_config: dict[str, str]) -> str:
owner = repo_config.get("owner", "")
name = repo_config.get("name", "")
return f"{GITHUB_API_BASE}/repos/{owner}/{name}"
def list_pr_reviews(pull_number: int) -> dict[str, Any]:
"""List all reviews on a pull request."""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews"
async def _fetch() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=_github_headers(token))
if response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "reviews": response.json()}
return asyncio.run(_fetch())
def get_pr_review(pull_number: int, review_id: int) -> dict[str, Any]:
"""Get a specific review on a pull request by review ID."""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews/{review_id}"
async def _fetch() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=_github_headers(token))
if response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "review": response.json()}
return asyncio.run(_fetch())
def create_pr_review(
pull_number: int,
body: str | None = None,
event: str = "COMMENT",
comments: list[dict[str, Any]] | None = None,
commit_id: str | None = None,
) -> dict[str, Any]:
"""Create a review on a pull request.
Args:
pull_number: The PR number to review.
body: The review body text (required for APPROVE/REQUEST_CHANGES, optional for COMMENT).
event: The review action - one of APPROVE, REQUEST_CHANGES, or COMMENT.
comments: Optional list of review comments. Each comment dict should have:
- path (str): The relative file path to comment on.
- body (str): The comment text.
- line (int, optional): The line number in the diff to comment on.
- side (str, optional): Which side of the diff to comment on (LEFT or RIGHT).
- start_line (int, optional): For multi-line comments, the start line.
- start_side (str, optional): For multi-line comments, the start side.
commit_id: Optional SHA of the commit to review. Defaults to the latest commit.
Returns:
Dictionary with success status and the created review data.
"""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews"
payload: dict[str, Any] = {"event": event}
if body is not None:
payload["body"] = body
if comments:
payload["comments"] = comments
if commit_id:
payload["commit_id"] = commit_id
async def _create() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=_github_headers(token), json=payload)
if response.status_code not in (200, 201):
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "review": response.json()}
return asyncio.run(_create())
def update_pr_review(
pull_number: int,
review_id: int,
body: str,
) -> dict[str, Any]:
"""Update the body of an existing review on a pull request.
Args:
pull_number: The PR number.
review_id: The ID of the review to update.
body: The new review body text.
Returns:
Dictionary with success status and the updated review data.
"""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews/{review_id}"
async def _update() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.put(url, headers=_github_headers(token), json={"body": body})
if response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "review": response.json()}
return asyncio.run(_update())
def dismiss_pr_review(
pull_number: int,
review_id: int,
message: str,
) -> dict[str, Any]:
"""Dismiss a review on a pull request.
Args:
pull_number: The PR number.
review_id: The ID of the review to dismiss.
message: A message explaining why the review is being dismissed.
Returns:
Dictionary with success status and the dismissed review data.
"""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews/{review_id}/dismissals"
async def _dismiss() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.put(
url, headers=_github_headers(token), json={"message": message}
)
if response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "review": response.json()}
return asyncio.run(_dismiss())
def submit_pr_review(
pull_number: int,
review_id: int,
body: str | None = None,
event: str = "COMMENT",
) -> dict[str, Any]:
"""Submit a pending review on a pull request.
Use this if a review was created without an event (pending state) and needs to be submitted.
Args:
pull_number: The PR number.
review_id: The ID of the pending review to submit.
body: Optional body text for the review submission.
event: The review action - one of APPROVE, REQUEST_CHANGES, or COMMENT.
Returns:
Dictionary with success status and the submitted review data.
"""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews/{review_id}/events"
payload: dict[str, Any] = {"event": event}
if body is not None:
payload["body"] = body
async def _submit() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=_github_headers(token), json=payload)
if response.status_code not in (200, 201):
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "review": response.json()}
return asyncio.run(_submit())
def list_pr_review_comments(
pull_number: int,
review_id: int | None = None,
) -> dict[str, Any]:
"""List comments on a pull request review.
Args:
pull_number: The PR number.
review_id: If provided, list comments for a specific review.
If not provided, list all review comments on the PR.
Returns:
Dictionary with success status and the list of review comments.
"""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
if review_id is not None:
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews/{review_id}/comments"
else:
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/comments"
async def _fetch() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=_github_headers(token))
if response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "comments": response.json()}
return asyncio.run(_fetch())