open-swe/agent/dashboard/review_chat_api.py
Johannes du Plessis 911c835c2a
feat: chat with your PR on the review page (#1534)
* feat: chat with your PR on the review page

Add a sandbox-less `chat` graph that answers questions about a single PR
from its diff, the published review findings, and read-only GitHub access.

- agent/chat.py: deepagents graph, no sandbox (default StateBackend, file
  mutation + execute tools excluded). PR context is seeded as virtual files
  under /pr/; a repo-scoped App token is resolved in-graph.
- tools: read_repo_file, search_repo_code, list_review_findings.
- dashboard/review_chat_api.py + routes: per-user chat thread, LangGraph
  stream/commands/state/history proxy pinned to the chat assistant, seeds
  diff/findings/overview on first run. Gated by repo access.
- UI: Chat tab wired to a chat-scoped StreamProvider (replaces Coming Soon).

* feat: admin setting for review-chat default model

Add a 'Open SWE Review Chat' default to team settings (default_chat_model /
default_chat_reasoning_effort). get_team_default_model("chat") inherits the
Agent default when unset; the chat graph resolves through it. Admin RolePicker
gains an 'Agent default' inherit option that clears the override.

* feat: multi-conversation review chat (tabs, new chat, history)

Replace the single per-PR chat thread with multiple per-user conversations:
- threads minted client-side; first message persists with a title derived
  from the prompt.
- list + delete endpoints; chat panel gets a tab strip (history), new-chat
  (+), close (x), refresh, an intro greeting, and suggested prompts.
- get_review_chat now returns availability only (ids are client-minted).

* ui fixes

* ui: review-chat history dropdown, full-width AI replies, resizable side panel

* fix(review-chat): enforce per-user thread ownership on proxy endpoints; reseed PR context on head change

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-15 17:17:30 -07:00

507 lines
18 KiB
Python

"""Backend for the review page's "chat with this PR" feature.
A dedicated, sandbox-less ``chat`` graph (``agent/chat.py``) answers questions
about one PR. This module mints a per-user chat thread, seeds the PR diff,
review findings, and an overview as virtual files on the first run, and proxies
the LangGraph stream/commands/state/history protocol the frontend SDK speaks —
the chat counterpart of ``thread_api``'s agent proxy, pinned to assistant
``chat`` and scoped to the review's PR.
"""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Any
import httpx
from deepagents.backends.utils import create_file_data
from fastapi import HTTPException
from ..reviewer_diff import fetch_pr_diff
from ..reviewer_findings import REVIEWER_THREAD_KIND
from ..utils.github_app import get_github_app_installation_token
from ..utils.thread_ops import langgraph_client, langgraph_url
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
from .review_api import classify_finding, get_review, reviewer_thread_id
from .thread_api import (
_DASHBOARD_STREAM_MODES,
_langgraph_proxy_headers,
_require_json_content_type,
_stream_thread_events,
)
logger = logging.getLogger(__name__)
_CHAT_ASSISTANT_ID = "chat"
_CHAT_SOURCE = "review_chat"
_PROXY_REQUEST_TIMEOUT = httpx.Timeout(30.0, connect=5.0)
_MAX_DIFF_CHARS = 400_000
def _now_ms() -> int:
return int(datetime.now(UTC).timestamp() * 1000)
_TITLE_MAX_CHARS = 60
async def _reviewer_thread_exists(owner: str, repo: str, pr_number: int) -> bool:
try:
thread = await langgraph_client().threads.get(reviewer_thread_id(owner, repo, pr_number))
except Exception: # noqa: BLE001
return False
metadata = thread.get("metadata") if isinstance(thread, dict) else None
return isinstance(metadata, dict) and metadata.get("kind") == REVIEWER_THREAD_KIND
async def get_review_chat(owner: str, repo: str, pr_number: int, login: str) -> dict[str, Any]:
"""Chat availability for this PR. Threads are minted client-side per chat."""
return {
"available": await _reviewer_thread_exists(owner, repo, pr_number),
"assistant_id": _CHAT_ASSISTANT_ID,
}
def _chat_thread_search_metadata(
owner: str, repo: str, pr_number: int, login: str
) -> dict[str, Any]:
return {
"kind": _CHAT_SOURCE,
"github_login": login,
"repo_owner": owner,
"repo_name": repo,
"pr_number": pr_number,
}
async def list_review_chat_threads(
owner: str, repo: str, pr_number: int, login: str, *, limit: int = 50
) -> list[dict[str, Any]]:
"""This user's chat conversations for the PR, newest first."""
client = langgraph_client()
try:
threads = await client.threads.search(
metadata=_chat_thread_search_metadata(owner, repo, pr_number, login),
limit=limit,
sort_by="updated_at",
sort_order="desc",
)
except Exception: # noqa: BLE001
logger.debug(
"chat thread search failed for %s/%s#%s", owner, repo, pr_number, exc_info=True
)
return []
out: list[dict[str, Any]] = []
for thread in threads or []:
if not isinstance(thread, dict):
continue
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
thread_id = thread.get("thread_id") or thread.get("id")
if not isinstance(thread_id, str):
continue
out.append(
{
"thread_id": thread_id,
"title": metadata.get("title") or "New chat",
"updated_at": thread.get("updated_at")
if isinstance(thread.get("updated_at"), str)
else None,
}
)
return out
async def delete_review_chat_thread(
owner: str, repo: str, pr_number: int, login: str, thread_id: str
) -> None:
"""Delete one of the user's chat threads (scoped + ownership-checked)."""
metadata = await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
if metadata is None:
return # already gone; treat as success
await langgraph_client().threads.delete(thread_id)
def _first_user_text(params: dict[str, Any]) -> str:
run_input = params.get("input")
messages = run_input.get("messages") if isinstance(run_input, dict) else None
if not isinstance(messages, list):
return ""
for message in messages:
if not isinstance(message, dict) or message.get("type") != "human":
continue
content = message.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str) and text.strip():
return text.strip()
return ""
def _derive_title(params: dict[str, Any]) -> str:
text = _first_user_text(params)
if not text:
return "New chat"
flattened = " ".join(text.split())
return flattened[:_TITLE_MAX_CHARS] if flattened else "New chat"
def _render_overview(review: dict[str, Any]) -> str:
pr = review.get("pr") if isinstance(review.get("pr"), dict) else {}
lines = [
f"# {review.get('title') or 'Pull request'} (#{review.get('number')})",
"",
f"- Repository: {review.get('full_name', '')}",
f"- Author: {review.get('author', '')}",
f"- Head: {review.get('head_ref', '')} @ {review.get('head_sha', '')[:12]}",
f"- Base: {review.get('base_ref', '')}",
f"- State: {pr.get('state', '')}",
f"- Changes: +{pr.get('additions', 0)} -{pr.get('deletions', 0)} "
f"across {pr.get('changed_files', 0)} file(s), {pr.get('commits', 0)} commit(s)",
"",
"## Description",
"",
str(pr.get("body") or "_No description provided._"),
]
return "\n".join(lines)
def _render_findings(findings: list[dict[str, Any]]) -> str:
if not findings:
return "# Review findings\n\n_No findings were published for this PR._"
out = ["# Review findings", ""]
for finding in findings:
group = classify_finding(finding)
location = finding.get("file") or ""
start = finding.get("start_line")
if location and start:
location = f"{location}:{start}"
out.append(
f"## [{group}] {finding.get('title') or 'Untitled'} "
f"({finding.get('severity', 'low')}/{finding.get('confidence', 'medium')}, "
f"{finding.get('status', 'open')})"
)
if location:
out.append(f"`{location}`")
out.append("")
out.append(str(finding.get("description") or ""))
suggestion = finding.get("suggestion")
if suggestion:
out.append("")
out.append(f"Suggested change:\n```\n{suggestion}\n```")
note = finding.get("resolution_note")
if note:
out.append("")
out.append(f"Resolution: {note}")
out.append("")
return "\n".join(out)
def _review_head_sha(review: dict[str, Any]) -> str:
pr = review.get("pr") if isinstance(review.get("pr"), dict) else {}
return str(pr.get("head_sha") or review.get("head_sha") or "")
async def _build_pr_context(
owner: str,
repo: str,
pr_number: int,
token: str,
*,
review: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str]:
"""Fetch diff + findings + overview as seedable files; return ``(files, head_sha)``.
Accepts an already-fetched ``review`` to avoid re-fetching it when the caller
has just read it to decide whether a reseed is needed.
"""
if review is None:
review = await get_review(owner, repo, pr_number)
findings = review.get("findings") if isinstance(review.get("findings"), list) else []
head_sha = _review_head_sha(review)
diff = await fetch_pr_diff(owner=owner, repo=repo, pr_number=pr_number, token=token) or ""
if len(diff) > _MAX_DIFF_CHARS:
diff = diff[:_MAX_DIFF_CHARS] + "\n\n[diff truncated]\n"
files = {
"/pr/overview.md": create_file_data(_render_overview(review)),
"/pr/diff.patch": create_file_data(diff or "[diff unavailable]"),
"/pr/findings.md": create_file_data(_render_findings(findings)),
}
return files, head_sha
async def _get_chat_thread_metadata(thread_id: str) -> dict[str, Any] | None:
client = langgraph_client()
try:
thread = await client.threads.get(thread_id)
except Exception: # noqa: BLE001
return None
return thread.get("metadata") if isinstance(thread, dict) else None
async def assert_chat_thread_access(
thread_id: str, owner: str, repo: str, pr_number: int, login: str
) -> dict[str, Any] | None:
"""Authorize a client-supplied chat thread id before proxying to LangGraph.
Chat threads are private per viewer and their ids come from the client, so
every proxy route must confirm the caller owns the thread it names. Returns
the thread metadata when it exists and belongs to ``login`` for this PR, or
``None`` when the thread doesn't exist yet (it's created lazily on the first
run, so there is nothing to leak). Raises 404 when a thread exists but is
owned by someone else or scoped to a different repo/PR — this also rejects
reviewer (or any non-chat) threads, whose ``kind`` is not ``_CHAT_SOURCE``.
"""
metadata = await _get_chat_thread_metadata(thread_id)
if metadata is None:
return None
owns = (
metadata.get("kind") == _CHAT_SOURCE
and metadata.get("github_login") == login
and metadata.get("repo_owner") == owner
and metadata.get("repo_name") == repo
and metadata.get("pr_number") == pr_number
)
if not owns:
raise HTTPException(404, "chat not found")
return metadata
async def _create_chat_thread(
thread_id: str, owner: str, repo: str, pr_number: int, login: str, *, title: str
) -> None:
now_ms = _now_ms()
metadata = {
"kind": _CHAT_SOURCE,
"source": _CHAT_SOURCE,
"github_login": login,
"repo_owner": owner,
"repo_name": repo,
"pr_number": pr_number,
"title": title,
"created_at_ms": now_ms,
"updated_at_ms": now_ms,
}
await langgraph_client().threads.create(
thread_id=thread_id, metadata=metadata, if_exists="do_nothing"
)
def _normalize_chat_model(configurable: dict[str, Any]) -> tuple[str | None, str | None]:
model_id = configurable.get("chat_model_id")
effort = configurable.get("chat_effort")
if (
isinstance(model_id, str)
and model_id in SUPPORTED_MODEL_IDS
and isinstance(effort, str)
and model_supports_effort(model_id, effort)
):
return model_id, effort
return None, None
async def _enrich_chat_command(
command: dict[str, Any],
*,
owner: str,
repo: str,
pr_number: int,
login: str,
thread_id: str,
) -> dict[str, Any]:
if command.get("method") != "run.start":
return command
params = command.get("params")
if not isinstance(params, dict):
params = {}
command["params"] = params
metadata = await _get_chat_thread_metadata(thread_id)
created = metadata is None
if created:
await _create_chat_thread(
thread_id, owner, repo, pr_number, login, title=_derive_title(params)
)
metadata = {}
client_config = params.get("config")
if not isinstance(client_config, dict):
client_config = {}
client_configurable = client_config.get("configurable")
if not isinstance(client_configurable, dict):
client_configurable = {}
configurable: dict[str, Any] = {
"thread_id": thread_id,
"source": _CHAT_SOURCE,
"github_login": login,
"chat_repo_owner": owner,
"chat_repo_name": repo,
"chat_pr_number": pr_number,
"reviewer_thread_id": reviewer_thread_id(owner, repo, pr_number),
}
model_id, effort = _normalize_chat_model(client_configurable)
if model_id and effort:
configurable["chat_model_id"] = model_id
configurable["chat_effort"] = effort
# Seed PR context on the thread's first run, and reseed whenever the PR head
# has moved since the last seed — otherwise the chat keeps answering from a
# stale diff/findings while the review page already shows the current head.
stored_head = metadata.get("chat_head_sha") if isinstance(metadata, dict) else None
stored_head = stored_head if isinstance(stored_head, str) else ""
review: dict[str, Any] | None = None
needs_seed = created
if not created:
try:
review = await get_review(owner, repo, pr_number)
except HTTPException:
review = None # transient/missing review: keep the existing context
if review is not None:
current_head = _review_head_sha(review)
needs_seed = bool(current_head) and current_head != stored_head
if needs_seed:
token = await get_github_app_installation_token(repositories=[repo])
if not token:
raise HTTPException(503, "GitHub App token unavailable")
try:
pr_files, head_sha = await _build_pr_context(
owner, repo, pr_number, token, review=review
)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
logger.warning("Failed to seed PR chat context for %s/%s#%s", owner, repo, pr_number)
raise HTTPException(502, "could not load PR context") from exc
if head_sha:
configurable["chat_head_sha"] = head_sha
await langgraph_client().threads.update(
thread_id=thread_id, metadata={"chat_head_sha": head_sha}
)
elif stored_head:
configurable["chat_head_sha"] = stored_head
run_input = params.get("input")
if not isinstance(run_input, dict):
run_input = {}
existing_files = run_input.get("files")
run_input["files"] = {
**(existing_files if isinstance(existing_files, dict) else {}),
**pr_files,
}
params["input"] = run_input
elif stored_head:
configurable["chat_head_sha"] = stored_head
params["assistant_id"] = _CHAT_ASSISTANT_ID
params.setdefault("stream_mode", list(_DASHBOARD_STREAM_MODES))
params.setdefault("stream_resumable", True)
params["config"] = {**client_config, "configurable": configurable}
command["params"] = params
return command
async def proxy_review_chat_commands(
owner: str,
repo: str,
pr_number: int,
login: str,
thread_id: str,
body: bytes,
*,
content_type: str = "application/json",
) -> tuple[int, bytes, str | None]:
# Reject threads the caller doesn't own; a missing thread is created lazily
# below on the first `run.start` (with the caller as owner).
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
_require_json_content_type(content_type)
try:
parsed = json.loads(body)
except json.JSONDecodeError as exc:
raise HTTPException(400, "command body must be a JSON object") from exc
if not isinstance(parsed, dict):
raise HTTPException(400, "command body must be a JSON object")
enriched = await _enrich_chat_command(
parsed, owner=owner, repo=repo, pr_number=pr_number, login=login, thread_id=thread_id
)
url = f"{langgraph_url().rstrip('/')}/threads/{thread_id}/commands"
headers = _langgraph_proxy_headers(content_type=content_type)
async with httpx.AsyncClient(timeout=_PROXY_REQUEST_TIMEOUT) as client:
response = await client.post(url, content=json.dumps(enriched).encode(), headers=headers)
return response.status_code, response.content, response.headers.get("content-type")
async def proxy_review_chat_stream_events(
owner: str,
repo: str,
pr_number: int,
login: str,
thread_id: str,
body: bytes,
*,
content_type: str = "application/json",
) -> AsyncIterator[bytes]:
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
_require_json_content_type(content_type)
return _stream_thread_events(thread_id, body, content_type)
async def _proxy_passthrough(
method: str, thread_id: str, suffix: str, body: bytes | None, content_type: str
) -> tuple[int, bytes, str | None]:
url = f"{langgraph_url().rstrip('/')}/threads/{thread_id}/{suffix}"
headers = _langgraph_proxy_headers(content_type=content_type)
async with httpx.AsyncClient(timeout=_PROXY_REQUEST_TIMEOUT) as client:
if method == "GET":
response = await client.get(url, headers=headers)
else:
response = await client.post(url, content=body or b"{}", headers=headers)
return response.status_code, response.content, response.headers.get("content-type")
async def proxy_review_chat_state(
owner: str, repo: str, pr_number: int, login: str, thread_id: str
) -> tuple[int, bytes, str | None]:
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
status_code, content, media_type = await _proxy_passthrough(
"GET", thread_id, "state", None, "application/json"
)
# The chat thread is created lazily on the first run, so an initial getState
# hits a missing thread. Return an empty idle state so the SDK hydrates a
# fresh thread instead of surfacing the 404 as a hard error.
if status_code == 404:
empty = json.dumps({"values": {}, "next": []}).encode()
return 200, empty, "application/json"
return status_code, content, media_type
async def proxy_review_chat_history(
owner: str,
repo: str,
pr_number: int,
login: str,
thread_id: str,
body: bytes,
*,
content_type: str = "application/json",
) -> tuple[int, bytes, str | None]:
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
_require_json_content_type(content_type)
status_code, content, media_type = await _proxy_passthrough(
"POST", thread_id, "history", body, content_type
)
# The thread is created lazily on the first run; before then, hydration
# history reads hit a missing thread. Return an empty list so the SDK
# treats it as a fresh thread instead of erroring.
if status_code == 404:
return 200, b"[]", "application/json"
return status_code, content, media_type