mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-06 01:52:12 +00:00
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>
This commit is contained in:
parent
698613e6db
commit
911c835c2a
16 changed files with 2689 additions and 127 deletions
159
agent/chat.py
Normal file
159
agent/chat.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""Chat graph.
|
||||||
|
|
||||||
|
A read-only "chat with this PR" agent for the review UI. Unlike the main agent
|
||||||
|
and reviewer, it has **no sandbox**: it answers questions about a single pull
|
||||||
|
request using the diff, the published review findings, and read-only access to
|
||||||
|
the repository over the GitHub API.
|
||||||
|
|
||||||
|
PR context (diff, findings, overview) is seeded as virtual files under ``/pr/``
|
||||||
|
into the ``files`` state channel by the dashboard chat proxy
|
||||||
|
(``agent/dashboard/review_chat_api.py``); the built-in ``read_file``/``grep``
|
||||||
|
tools operate over those. Repo coordinates and the reviewer thread id arrive in
|
||||||
|
``configurable``; a repo-scoped GitHub App token is resolved here so the
|
||||||
|
GitHub-backed tools never receive a user credential.
|
||||||
|
"""
|
||||||
|
# ruff: noqa: E402
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from langgraph.graph.state import RunnableConfig
|
||||||
|
from langgraph.pregel import Pregel
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore", module="langchain_core._api.deprecation")
|
||||||
|
warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)
|
||||||
|
|
||||||
|
from deepagents import create_deep_agent
|
||||||
|
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||||
|
|
||||||
|
from .dashboard.options import SUPPORTED_MODEL_IDS, model_supports_effort
|
||||||
|
from .dashboard.team_settings import get_team_default_model
|
||||||
|
from .middleware import (
|
||||||
|
ExcludeToolsMiddleware,
|
||||||
|
SanitizeThinkingBlocksMiddleware,
|
||||||
|
SanitizeToolInputsMiddleware,
|
||||||
|
ToolErrorMiddleware,
|
||||||
|
)
|
||||||
|
from .server import (
|
||||||
|
DEFAULT_LLM_MAX_TOKENS,
|
||||||
|
DEFAULT_RECURSION_LIMIT,
|
||||||
|
graph_loaded_for_execution,
|
||||||
|
)
|
||||||
|
from .tools import (
|
||||||
|
fetch_url,
|
||||||
|
list_review_findings,
|
||||||
|
read_repo_file,
|
||||||
|
search_repo_code,
|
||||||
|
web_search,
|
||||||
|
)
|
||||||
|
from .utils.github_app import get_github_app_installation_token
|
||||||
|
from .utils.model import DEFAULT_LLM_REASONING, make_model, provider_model_kwargs
|
||||||
|
from .utils.tracing import AGENT_TRACING_PROJECT, traced_graph_factory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHAT_MODEL_CALL_LIMIT = 100
|
||||||
|
|
||||||
|
# Read-only: the chat agent never mutates files or runs shell commands. These are
|
||||||
|
# injected by deepagents' FilesystemMiddleware and stripped before the model sees
|
||||||
|
# them (there is no sandbox, so ``execute`` would error anyway).
|
||||||
|
_EXCLUDED_TOOLS = frozenset({"execute", "write_file", "edit_file"})
|
||||||
|
|
||||||
|
CHAT_PROMPT = """You are a code-review chat assistant. You help the author and reviewers \
|
||||||
|
understand one GitHub pull request: `{repo_owner}/{repo_name}` #{pr_number}.
|
||||||
|
|
||||||
|
You have NO sandbox and cannot run code, execute tests, commit, or open PRs. You \
|
||||||
|
reason from the PR's diff, the published review findings, and read-only access to \
|
||||||
|
the repository.
|
||||||
|
|
||||||
|
Context already loaded as virtual files (use `read_file`, `ls`, `grep`):
|
||||||
|
- `/pr/overview.md` — title, description, author, branches, head commit, change stats.
|
||||||
|
- `/pr/diff.patch` — the unified diff under review.
|
||||||
|
- `/pr/findings.md` — the reviewer's published findings, rendered for reading.
|
||||||
|
|
||||||
|
Tools:
|
||||||
|
- `read_repo_file(path, ref)` — read any repo file/dir at a commit (defaults to the \
|
||||||
|
PR head). Use it to inspect callers, definitions, and neighboring code beyond the diff.
|
||||||
|
- `search_repo_code(query)` — find a symbol or phrase across the repository.
|
||||||
|
- `list_review_findings(status_filter)` — the live findings (open/resolved/dismissed) \
|
||||||
|
with severity, confidence, and resolution notes.
|
||||||
|
- `web_search`, `fetch_url` — for external docs or standards.
|
||||||
|
|
||||||
|
Guidance:
|
||||||
|
- Be concrete and cite specific files and line numbers from the diff.
|
||||||
|
- Ground claims about the review in the actual findings; don't invent issues.
|
||||||
|
- When you propose a change, describe it precisely — you cannot apply it yourself.
|
||||||
|
- Keep answers focused and skimmable. Match the depth of the question.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_chat_model(configurable: dict) -> tuple[str, str]:
|
||||||
|
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
|
||||||
|
# Team review-chat default, which itself inherits the Agent default if unset.
|
||||||
|
return await get_team_default_model("chat")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
||||||
|
"""Get a read-only PR chat agent. No sandbox; PR context comes via config."""
|
||||||
|
thread_id = config["configurable"].get("thread_id")
|
||||||
|
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
|
||||||
|
|
||||||
|
if thread_id is None or not graph_loaded_for_execution(config):
|
||||||
|
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
|
||||||
|
|
||||||
|
configurable = config["configurable"]
|
||||||
|
repo_owner = str(configurable.get("chat_repo_owner") or "")
|
||||||
|
repo_name = str(configurable.get("chat_repo_name") or "")
|
||||||
|
pr_number = configurable.get("chat_pr_number")
|
||||||
|
|
||||||
|
# Resolve a repo-scoped, read-only App token in-graph so a user credential is
|
||||||
|
# never passed through the run config. Tools read it from configurable.
|
||||||
|
token = await get_github_app_installation_token(repositories=[repo_name] if repo_name else None)
|
||||||
|
if isinstance(token, str) and token:
|
||||||
|
configurable["chat_github_token"] = token
|
||||||
|
|
||||||
|
model_id, effort = await _resolve_chat_model(configurable)
|
||||||
|
model_kwargs = provider_model_kwargs(
|
||||||
|
model_id,
|
||||||
|
effort,
|
||||||
|
max_tokens=DEFAULT_LLM_MAX_TOKENS,
|
||||||
|
openai_reasoning_default=DEFAULT_LLM_REASONING,
|
||||||
|
)
|
||||||
|
|
||||||
|
system_prompt = CHAT_PROMPT.format(
|
||||||
|
repo_owner=repo_owner or "<owner>",
|
||||||
|
repo_name=repo_name or "<repo>",
|
||||||
|
pr_number=pr_number if isinstance(pr_number, int) else "?",
|
||||||
|
)
|
||||||
|
|
||||||
|
return create_deep_agent(
|
||||||
|
model=make_model(model_id, **model_kwargs),
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
tools=[
|
||||||
|
read_repo_file,
|
||||||
|
search_repo_code,
|
||||||
|
list_review_findings,
|
||||||
|
web_search,
|
||||||
|
fetch_url,
|
||||||
|
],
|
||||||
|
middleware=[
|
||||||
|
SanitizeToolInputsMiddleware(),
|
||||||
|
ModelCallLimitMiddleware(run_limit=CHAT_MODEL_CALL_LIMIT, exit_behavior="end"),
|
||||||
|
ToolErrorMiddleware(),
|
||||||
|
ExcludeToolsMiddleware(excluded=_EXCLUDED_TOOLS),
|
||||||
|
SanitizeThinkingBlocksMiddleware(),
|
||||||
|
],
|
||||||
|
).with_config(config)
|
||||||
|
|
||||||
|
|
||||||
|
traced_chat_agent = traced_graph_factory(get_chat_agent, AGENT_TRACING_PROJECT)
|
||||||
507
agent/dashboard/review_chat_api.py
Normal file
507
agent/dashboard/review_chat_api.py
Normal file
|
|
@ -0,0 +1,507 @@
|
||||||
|
"""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
|
||||||
|
|
@ -69,6 +69,15 @@ from .review_api import (
|
||||||
list_reviews,
|
list_reviews,
|
||||||
trigger_re_review,
|
trigger_re_review,
|
||||||
)
|
)
|
||||||
|
from .review_chat_api import (
|
||||||
|
delete_review_chat_thread,
|
||||||
|
get_review_chat,
|
||||||
|
list_review_chat_threads,
|
||||||
|
proxy_review_chat_commands,
|
||||||
|
proxy_review_chat_history,
|
||||||
|
proxy_review_chat_state,
|
||||||
|
proxy_review_chat_stream_events,
|
||||||
|
)
|
||||||
from .review_style_jobs import (
|
from .review_style_jobs import (
|
||||||
cancel_review_style_analysis,
|
cancel_review_style_analysis,
|
||||||
start_bootstrap_analysis,
|
start_bootstrap_analysis,
|
||||||
|
|
@ -830,6 +839,136 @@ async def api_re_review(
|
||||||
return await trigger_re_review(owner, repo, pr_number, session["sub"])
|
return await trigger_re_review(owner, repo, pr_number, session["sub"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- PR chat (sandbox-less ``chat`` graph) -----------------------------------
|
||||||
|
# The frontend points a LangGraph StreamProvider at the base
|
||||||
|
# ``/reviews/{owner}/{repo}/{pr_number}/chat``; the SDK then issues the
|
||||||
|
# ``/threads/{id}/{commands,stream/events,state,history}`` calls proxied below.
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reviews/{owner}/{repo}/{pr_number}/chat")
|
||||||
|
async def api_get_review_chat(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
|
||||||
|
return await get_review_chat(owner, repo, pr_number, session["sub"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reviews/{owner}/{repo}/{pr_number}/chat/threads")
|
||||||
|
async def api_list_review_chat_threads(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
|
||||||
|
threads = await list_review_chat_threads(owner, repo, pr_number, session["sub"])
|
||||||
|
return {"threads": threads}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/reviews/{owner}/{repo}/{pr_number}/chat/threads/{thread_id}")
|
||||||
|
async def api_delete_review_chat_thread(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
thread_id: str,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> Response:
|
||||||
|
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
|
||||||
|
await delete_review_chat_thread(owner, repo, pr_number, session["sub"], thread_id)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reviews/{owner}/{repo}/{pr_number}/chat/threads/{thread_id}/commands")
|
||||||
|
async def api_review_chat_commands(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
thread_id: str,
|
||||||
|
request: Request,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> Response:
|
||||||
|
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
|
||||||
|
body = await request.body()
|
||||||
|
status_code, content, media_type = await proxy_review_chat_commands(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pr_number,
|
||||||
|
session["sub"],
|
||||||
|
thread_id,
|
||||||
|
body,
|
||||||
|
content_type=request.headers.get("content-type", "application/json"),
|
||||||
|
)
|
||||||
|
return Response(content=content, status_code=status_code, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reviews/{owner}/{repo}/{pr_number}/chat/threads/{thread_id}/stream/events")
|
||||||
|
async def api_review_chat_stream_events(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
thread_id: str,
|
||||||
|
request: Request,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> StreamingResponse:
|
||||||
|
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
|
||||||
|
body = await request.body()
|
||||||
|
stream = await proxy_review_chat_stream_events(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pr_number,
|
||||||
|
session["sub"],
|
||||||
|
thread_id,
|
||||||
|
body,
|
||||||
|
content_type=request.headers.get("content-type", "application/json"),
|
||||||
|
)
|
||||||
|
return StreamingResponse(
|
||||||
|
stream,
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reviews/{owner}/{repo}/{pr_number}/chat/threads/{thread_id}/state")
|
||||||
|
async def api_review_chat_state(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
thread_id: str,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> Response:
|
||||||
|
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
|
||||||
|
status_code, content, media_type = await proxy_review_chat_state(
|
||||||
|
owner, repo, pr_number, session["sub"], thread_id
|
||||||
|
)
|
||||||
|
return Response(content=content, status_code=status_code, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reviews/{owner}/{repo}/{pr_number}/chat/threads/{thread_id}/history")
|
||||||
|
async def api_review_chat_history(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
thread_id: str,
|
||||||
|
request: Request,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> Response:
|
||||||
|
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
|
||||||
|
body = await request.body()
|
||||||
|
status_code, content, media_type = await proxy_review_chat_history(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pr_number,
|
||||||
|
session["sub"],
|
||||||
|
thread_id,
|
||||||
|
body,
|
||||||
|
content_type=request.headers.get("content-type", "application/json"),
|
||||||
|
)
|
||||||
|
return Response(content=content, status_code=status_code, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/review-styles")
|
@router.post("/review-styles")
|
||||||
async def api_create_review_style(
|
async def api_create_review_style(
|
||||||
body: ReviewStyleCreate,
|
body: ReviewStyleCreate,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ class TeamSettingsUpdate(BaseModel):
|
||||||
default_reviewer_reasoning_effort: str | None = None
|
default_reviewer_reasoning_effort: str | None = None
|
||||||
default_reviewer_subagent_model: str | None = None
|
default_reviewer_subagent_model: str | None = None
|
||||||
default_reviewer_subagent_reasoning_effort: str | None = None
|
default_reviewer_subagent_reasoning_effort: str | None = None
|
||||||
|
default_chat_model: str | None = None
|
||||||
|
default_chat_reasoning_effort: str | None = None
|
||||||
|
|
||||||
@field_validator("org_guidelines", mode="before")
|
@field_validator("org_guidelines", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -87,6 +89,9 @@ class TeamSettingsUpdate(BaseModel):
|
||||||
self.default_reviewer_subagent_reasoning_effort,
|
self.default_reviewer_subagent_reasoning_effort,
|
||||||
"reviewer subagent",
|
"reviewer subagent",
|
||||||
)
|
)
|
||||||
|
_validate_model_effort_pair(
|
||||||
|
self.default_chat_model, self.default_chat_reasoning_effort, "review chat"
|
||||||
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -139,6 +144,9 @@ def _default_settings() -> dict[str, Any]:
|
||||||
"default_reviewer_reasoning_effort": fallback_effort,
|
"default_reviewer_reasoning_effort": fallback_effort,
|
||||||
"default_reviewer_subagent_model": fallback_model,
|
"default_reviewer_subagent_model": fallback_model,
|
||||||
"default_reviewer_subagent_reasoning_effort": fallback_effort,
|
"default_reviewer_subagent_reasoning_effort": fallback_effort,
|
||||||
|
# No hardcoded chat default: unset means "inherit the Agent default".
|
||||||
|
"default_chat_model": None,
|
||||||
|
"default_chat_reasoning_effort": None,
|
||||||
"updated_at": None,
|
"updated_at": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +192,8 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
|
||||||
"default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort,
|
"default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort,
|
||||||
"default_reviewer_subagent_model": update.default_reviewer_subagent_model,
|
"default_reviewer_subagent_model": update.default_reviewer_subagent_model,
|
||||||
"default_reviewer_subagent_reasoning_effort": update.default_reviewer_subagent_reasoning_effort,
|
"default_reviewer_subagent_reasoning_effort": update.default_reviewer_subagent_reasoning_effort,
|
||||||
|
"default_chat_model": update.default_chat_model,
|
||||||
|
"default_chat_reasoning_effort": update.default_chat_reasoning_effort,
|
||||||
"updated_at": datetime.now(UTC).isoformat(),
|
"updated_at": datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value)
|
await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value)
|
||||||
|
|
@ -196,7 +206,7 @@ async def get_team_default_repo() -> dict[str, str] | None:
|
||||||
|
|
||||||
|
|
||||||
async def get_team_default_model(
|
async def get_team_default_model(
|
||||||
role: Literal["agent", "reviewer"],
|
role: Literal["agent", "reviewer", "chat"],
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Return the team-wide default ``(model_id, reasoning_effort)`` for ``role``.
|
"""Return the team-wide default ``(model_id, reasoning_effort)`` for ``role``.
|
||||||
|
|
||||||
|
|
@ -205,9 +215,25 @@ async def get_team_default_model(
|
||||||
(so a stale Anthropic/OpenAI selection stays on its provider rather than
|
(so a stale Anthropic/OpenAI selection stays on its provider rather than
|
||||||
jumping cross-provider); otherwise the hardcoded global default from
|
jumping cross-provider); otherwise the hardcoded global default from
|
||||||
:func:`agent.dashboard.options.default_model_pair`.
|
:func:`agent.dashboard.options.default_model_pair`.
|
||||||
|
|
||||||
|
``"chat"`` (the review-page PR chat) has no hardcoded default: when its
|
||||||
|
admin setting is unset/invalid it inherits the team **agent** default.
|
||||||
"""
|
"""
|
||||||
settings = await get_team_settings()
|
settings = await get_team_settings()
|
||||||
if role == "agent":
|
if role == "chat":
|
||||||
|
model = settings.get("default_chat_model")
|
||||||
|
effort = settings.get("default_chat_reasoning_effort")
|
||||||
|
if (
|
||||||
|
isinstance(model, str)
|
||||||
|
and isinstance(effort, str)
|
||||||
|
and model in SUPPORTED_MODEL_IDS
|
||||||
|
and model_supports_effort(model, effort)
|
||||||
|
):
|
||||||
|
return _resolve_default_pair(model, effort)
|
||||||
|
# Inherit the Agent default when no chat-specific model is configured.
|
||||||
|
model = settings.get("default_agent_model")
|
||||||
|
effort = settings.get("default_agent_reasoning_effort")
|
||||||
|
elif role == "agent":
|
||||||
model = settings.get("default_agent_model")
|
model = settings.get("default_agent_model")
|
||||||
effort = settings.get("default_agent_reasoning_effort")
|
effort = settings.get("default_agent_reasoning_effort")
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@ from .linear_get_issue_comments import linear_get_issue_comments
|
||||||
from .linear_list_teams import linear_list_teams
|
from .linear_list_teams import linear_list_teams
|
||||||
from .linear_update_issue import linear_update_issue
|
from .linear_update_issue import linear_update_issue
|
||||||
from .list_findings import list_findings
|
from .list_findings import list_findings
|
||||||
|
from .list_review_findings import list_review_findings
|
||||||
from .open_pull_request import open_pull_request
|
from .open_pull_request import open_pull_request
|
||||||
from .publish_review import publish_review
|
from .publish_review import publish_review
|
||||||
|
from .read_repo_file import read_repo_file
|
||||||
from .reply_to_finding_thread import reply_to_finding_thread
|
from .reply_to_finding_thread import reply_to_finding_thread
|
||||||
from .request_pr_review import request_pr_review
|
from .request_pr_review import request_pr_review
|
||||||
from .resolve_finding_thread import resolve_finding_thread
|
from .resolve_finding_thread import resolve_finding_thread
|
||||||
|
from .search_repo_code import search_repo_code
|
||||||
from .slack_read_thread_messages import slack_read_thread_messages
|
from .slack_read_thread_messages import slack_read_thread_messages
|
||||||
from .slack_thread_reply import slack_thread_reply
|
from .slack_thread_reply import slack_thread_reply
|
||||||
from .update_finding import update_finding
|
from .update_finding import update_finding
|
||||||
|
|
@ -31,11 +34,14 @@ __all__ = [
|
||||||
"linear_list_teams",
|
"linear_list_teams",
|
||||||
"linear_update_issue",
|
"linear_update_issue",
|
||||||
"list_findings",
|
"list_findings",
|
||||||
|
"list_review_findings",
|
||||||
"open_pull_request",
|
"open_pull_request",
|
||||||
"publish_review",
|
"publish_review",
|
||||||
|
"read_repo_file",
|
||||||
"request_pr_review",
|
"request_pr_review",
|
||||||
"reply_to_finding_thread",
|
"reply_to_finding_thread",
|
||||||
"resolve_finding_thread",
|
"resolve_finding_thread",
|
||||||
|
"search_repo_code",
|
||||||
"slack_read_thread_messages",
|
"slack_read_thread_messages",
|
||||||
"slack_thread_reply",
|
"slack_thread_reply",
|
||||||
"update_finding",
|
"update_finding",
|
||||||
|
|
|
||||||
71
agent/tools/list_review_findings.py
Normal file
71
agent/tools/list_review_findings.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Tool: ``list_review_findings``. Read the published review's findings.
|
||||||
|
|
||||||
|
The PR chat agent runs on its own thread; the findings live on the canonical
|
||||||
|
reviewer thread for the PR. The reviewer thread id is seeded into the run config
|
||||||
|
by the dashboard chat proxy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langgraph.config import get_config
|
||||||
|
|
||||||
|
from ..reviewer_findings import list_findings as list_findings_async
|
||||||
|
|
||||||
|
_COMPACT_FIELDS = (
|
||||||
|
"id",
|
||||||
|
"severity",
|
||||||
|
"confidence",
|
||||||
|
"category",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"suggestion",
|
||||||
|
"file",
|
||||||
|
"start_line",
|
||||||
|
"end_line",
|
||||||
|
"side",
|
||||||
|
"status",
|
||||||
|
"resolution_note",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compact(finding: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {key: finding.get(key) for key in _COMPACT_FIELDS if finding.get(key) is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def list_review_findings(status_filter: str | None = None) -> dict[str, Any]:
|
||||||
|
"""List the findings the reviewer published for this PR.
|
||||||
|
|
||||||
|
Use this to ground answers about the review — what was flagged, the
|
||||||
|
severity/confidence, and any resolution notes. Prefer quoting these over
|
||||||
|
re-deriving issues from the diff.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status_filter: One of ``open``, ``resolved``, ``dismissed``. ``None``
|
||||||
|
(default) returns findings of every status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{findings, count}``; ``{findings: [], count: 0, error}`` on failure.
|
||||||
|
"""
|
||||||
|
if status_filter is not None and status_filter not in {"open", "resolved", "dismissed"}:
|
||||||
|
return {"findings": [], "count": 0, "error": f"Invalid status_filter: {status_filter}"}
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||||
|
reviewer_thread_id = (
|
||||||
|
configurable.get("reviewer_thread_id") if isinstance(configurable, dict) else None
|
||||||
|
)
|
||||||
|
if not isinstance(reviewer_thread_id, str) or not reviewer_thread_id:
|
||||||
|
return {"findings": [], "count": 0, "error": "reviewer thread unavailable"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
findings = asyncio.run(list_findings_async(reviewer_thread_id))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"findings": [], "count": 0, "error": f"could not load findings: {exc!s}"}
|
||||||
|
|
||||||
|
if status_filter is not None:
|
||||||
|
findings = [f for f in findings if f.get("status") == status_filter]
|
||||||
|
compact = [_compact(f) for f in findings]
|
||||||
|
return {"findings": compact, "count": len(compact)}
|
||||||
104
agent/tools/read_repo_file.py
Normal file
104
agent/tools/read_repo_file.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"""Tool: ``read_repo_file``. Read repo files/dirs over the GitHub API (no sandbox).
|
||||||
|
|
||||||
|
The PR chat agent has no sandbox, so it reads source at a specific ref through
|
||||||
|
the GitHub contents API. Repo coordinates and a read-only token come from the
|
||||||
|
run config (seeded by the dashboard chat proxy).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from langgraph.config import get_config
|
||||||
|
|
||||||
|
from ..utils.github_checks import github_headers
|
||||||
|
|
||||||
|
_GITHUB_API = "https://api.github.com"
|
||||||
|
_MAX_FILE_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_repo_context() -> tuple[str, str, str | None, str | None]:
|
||||||
|
config = get_config()
|
||||||
|
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||||
|
if not isinstance(configurable, dict):
|
||||||
|
configurable = {}
|
||||||
|
owner = configurable.get("chat_repo_owner")
|
||||||
|
repo = configurable.get("chat_repo_name")
|
||||||
|
token = configurable.get("chat_github_token")
|
||||||
|
head_sha = configurable.get("chat_head_sha")
|
||||||
|
return (
|
||||||
|
owner if isinstance(owner, str) else "",
|
||||||
|
repo if isinstance(repo, str) else "",
|
||||||
|
token if isinstance(token, str) and token else None,
|
||||||
|
head_sha if isinstance(head_sha, str) and head_sha else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_repo_file(path: str, ref: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Read a file (or list a directory) from the PR's repository at a git ref.
|
||||||
|
|
||||||
|
Use this to inspect code beyond the diff — callers, definitions, neighboring
|
||||||
|
modules, config — at the exact commit under review. The diff itself is
|
||||||
|
already available as the virtual file ``/pr/diff.patch``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Repo-relative path, e.g. ``src/app/main.py`` or ``src/app`` for a
|
||||||
|
directory listing. Leading slashes are ignored.
|
||||||
|
ref: Git ref (branch, tag, or SHA). Defaults to the PR head commit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
For a file: ``{success, path, ref, content, truncated}``.
|
||||||
|
For a directory: ``{success, path, ref, entries}`` where each entry is
|
||||||
|
``{name, type, path}``.
|
||||||
|
On failure: ``{success: False, error}``.
|
||||||
|
"""
|
||||||
|
owner, repo, token, head_sha = _chat_repo_context()
|
||||||
|
if not owner or not repo:
|
||||||
|
return {"success": False, "error": "repository context unavailable"}
|
||||||
|
|
||||||
|
clean_path = path.strip().lstrip("/")
|
||||||
|
resolved_ref = (ref or head_sha or "").strip()
|
||||||
|
params = {"ref": resolved_ref} if resolved_ref else None
|
||||||
|
url = f"{_GITHUB_API}/repos/{owner}/{repo}/contents/{clean_path}"
|
||||||
|
headers = github_headers(token or "")
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, params=params, timeout=30)
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
return {"success": False, "error": f"GitHub request failed: {exc!s}"}
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
return {"success": False, "error": f"not found: {clean_path} @ {resolved_ref or 'default'}"}
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return {"success": False, "error": f"GitHub returned {response.status_code}"}
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
if isinstance(payload, list):
|
||||||
|
entries = [
|
||||||
|
{
|
||||||
|
"name": item.get("name"),
|
||||||
|
"type": item.get("type"),
|
||||||
|
"path": item.get("path"),
|
||||||
|
}
|
||||||
|
for item in payload
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
return {"success": True, "path": clean_path, "ref": resolved_ref, "entries": entries}
|
||||||
|
|
||||||
|
if not isinstance(payload, dict) or payload.get("type") != "file":
|
||||||
|
return {"success": False, "error": f"unsupported content type for {clean_path}"}
|
||||||
|
|
||||||
|
encoded = payload.get("content")
|
||||||
|
if not isinstance(encoded, str):
|
||||||
|
return {"success": False, "error": "file content unavailable (too large for contents API)"}
|
||||||
|
raw = base64.b64decode(encoded)
|
||||||
|
truncated = len(raw) > _MAX_FILE_BYTES
|
||||||
|
text = raw[:_MAX_FILE_BYTES].decode("utf-8", errors="replace")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"path": clean_path,
|
||||||
|
"ref": resolved_ref,
|
||||||
|
"content": text,
|
||||||
|
"truncated": truncated,
|
||||||
|
}
|
||||||
83
agent/tools/search_repo_code.py
Normal file
83
agent/tools/search_repo_code.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""Tool: ``search_repo_code``. Search the PR's repository via the GitHub code-search API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from langgraph.config import get_config
|
||||||
|
|
||||||
|
from ..utils.github_checks import github_headers
|
||||||
|
|
||||||
|
_GITHUB_API = "https://api.github.com"
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_repo_context() -> tuple[str, str, str | None]:
|
||||||
|
config = get_config()
|
||||||
|
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||||
|
if not isinstance(configurable, dict):
|
||||||
|
configurable = {}
|
||||||
|
owner = configurable.get("chat_repo_owner")
|
||||||
|
repo = configurable.get("chat_repo_name")
|
||||||
|
token = configurable.get("chat_github_token")
|
||||||
|
return (
|
||||||
|
owner if isinstance(owner, str) else "",
|
||||||
|
repo if isinstance(repo, str) else "",
|
||||||
|
token if isinstance(token, str) and token else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_repo_code(query: str, max_results: int = 20) -> dict[str, Any]:
|
||||||
|
"""Search code in the PR's repository for a keyword, symbol, or phrase.
|
||||||
|
|
||||||
|
Backed by GitHub code search, which indexes the repository's default branch
|
||||||
|
(not arbitrary refs). Use it to locate where a symbol is defined or used,
|
||||||
|
then ``read_repo_file`` for the surrounding context. For matches within the
|
||||||
|
changed lines, search the virtual file ``/pr/diff.patch`` instead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search terms. Repo scoping is added automatically.
|
||||||
|
max_results: Max matches to return (capped at 50).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{success, total_count, results}`` where each result is
|
||||||
|
``{path, fragments}``; ``{success: False, error}`` on failure.
|
||||||
|
"""
|
||||||
|
owner, repo, token = _chat_repo_context()
|
||||||
|
if not owner or not repo:
|
||||||
|
return {"success": False, "error": "repository context unavailable"}
|
||||||
|
|
||||||
|
capped = max(1, min(max_results, 50))
|
||||||
|
headers = github_headers(token or "")
|
||||||
|
headers["Accept"] = "application/vnd.github.text-match+json"
|
||||||
|
params = {"q": f"{query} repo:{owner}/{repo}", "per_page": capped}
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{_GITHUB_API}/search/code", headers=headers, params=params, timeout=30
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
return {"success": False, "error": f"GitHub request failed: {exc!s}"}
|
||||||
|
|
||||||
|
if response.status_code == 422:
|
||||||
|
return {"success": False, "error": "query rejected by GitHub code search"}
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return {"success": False, "error": f"GitHub returned {response.status_code}"}
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
items = payload.get("items") if isinstance(payload, dict) else None
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for item in items if isinstance(items, list) else []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
fragments = [
|
||||||
|
match.get("fragment")
|
||||||
|
for match in item.get("text_matches", [])
|
||||||
|
if isinstance(match, dict) and isinstance(match.get("fragment"), str)
|
||||||
|
]
|
||||||
|
results.append({"path": item.get("path", ""), "fragments": fragments})
|
||||||
|
total = payload.get("total_count") if isinstance(payload, dict) else None
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"total_count": total if isinstance(total, int) else len(results),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"agent": "agent.server:traced_agent",
|
"agent": "agent.server:traced_agent",
|
||||||
"reviewer": "agent.reviewer:traced_reviewer_agent",
|
"reviewer": "agent.reviewer:traced_reviewer_agent",
|
||||||
"analyzer": "agent.analyzer:traced_analyzer",
|
"analyzer": "agent.analyzer:traced_analyzer",
|
||||||
|
"chat": "agent.chat:traced_chat_agent",
|
||||||
"scheduler": "agent.scheduler:get_scheduler",
|
"scheduler": "agent.scheduler:get_scheduler",
|
||||||
"ci_monitor": "agent.ci_monitor:get_ci_monitor"
|
"ci_monitor": "agent.ci_monitor:get_ci_monitor"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
532
tests/test_review_chat.py
Normal file
532
tests/test_review_chat.py
Normal file
|
|
@ -0,0 +1,532 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent.dashboard import review_chat_api
|
||||||
|
|
||||||
|
# `agent.tools.__init__` rebinds these names to the tool *functions*, shadowing
|
||||||
|
# the submodules. Import the real modules so we can monkeypatch their globals.
|
||||||
|
list_review_findings = importlib.import_module("agent.tools.list_review_findings")
|
||||||
|
read_repo_file = importlib.import_module("agent.tools.read_repo_file")
|
||||||
|
search_repo_code = importlib.import_module("agent.tools.search_repo_code")
|
||||||
|
|
||||||
|
|
||||||
|
# --- chat thread list / delete / title ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_title_from_first_user_message() -> None:
|
||||||
|
params = {
|
||||||
|
"input": {"messages": [{"type": "human", "content": " Why did we drop\nstructs? "}]}
|
||||||
|
}
|
||||||
|
assert review_chat_api._derive_title(params) == "Why did we drop structs?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_title_defaults_when_no_message() -> None:
|
||||||
|
assert review_chat_api._derive_title({"input": {"messages": []}}) == "New chat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_title_truncates() -> None:
|
||||||
|
params = {"input": {"messages": [{"type": "human", "content": "x" * 200}]}}
|
||||||
|
assert len(review_chat_api._derive_title(params)) == review_chat_api._TITLE_MAX_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_review_chat_threads_scopes_and_maps(monkeypatch) -> None:
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def search(**kwargs: Any) -> list[dict[str, Any]]:
|
||||||
|
captured["metadata"] = kwargs.get("metadata")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"thread_id": "c1",
|
||||||
|
"updated_at": "2026-06-15T00:00:00Z",
|
||||||
|
"metadata": {"title": "Why structs?"},
|
||||||
|
},
|
||||||
|
{"thread_id": "c2", "metadata": {}}, # untitled -> default label
|
||||||
|
]
|
||||||
|
|
||||||
|
client = SimpleNamespace(threads=SimpleNamespace(search=search))
|
||||||
|
monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client)
|
||||||
|
|
||||||
|
threads = await review_chat_api.list_review_chat_threads("acme", "repo", 7, "octocat")
|
||||||
|
assert captured["metadata"] == {
|
||||||
|
"kind": "review_chat",
|
||||||
|
"github_login": "octocat",
|
||||||
|
"repo_owner": "acme",
|
||||||
|
"repo_name": "repo",
|
||||||
|
"pr_number": 7,
|
||||||
|
}
|
||||||
|
assert threads[0] == {
|
||||||
|
"thread_id": "c1",
|
||||||
|
"title": "Why structs?",
|
||||||
|
"updated_at": "2026-06-15T00:00:00Z",
|
||||||
|
}
|
||||||
|
assert threads[1]["title"] == "New chat"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_review_chat_thread_checks_ownership(monkeypatch) -> None:
|
||||||
|
deleted: list[str] = []
|
||||||
|
|
||||||
|
async def get(thread_id: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"metadata": {
|
||||||
|
"kind": "review_chat",
|
||||||
|
"github_login": "octocat",
|
||||||
|
"repo_owner": "acme",
|
||||||
|
"repo_name": "repo",
|
||||||
|
"pr_number": 7,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def delete(thread_id: str) -> None:
|
||||||
|
deleted.append(thread_id)
|
||||||
|
|
||||||
|
client = SimpleNamespace(threads=SimpleNamespace(get=get, delete=delete))
|
||||||
|
monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client)
|
||||||
|
|
||||||
|
await review_chat_api.delete_review_chat_thread("acme", "repo", 7, "octocat", "c1")
|
||||||
|
assert deleted == ["c1"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_review_chat_thread_rejects_other_user(monkeypatch) -> None:
|
||||||
|
async def get(thread_id: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"metadata": {"kind": "review_chat", "github_login": "hubot"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def delete(thread_id: str) -> None:
|
||||||
|
raise AssertionError("should not delete another user's chat")
|
||||||
|
|
||||||
|
client = SimpleNamespace(threads=SimpleNamespace(get=get, delete=delete))
|
||||||
|
monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client)
|
||||||
|
|
||||||
|
with pytest.raises(Exception): # noqa: B017,PT011 - HTTPException(404)
|
||||||
|
await review_chat_api.delete_review_chat_thread("acme", "repo", 7, "octocat", "c1")
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_thread_metadata(monkeypatch, metadata: dict[str, Any] | None) -> None:
|
||||||
|
async def get(thread_id: str) -> dict[str, Any]:
|
||||||
|
if metadata is None:
|
||||||
|
raise RuntimeError("not found")
|
||||||
|
return {"thread_id": thread_id, "metadata": metadata}
|
||||||
|
|
||||||
|
client = SimpleNamespace(threads=SimpleNamespace(get=get))
|
||||||
|
monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_assert_chat_thread_access_allows_owner(monkeypatch) -> None:
|
||||||
|
_patch_thread_metadata(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"kind": "review_chat",
|
||||||
|
"github_login": "octocat",
|
||||||
|
"repo_owner": "acme",
|
||||||
|
"repo_name": "repo",
|
||||||
|
"pr_number": 7,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
meta = await review_chat_api.assert_chat_thread_access("ct-1", "acme", "repo", 7, "octocat")
|
||||||
|
assert meta is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_assert_chat_thread_access_missing_thread_returns_none(monkeypatch) -> None:
|
||||||
|
_patch_thread_metadata(monkeypatch, None)
|
||||||
|
assert (
|
||||||
|
await review_chat_api.assert_chat_thread_access("ct-1", "acme", "repo", 7, "octocat")
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"metadata",
|
||||||
|
[
|
||||||
|
{ # another user's chat thread
|
||||||
|
"kind": "review_chat",
|
||||||
|
"github_login": "hubot",
|
||||||
|
"repo_owner": "acme",
|
||||||
|
"repo_name": "repo",
|
||||||
|
"pr_number": 7,
|
||||||
|
},
|
||||||
|
{ # a reviewer (non-chat) thread with the same deterministic id space
|
||||||
|
"kind": "reviewer",
|
||||||
|
"github_login": "octocat",
|
||||||
|
"repo_owner": "acme",
|
||||||
|
"repo_name": "repo",
|
||||||
|
"pr_number": 7,
|
||||||
|
},
|
||||||
|
{ # right user, wrong PR scope
|
||||||
|
"kind": "review_chat",
|
||||||
|
"github_login": "octocat",
|
||||||
|
"repo_owner": "acme",
|
||||||
|
"repo_name": "repo",
|
||||||
|
"pr_number": 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_assert_chat_thread_access_rejects_unauthorized(monkeypatch, metadata) -> None:
|
||||||
|
_patch_thread_metadata(monkeypatch, metadata)
|
||||||
|
with pytest.raises(Exception): # noqa: B017,PT011 - HTTPException(404)
|
||||||
|
await review_chat_api.assert_chat_thread_access("ct-1", "acme", "repo", 7, "octocat")
|
||||||
|
|
||||||
|
|
||||||
|
# --- tools -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_review_findings_compacts_and_filters(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
list_review_findings,
|
||||||
|
"get_config",
|
||||||
|
lambda: {"configurable": {"reviewer_thread_id": "rt-1"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_list(thread_id: str) -> list[dict[str, Any]]:
|
||||||
|
assert thread_id == "rt-1"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "f1",
|
||||||
|
"title": "Open one",
|
||||||
|
"status": "open",
|
||||||
|
"severity": "high",
|
||||||
|
"github_review_comment_id": 999,
|
||||||
|
},
|
||||||
|
{"id": "f2", "title": "Closed one", "status": "resolved", "severity": "low"},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(list_review_findings, "list_findings_async", fake_list)
|
||||||
|
|
||||||
|
result = list_review_findings.list_review_findings(status_filter="open")
|
||||||
|
assert result["count"] == 1
|
||||||
|
finding = result["findings"][0]
|
||||||
|
assert finding["id"] == "f1"
|
||||||
|
# compact view drops GitHub plumbing fields
|
||||||
|
assert "github_review_comment_id" not in finding
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_review_findings_requires_reviewer_thread(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(list_review_findings, "get_config", lambda: {"configurable": {}})
|
||||||
|
result = list_review_findings.list_review_findings()
|
||||||
|
assert result["count"] == 0
|
||||||
|
assert "reviewer thread" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_repo_file_decodes_file(monkeypatch) -> None:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
read_repo_file,
|
||||||
|
"get_config",
|
||||||
|
lambda: {
|
||||||
|
"configurable": {
|
||||||
|
"chat_repo_owner": "acme",
|
||||||
|
"chat_repo_name": "repo",
|
||||||
|
"chat_github_token": "tok",
|
||||||
|
"chat_head_sha": "deadbeef",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def fake_get(url, headers=None, params=None, timeout=None):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["params"] = params
|
||||||
|
return SimpleNamespace(
|
||||||
|
status_code=200,
|
||||||
|
json=lambda: {"type": "file", "content": base64.b64encode(b"hello\nworld").decode()},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(read_repo_file.requests, "get", fake_get)
|
||||||
|
|
||||||
|
result = read_repo_file.read_repo_file("src/app.py")
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["content"] == "hello\nworld"
|
||||||
|
assert result["ref"] == "deadbeef" # defaults to head sha
|
||||||
|
assert captured["params"] == {"ref": "deadbeef"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_repo_file_lists_directory(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
read_repo_file,
|
||||||
|
"get_config",
|
||||||
|
lambda: {
|
||||||
|
"configurable": {
|
||||||
|
"chat_repo_owner": "acme",
|
||||||
|
"chat_repo_name": "repo",
|
||||||
|
"chat_github_token": "tok",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_get(url, headers=None, params=None, timeout=None):
|
||||||
|
return SimpleNamespace(
|
||||||
|
status_code=200,
|
||||||
|
json=lambda: [
|
||||||
|
{"name": "a.py", "type": "file", "path": "src/a.py"},
|
||||||
|
{"name": "sub", "type": "dir", "path": "src/sub"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(read_repo_file.requests, "get", fake_get)
|
||||||
|
result = read_repo_file.read_repo_file("src")
|
||||||
|
assert result["success"] is True
|
||||||
|
assert {e["name"] for e in result["entries"]} == {"a.py", "sub"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_repo_file_missing_context(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(read_repo_file, "get_config", lambda: {"configurable": {}})
|
||||||
|
result = read_repo_file.read_repo_file("src/app.py")
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_repo_code_scopes_to_repo(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
search_repo_code,
|
||||||
|
"get_config",
|
||||||
|
lambda: {
|
||||||
|
"configurable": {
|
||||||
|
"chat_repo_owner": "acme",
|
||||||
|
"chat_repo_name": "repo",
|
||||||
|
"chat_github_token": "tok",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def fake_get(url, headers=None, params=None, timeout=None):
|
||||||
|
captured["params"] = params
|
||||||
|
return SimpleNamespace(
|
||||||
|
status_code=200,
|
||||||
|
json=lambda: {
|
||||||
|
"total_count": 1,
|
||||||
|
"items": [{"path": "src/a.py", "text_matches": [{"fragment": "def foo()"}]}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(search_repo_code.requests, "get", fake_get)
|
||||||
|
result = search_repo_code.search_repo_code("foo")
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "repo:acme/repo" in captured["params"]["q"]
|
||||||
|
assert result["results"][0]["path"] == "src/a.py"
|
||||||
|
|
||||||
|
|
||||||
|
# --- proxy enrichment --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_review() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"title": "Fix things",
|
||||||
|
"number": 7,
|
||||||
|
"full_name": "acme/repo",
|
||||||
|
"author": "octocat",
|
||||||
|
"head_ref": "feature",
|
||||||
|
"base_ref": "main",
|
||||||
|
"head_sha": "abc123def456",
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"id": "f1",
|
||||||
|
"title": "Bug",
|
||||||
|
"severity": "high",
|
||||||
|
"confidence": "high",
|
||||||
|
"status": "open",
|
||||||
|
"file": "src/a.py",
|
||||||
|
"start_line": 5,
|
||||||
|
"description": "boom",
|
||||||
|
"group": "bug",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"pr": {
|
||||||
|
"state": "open",
|
||||||
|
"body": "desc",
|
||||||
|
"additions": 1,
|
||||||
|
"deletions": 2,
|
||||||
|
"changed_files": 1,
|
||||||
|
"commits": 1,
|
||||||
|
"head_sha": "abc123def456",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _client_for_enrich(existing_metadata: dict[str, Any] | None) -> tuple[Any, dict[str, Any]]:
|
||||||
|
captured: dict[str, Any] = {"created": False, "updated": []}
|
||||||
|
|
||||||
|
async def get(thread_id: str) -> dict[str, Any]:
|
||||||
|
if existing_metadata is None:
|
||||||
|
raise RuntimeError("not found")
|
||||||
|
return {"thread_id": thread_id, "metadata": existing_metadata}
|
||||||
|
|
||||||
|
async def create(**kwargs: Any) -> None:
|
||||||
|
captured["created"] = True
|
||||||
|
|
||||||
|
async def update(**kwargs: Any) -> None:
|
||||||
|
captured["updated"].append(kwargs.get("metadata"))
|
||||||
|
|
||||||
|
client = SimpleNamespace(threads=SimpleNamespace(get=get, create=create, update=update))
|
||||||
|
return client, captured
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_enrich_deps(monkeypatch, *, metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
client, captured = _client_for_enrich(metadata)
|
||||||
|
monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client)
|
||||||
|
|
||||||
|
async def fake_get_review(owner, repo, pr_number):
|
||||||
|
return _fake_review()
|
||||||
|
|
||||||
|
async def fake_diff(*, owner, repo, pr_number, token):
|
||||||
|
return "diff --git a/x b/x\n+added\n"
|
||||||
|
|
||||||
|
async def fake_token(repositories=None):
|
||||||
|
return "app-token"
|
||||||
|
|
||||||
|
monkeypatch.setattr(review_chat_api, "get_review", fake_get_review)
|
||||||
|
monkeypatch.setattr(review_chat_api, "fetch_pr_diff", fake_diff)
|
||||||
|
monkeypatch.setattr(review_chat_api, "get_github_app_installation_token", fake_token)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enrich_chat_command_seeds_context_on_create(monkeypatch) -> None:
|
||||||
|
_patch_enrich_deps(monkeypatch, metadata=None)
|
||||||
|
command = {"method": "run.start", "params": {"input": {"messages": []}}}
|
||||||
|
|
||||||
|
enriched = await review_chat_api._enrich_chat_command(
|
||||||
|
command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
params = enriched["params"]
|
||||||
|
assert params["assistant_id"] == "chat"
|
||||||
|
configurable = params["config"]["configurable"]
|
||||||
|
assert configurable["chat_repo_owner"] == "acme"
|
||||||
|
assert configurable["chat_repo_name"] == "repo"
|
||||||
|
assert configurable["chat_pr_number"] == 7
|
||||||
|
assert configurable["chat_head_sha"] == "abc123def456"
|
||||||
|
assert configurable["reviewer_thread_id"] == review_chat_api.reviewer_thread_id(
|
||||||
|
"acme", "repo", 7
|
||||||
|
)
|
||||||
|
files = params["input"]["files"]
|
||||||
|
assert set(files) == {"/pr/overview.md", "/pr/diff.patch", "/pr/findings.md"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enrich_chat_command_reuses_context_when_head_unchanged(monkeypatch) -> None:
|
||||||
|
# Stored head matches the current review head -> no reseed.
|
||||||
|
captured = _patch_enrich_deps(
|
||||||
|
monkeypatch, metadata={"kind": "review_chat", "chat_head_sha": "abc123def456"}
|
||||||
|
)
|
||||||
|
command = {"method": "run.start", "params": {"input": {"messages": []}}}
|
||||||
|
|
||||||
|
enriched = await review_chat_api._enrich_chat_command(
|
||||||
|
command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
params = enriched["params"]
|
||||||
|
# No re-seeding of files when the head hasn't moved.
|
||||||
|
assert "files" not in params["input"]
|
||||||
|
assert params["config"]["configurable"]["chat_head_sha"] == "abc123def456"
|
||||||
|
assert captured["created"] is False
|
||||||
|
assert captured["updated"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enrich_chat_command_reseeds_on_head_change(monkeypatch) -> None:
|
||||||
|
# Stored head is stale relative to the current review head -> reseed.
|
||||||
|
captured = _patch_enrich_deps(
|
||||||
|
monkeypatch, metadata={"kind": "review_chat", "chat_head_sha": "old-stale-sha"}
|
||||||
|
)
|
||||||
|
command = {"method": "run.start", "params": {"input": {"messages": []}}}
|
||||||
|
|
||||||
|
enriched = await review_chat_api._enrich_chat_command(
|
||||||
|
command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
params = enriched["params"]
|
||||||
|
files = params["input"]["files"]
|
||||||
|
assert set(files) == {"/pr/overview.md", "/pr/diff.patch", "/pr/findings.md"}
|
||||||
|
assert params["config"]["configurable"]["chat_head_sha"] == "abc123def456"
|
||||||
|
assert {"chat_head_sha": "abc123def456"} in captured["updated"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enrich_chat_command_ignores_non_run_start(monkeypatch) -> None:
|
||||||
|
_patch_enrich_deps(monkeypatch, metadata=None)
|
||||||
|
command = {"method": "something.else", "params": {}}
|
||||||
|
enriched = await review_chat_api._enrich_chat_command(
|
||||||
|
command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1"
|
||||||
|
)
|
||||||
|
assert enriched == command
|
||||||
|
assert "assistant_id" not in enriched["params"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_proxy_state_normalizes_missing_thread(monkeypatch) -> None:
|
||||||
|
async def fake_passthrough(method, thread_id, suffix, body, content_type):
|
||||||
|
return 404, b"not found", "text/plain"
|
||||||
|
|
||||||
|
async def no_thread(thread_id: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(review_chat_api, "_proxy_passthrough", fake_passthrough)
|
||||||
|
monkeypatch.setattr(review_chat_api, "_get_chat_thread_metadata", no_thread)
|
||||||
|
status, content, media_type = await review_chat_api.proxy_review_chat_state(
|
||||||
|
"acme", "repo", 7, "octocat", "ct-1"
|
||||||
|
)
|
||||||
|
assert status == 200
|
||||||
|
assert b'"next": []' in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_proxy_history_normalizes_missing_thread(monkeypatch) -> None:
|
||||||
|
async def fake_passthrough(method, thread_id, suffix, body, content_type):
|
||||||
|
return 404, b"not found", "text/plain"
|
||||||
|
|
||||||
|
async def no_thread(thread_id: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(review_chat_api, "_proxy_passthrough", fake_passthrough)
|
||||||
|
monkeypatch.setattr(review_chat_api, "_get_chat_thread_metadata", no_thread)
|
||||||
|
status, content, _ = await review_chat_api.proxy_review_chat_history(
|
||||||
|
"acme", "repo", 7, "octocat", "ct-1", b"{}"
|
||||||
|
)
|
||||||
|
assert status == 200
|
||||||
|
assert content == b"[]"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_proxy_state_rejects_foreign_thread(monkeypatch) -> None:
|
||||||
|
async def other_owner(thread_id: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": "review_chat",
|
||||||
|
"github_login": "hubot",
|
||||||
|
"repo_owner": "acme",
|
||||||
|
"repo_name": "repo",
|
||||||
|
"pr_number": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_passthrough(*args, **kwargs):
|
||||||
|
raise AssertionError("must not proxy a thread the caller doesn't own")
|
||||||
|
|
||||||
|
monkeypatch.setattr(review_chat_api, "_get_chat_thread_metadata", other_owner)
|
||||||
|
monkeypatch.setattr(review_chat_api, "_proxy_passthrough", fake_passthrough)
|
||||||
|
with pytest.raises(Exception): # noqa: B017,PT011 - HTTPException(404)
|
||||||
|
await review_chat_api.proxy_review_chat_state("acme", "repo", 7, "octocat", "ct-1")
|
||||||
|
|
||||||
|
|
||||||
|
# --- graph factory guard -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_chat_agent_returns_trivial_when_not_for_execution() -> None:
|
||||||
|
from agent.chat import get_chat_agent
|
||||||
|
|
||||||
|
graph = asyncio.run(get_chat_agent({"configurable": {"thread_id": None}}))
|
||||||
|
assert graph is not None
|
||||||
|
|
@ -9,8 +9,12 @@ from agent.dashboard.team_settings import (
|
||||||
ORG_GUIDELINES_MAX_CHARS,
|
ORG_GUIDELINES_MAX_CHARS,
|
||||||
TeamSettingsUpdate,
|
TeamSettingsUpdate,
|
||||||
get_org_review_guidelines,
|
get_org_review_guidelines,
|
||||||
|
get_team_default_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_AGENT_PAIR = ("anthropic:claude-opus-4-8", "high")
|
||||||
|
_CHAT_PAIR = ("google_genai:gemini-3.5-flash", "low")
|
||||||
|
|
||||||
|
|
||||||
def test_org_guidelines_blank_normalizes_to_none() -> None:
|
def test_org_guidelines_blank_normalizes_to_none() -> None:
|
||||||
assert TeamSettingsUpdate(org_guidelines=" ").org_guidelines is None
|
assert TeamSettingsUpdate(org_guidelines=" ").org_guidelines is None
|
||||||
|
|
@ -45,3 +49,69 @@ async def test_get_org_review_guidelines_returns_none_when_unset() -> None:
|
||||||
return_value={"org_guidelines": None},
|
return_value={"org_guidelines": None},
|
||||||
):
|
):
|
||||||
assert await get_org_review_guidelines() is None
|
assert await get_org_review_guidelines() is None
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides: object) -> dict[str, object]:
|
||||||
|
base = {
|
||||||
|
"default_agent_model": _AGENT_PAIR[0],
|
||||||
|
"default_agent_reasoning_effort": _AGENT_PAIR[1],
|
||||||
|
"default_chat_model": None,
|
||||||
|
"default_chat_reasoning_effort": None,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_default_inherits_agent_when_unset() -> None:
|
||||||
|
with patch(
|
||||||
|
"agent.dashboard.team_settings.get_team_settings",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=_settings(),
|
||||||
|
):
|
||||||
|
assert await get_team_default_model("chat") == _AGENT_PAIR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_default_uses_chat_model_when_set() -> None:
|
||||||
|
with patch(
|
||||||
|
"agent.dashboard.team_settings.get_team_settings",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=_settings(
|
||||||
|
default_chat_model=_CHAT_PAIR[0],
|
||||||
|
default_chat_reasoning_effort=_CHAT_PAIR[1],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
assert await get_team_default_model("chat") == _CHAT_PAIR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_default_inherits_agent_when_chat_model_invalid() -> None:
|
||||||
|
with patch(
|
||||||
|
"agent.dashboard.team_settings.get_team_settings",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=_settings(
|
||||||
|
default_chat_model="bogus:model",
|
||||||
|
default_chat_reasoning_effort="high",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
assert await get_team_default_model("chat") == _AGENT_PAIR
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_settings_update_accepts_chat_pair() -> None:
|
||||||
|
update = TeamSettingsUpdate(
|
||||||
|
default_chat_model=_CHAT_PAIR[0],
|
||||||
|
default_chat_reasoning_effort=_CHAT_PAIR[1],
|
||||||
|
)
|
||||||
|
assert update.default_chat_model == _CHAT_PAIR[0]
|
||||||
|
assert update.default_chat_reasoning_effort == _CHAT_PAIR[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_settings_update_rejects_chat_effort_without_model() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TeamSettingsUpdate(default_chat_reasoning_effort="high")
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_settings_update_rejects_unsupported_chat_effort() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TeamSettingsUpdate(default_chat_model=_CHAT_PAIR[0], default_chat_reasoning_effort="max")
|
||||||
|
|
|
||||||
641
ui/src/components/agents/ReviewChat.tsx
Normal file
641
ui/src/components/agents/ReviewChat.tsx
Normal file
|
|
@ -0,0 +1,641 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { StreamProvider, useStreamContext } from "@langchain/react"
|
||||||
|
import { overrideFetchImplementation } from "@langchain/langgraph-sdk"
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import {
|
||||||
|
ArrowClockwiseIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
CheckIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SparkleIcon,
|
||||||
|
TrashIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "@phosphor-icons/react"
|
||||||
|
import { Menu } from "@base-ui/react/menu"
|
||||||
|
import type { BaseMessage } from "@langchain/core/messages"
|
||||||
|
|
||||||
|
import type { ReviewChatThread } from "@/lib/api"
|
||||||
|
import { Markdown } from "@/components/agents/ported"
|
||||||
|
import { IconButton } from "@/components/ui/button"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { api, reviewChatApiBase } from "@/lib/api"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const dashboardFetch: typeof fetch = (input, init) =>
|
||||||
|
fetch(input, { ...init, credentials: "include" })
|
||||||
|
|
||||||
|
// The SDK's internal Client issues some reads (getState, history) outside the
|
||||||
|
// transport's fetch; without this they drop the session cookie cross-origin.
|
||||||
|
overrideFetchImplementation(dashboardFetch)
|
||||||
|
|
||||||
|
const SUGGESTED_PROMPTS = [
|
||||||
|
"Summarize the changes in this PR",
|
||||||
|
"Walk me through the review findings",
|
||||||
|
"What are the riskiest parts of this change?",
|
||||||
|
]
|
||||||
|
|
||||||
|
// Kept in sync with the backend's `_derive_title` so the optimistic tab label
|
||||||
|
// matches the title the server persists for the thread.
|
||||||
|
const DEFAULT_TITLE = "New chat"
|
||||||
|
const TITLE_MAX_CHARS = 60
|
||||||
|
|
||||||
|
function deriveTitle(text: string): string {
|
||||||
|
const flattened = text.trim().split(/\s+/).join(" ")
|
||||||
|
return flattened ? flattened.slice(0, TITLE_MAX_CHARS) : DEFAULT_TITLE
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageType(message: BaseMessage): string {
|
||||||
|
const candidate = message as unknown as {
|
||||||
|
getType?: () => string
|
||||||
|
type?: string
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
return candidate.getType?.() ?? candidate.type ?? candidate.role ?? "ai"
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageText(content: BaseMessage["content"]): string {
|
||||||
|
if (typeof content === "string") return content
|
||||||
|
if (!Array.isArray(content)) return ""
|
||||||
|
return content
|
||||||
|
.map((block) => {
|
||||||
|
if (typeof block === "string") return block
|
||||||
|
if (typeof block === "object" && "text" in block) {
|
||||||
|
const text = (block as { text?: unknown }).text
|
||||||
|
return typeof text === "string" ? text : ""
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Conversation store ------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The client owns the conversation list. Each conversation is a client-minted
|
||||||
|
// thread id; the thread is created server-side lazily on its first message.
|
||||||
|
// The server thread list is only used to recover conversations on a fresh
|
||||||
|
// browser and to reconcile titles — it is never the sole source for the tabs,
|
||||||
|
// so an empty/lagging server response can no longer make open chats vanish.
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatState {
|
||||||
|
conversations: Array<Conversation>
|
||||||
|
activeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(owner: string, repo: string, number: number): string {
|
||||||
|
return `osw:review-chat:${owner}/${repo}/${number}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function newDraft(): Conversation {
|
||||||
|
return { id: crypto.randomUUID(), title: DEFAULT_TITLE, createdAt: Date.now() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConversation(value: unknown): value is Conversation {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
const candidate = value as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
typeof candidate.id === "string" &&
|
||||||
|
typeof candidate.title === "string" &&
|
||||||
|
typeof candidate.createdAt === "number"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadState(key: string): ChatState {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
if (raw) {
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
const source = (parsed ?? {}) as Record<string, unknown>
|
||||||
|
const conversations = Array.isArray(source.conversations)
|
||||||
|
? source.conversations.filter(isConversation)
|
||||||
|
: []
|
||||||
|
const first = conversations[0]
|
||||||
|
if (first) {
|
||||||
|
const activeId =
|
||||||
|
typeof source.activeId === "string" &&
|
||||||
|
conversations.some((c) => c.id === source.activeId)
|
||||||
|
? source.activeId
|
||||||
|
: first.id
|
||||||
|
return { conversations, activeId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through to a fresh draft */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const draft = newDraft()
|
||||||
|
return { conversations: [draft], activeId: draft.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveState(key: string, state: ChatState): void {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(state))
|
||||||
|
} catch {
|
||||||
|
/* ignore quota / availability errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useConversations(key: string) {
|
||||||
|
const [state, setState] = useState<ChatState>(() => loadState(key))
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
(fn: (prev: ChatState) => ChatState) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = fn(prev)
|
||||||
|
if (next === prev) return prev
|
||||||
|
saveState(key, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[key],
|
||||||
|
)
|
||||||
|
|
||||||
|
const select = useCallback(
|
||||||
|
(conversation: Conversation) => {
|
||||||
|
update((prev) => ({
|
||||||
|
conversations: prev.conversations.some((c) => c.id === conversation.id)
|
||||||
|
? prev.conversations
|
||||||
|
: [...prev.conversations, conversation],
|
||||||
|
activeId: conversation.id,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[update],
|
||||||
|
)
|
||||||
|
|
||||||
|
const newChat = useCallback(() => {
|
||||||
|
update((prev) => {
|
||||||
|
const active = prev.conversations.find((c) => c.id === prev.activeId)
|
||||||
|
// Reuse a pristine, never-sent draft instead of stacking empty tabs.
|
||||||
|
if (active && active.title === DEFAULT_TITLE) return prev
|
||||||
|
const draft = newDraft()
|
||||||
|
return { conversations: [...prev.conversations, draft], activeId: draft.id }
|
||||||
|
})
|
||||||
|
}, [update])
|
||||||
|
|
||||||
|
// Names a conversation from its first message; later messages don't rename it.
|
||||||
|
const nameConversation = useCallback(
|
||||||
|
(id: string, title: string) => {
|
||||||
|
update((prev) => {
|
||||||
|
const current = prev.conversations.find((c) => c.id === id)
|
||||||
|
if (!current || current.title !== DEFAULT_TITLE) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
conversations: prev.conversations.map((c) =>
|
||||||
|
c.id === id ? { ...c, title } : c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[update],
|
||||||
|
)
|
||||||
|
|
||||||
|
const close = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
update((prev) => {
|
||||||
|
const index = prev.conversations.findIndex((c) => c.id === id)
|
||||||
|
if (index === -1) return prev
|
||||||
|
const remaining = prev.conversations.filter((c) => c.id !== id)
|
||||||
|
const fallback = remaining[Math.max(0, index - 1)]
|
||||||
|
if (!fallback) {
|
||||||
|
const draft = newDraft()
|
||||||
|
return { conversations: [draft], activeId: draft.id }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
conversations: remaining,
|
||||||
|
activeId: prev.activeId === id ? fallback.id : prev.activeId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[update],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { ...state, select, newChat, nameConversation, close }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- View --------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EmptyState({ onPick }: { onPick: (prompt: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
I've reviewed this PR. Ask me about the diff, the findings, or the
|
||||||
|
surrounding code — I have read-only access to the repository.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Suggested prompts
|
||||||
|
</span>
|
||||||
|
{SUGGESTED_PROMPTS.map((prompt) => (
|
||||||
|
<button
|
||||||
|
key={prompt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(prompt)}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-foreground hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
<SparkleIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
{prompt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Skeleton className="h-12 w-2/5 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-4/5" />
|
||||||
|
<Skeleton className="h-4 w-3/5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatBody({
|
||||||
|
onUserSend,
|
||||||
|
expectsHistory,
|
||||||
|
}: {
|
||||||
|
onUserSend: (text: string) => void
|
||||||
|
expectsHistory: boolean
|
||||||
|
}) {
|
||||||
|
const stream = useStreamContext()
|
||||||
|
const [value, setValue] = useState("")
|
||||||
|
const endRef = useRef<HTMLDivElement>(null)
|
||||||
|
const messages = stream.messages
|
||||||
|
const busy = stream.isLoading
|
||||||
|
// True during the one-time getState hydration when switching to / loading an
|
||||||
|
// existing thread, before its messages have arrived.
|
||||||
|
const hydrating = stream.isThreadLoading
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
endRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed || busy) return
|
||||||
|
onUserSend(trimmed)
|
||||||
|
void stream.submit({ messages: [{ type: "human", content: trimmed }] })
|
||||||
|
},
|
||||||
|
[busy, stream, onUserSend],
|
||||||
|
)
|
||||||
|
|
||||||
|
const visible = messages.filter((message) => {
|
||||||
|
const type = messageType(message)
|
||||||
|
if (type !== "human" && type !== "ai") return false
|
||||||
|
return messageText(message.content).trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitComposer = () => {
|
||||||
|
send(value)
|
||||||
|
setValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the loading placeholder (not the empty/intro state) while an existing
|
||||||
|
// conversation hydrates, so a chat with messages never flashes its greeting.
|
||||||
|
const showEmpty = visible.length === 0 && !busy
|
||||||
|
const showLoading = showEmpty && hydrating && expectsHistory
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{showLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : showEmpty ? (
|
||||||
|
<EmptyState onPick={send} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||||
|
{visible.map((message, index) => {
|
||||||
|
const isUser = messageType(message) === "human"
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id ?? index}
|
||||||
|
className={cn("flex", isUser ? "justify-end" : "justify-start")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-foreground",
|
||||||
|
isUser
|
||||||
|
? "max-w-[85%] rounded-lg bg-muted px-3 py-2"
|
||||||
|
: "w-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUser ? (
|
||||||
|
<span className="whitespace-pre-wrap">
|
||||||
|
{messageText(message.content)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Markdown content={messageText(message.content)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{busy && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
Thinking…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-end gap-2 rounded-2xl border border-border bg-background py-1.5 pl-3.5 pr-1.5 transition-colors focus-within:border-ring/60">
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
submitComposer()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Ask anything about this PR…"
|
||||||
|
rows={1}
|
||||||
|
className="max-h-40 min-h-7 flex-1 resize-none rounded-none border-0 bg-transparent px-0 py-1 shadow-none focus-visible:border-transparent focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
onClick={submitComposer}
|
||||||
|
disabled={!value.trim() || busy}
|
||||||
|
aria-label="Send message"
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="size-4" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatPanel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
number,
|
||||||
|
assistantId,
|
||||||
|
}: {
|
||||||
|
owner: string
|
||||||
|
repo: string
|
||||||
|
number: number
|
||||||
|
assistantId: string
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const threadsKey = useMemo(
|
||||||
|
() => ["review-chat-threads", owner, repo, number] as const,
|
||||||
|
[owner, repo, number],
|
||||||
|
)
|
||||||
|
const { conversations, activeId, select, newChat, nameConversation, close } =
|
||||||
|
useConversations(storageKey(owner, repo, number))
|
||||||
|
|
||||||
|
const threadsQuery = useQuery({
|
||||||
|
queryKey: threadsKey,
|
||||||
|
queryFn: () => api.listReviewChatThreads(owner, repo, number),
|
||||||
|
})
|
||||||
|
const serverThreads = useMemo(
|
||||||
|
() => threadsQuery.data?.threads ?? [],
|
||||||
|
[threadsQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const invalidateThreads = useCallback(() => {
|
||||||
|
void qc.invalidateQueries({ queryKey: threadsKey })
|
||||||
|
}, [qc, threadsKey])
|
||||||
|
|
||||||
|
const deleteThread = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
api.deleteReviewChatThread(owner, repo, number, id),
|
||||||
|
onMutate: (id: string) => {
|
||||||
|
qc.setQueryData<{ threads: Array<ReviewChatThread> }>(threadsKey, (old) =>
|
||||||
|
old ? { threads: old.threads.filter((t) => t.thread_id !== id) } : old,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: invalidateThreads,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Open tabs are the client's conversations (titles reconciled from the
|
||||||
|
// server). The history dropdown is the full set — open tabs plus every other
|
||||||
|
// conversation the server knows about (e.g. recovered on a fresh browser) —
|
||||||
|
// newest first, so past chats are reachable without cluttering the tab strip.
|
||||||
|
const { openTabs, historyItems } = useMemo(() => {
|
||||||
|
const byId = new Map(conversations.map((c) => [c.id, { ...c }]))
|
||||||
|
for (const thread of serverThreads) {
|
||||||
|
const existing = byId.get(thread.thread_id)
|
||||||
|
if (existing) {
|
||||||
|
if (thread.title && thread.title !== DEFAULT_TITLE) {
|
||||||
|
existing.title = thread.title
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
byId.set(thread.thread_id, {
|
||||||
|
id: thread.thread_id,
|
||||||
|
title: thread.title || DEFAULT_TITLE,
|
||||||
|
createdAt: thread.updated_at ? Date.parse(thread.updated_at) || 0 : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
openTabs: conversations.map((c) => byId.get(c.id) ?? c),
|
||||||
|
historyItems: [...byId.values()].sort((a, b) => b.createdAt - a.createdAt),
|
||||||
|
}
|
||||||
|
}, [conversations, serverThreads])
|
||||||
|
|
||||||
|
const handleClose = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (serverThreads.some((t) => t.thread_id === id)) {
|
||||||
|
deleteThread.mutate(id)
|
||||||
|
}
|
||||||
|
close(id)
|
||||||
|
},
|
||||||
|
[serverThreads, deleteThread, close],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleUserSend = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
nameConversation(activeId, deriveTitle(text))
|
||||||
|
},
|
||||||
|
[nameConversation, activeId],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Whether the active conversation should already have messages: it exists
|
||||||
|
// server-side, or it's been named by a sent message. Drives the hydration
|
||||||
|
// placeholder so an existing chat never flashes the intro state on load.
|
||||||
|
const expectsHistory = useMemo(() => {
|
||||||
|
if (serverThreads.some((t) => t.thread_id === activeId)) return true
|
||||||
|
const active = conversations.find((c) => c.id === activeId)
|
||||||
|
return active !== undefined && active.title !== DEFAULT_TITLE
|
||||||
|
}, [serverThreads, conversations, activeId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||||
|
<div className="flex flex-1 items-center gap-1 overflow-x-auto">
|
||||||
|
{openTabs.map((tab) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center gap-1 rounded-md py-1 pl-2 pr-1 text-xs",
|
||||||
|
tab.id === activeId
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => select(tab)}
|
||||||
|
className="max-w-[140px] truncate"
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</button>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="Close chat"
|
||||||
|
onClick={() => handleClose(tab.id)}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="New chat"
|
||||||
|
onClick={newChat}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Menu.Root
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) void threadsQuery.refetch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu.Trigger
|
||||||
|
render={
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Chat history"
|
||||||
|
>
|
||||||
|
<ArrowClockwiseIcon />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Menu.Portal>
|
||||||
|
<Menu.Positioner align="end" sideOffset={6} className="z-50">
|
||||||
|
<Menu.Popup className="origin-(--transform-origin) max-h-80 w-64 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||||
|
{historyItems.length === 0 ? (
|
||||||
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
No conversations yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
historyItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="group/hist relative flex items-stretch"
|
||||||
|
>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => select(item)}
|
||||||
|
className="flex flex-1 cursor-default items-center gap-2 rounded-md py-1.5 pl-2 pr-8 text-xs outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{item.id === activeId ? (
|
||||||
|
<CheckIcon className="size-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<span className="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate text-left">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
</Menu.Item>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="Delete chat"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/hist:opacity-100"
|
||||||
|
onClick={() => handleClose(item.id)}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Menu.Popup>
|
||||||
|
</Menu.Positioner>
|
||||||
|
</Menu.Portal>
|
||||||
|
</Menu.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StreamProvider
|
||||||
|
key={activeId}
|
||||||
|
apiUrl={reviewChatApiBase(owner, repo, number)}
|
||||||
|
assistantId={assistantId}
|
||||||
|
fetch={dashboardFetch}
|
||||||
|
threadId={activeId}
|
||||||
|
onCompleted={invalidateThreads}
|
||||||
|
>
|
||||||
|
<ChatBody onUserSend={handleUserSend} expectsHistory={expectsHistory} />
|
||||||
|
</StreamProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewChat({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
number,
|
||||||
|
}: {
|
||||||
|
owner: string
|
||||||
|
repo: string
|
||||||
|
number: number
|
||||||
|
}) {
|
||||||
|
const meta = useQuery({
|
||||||
|
queryKey: ["review-chat", owner, repo, number],
|
||||||
|
queryFn: () => api.getReviewChat(owner, repo, number),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (meta.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-3 p-4">
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.isError || !meta.data.available) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-muted-foreground">
|
||||||
|
Chat becomes available once the review has finished running.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatPanel
|
||||||
|
key={`${owner}/${repo}/${number}`}
|
||||||
|
owner={owner}
|
||||||
|
repo={repo}
|
||||||
|
number={number}
|
||||||
|
assistantId={meta.data.assistant_id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -120,6 +120,8 @@ export interface TeamSettings {
|
||||||
default_reviewer_reasoning_effort?: string | null;
|
default_reviewer_reasoning_effort?: string | null;
|
||||||
default_reviewer_subagent_model?: string | null;
|
default_reviewer_subagent_model?: string | null;
|
||||||
default_reviewer_subagent_reasoning_effort?: string | null;
|
default_reviewer_subagent_reasoning_effort?: string | null;
|
||||||
|
default_chat_model?: string | null;
|
||||||
|
default_chat_reasoning_effort?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,6 +381,35 @@ export interface ReviewDiffPayload {
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReviewChatMeta {
|
||||||
|
available: boolean;
|
||||||
|
assistant_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewChatThread {
|
||||||
|
thread_id: string;
|
||||||
|
title: string;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute base URL for the PR chat's LangGraph StreamProvider. The SDK builds
|
||||||
|
* request URLs as `new URL(apiUrl + path)`, so this must be absolute — a
|
||||||
|
* same-origin base is promoted using the current origin.
|
||||||
|
*/
|
||||||
|
export function reviewChatApiBase(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
number: number,
|
||||||
|
): string {
|
||||||
|
const path = `${API_BASE}/dashboard/api/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`;
|
||||||
|
if (/^https?:\/\//.test(path)) return path;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return `${window.location.origin}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReviewerEvalStatus {
|
export interface ReviewerEvalStatus {
|
||||||
name: string;
|
name: string;
|
||||||
status: "idle" | "running" | "completed" | "failed";
|
status: "idle" | "running" | "completed" | "failed";
|
||||||
|
|
@ -493,6 +524,24 @@ export const api = {
|
||||||
request<ReviewDiffPayload>(
|
request<ReviewDiffPayload>(
|
||||||
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/diff`,
|
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/diff`,
|
||||||
),
|
),
|
||||||
|
getReviewChat: (owner: string, repo: string, number: number) =>
|
||||||
|
request<ReviewChatMeta>(
|
||||||
|
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`,
|
||||||
|
),
|
||||||
|
listReviewChatThreads: (owner: string, repo: string, number: number) =>
|
||||||
|
request<{ threads: Array<ReviewChatThread> }>(
|
||||||
|
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads`,
|
||||||
|
),
|
||||||
|
deleteReviewChatThread: (
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
number: number,
|
||||||
|
threadId: string,
|
||||||
|
) =>
|
||||||
|
request<void>(
|
||||||
|
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads/${encodeURIComponent(threadId)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
reReview: (owner: string, repo: string, number: number) =>
|
reReview: (owner: string, repo: string, number: number) =>
|
||||||
request<{
|
request<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
|
||||||
|
|
@ -55,16 +55,20 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
<body>
|
<body>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children ?? <Outlet />}
|
{children ?? <Outlet />}
|
||||||
<TanStackDevtools
|
{import.meta.env.VITE_DEVTOOLS !== "false" && (
|
||||||
config={{ position: "bottom-right" }}
|
<>
|
||||||
plugins={[
|
<TanStackDevtools
|
||||||
{
|
config={{ position: "bottom-right" }}
|
||||||
name: "Tanstack Router",
|
plugins={[
|
||||||
render: <TanStackRouterDevtoolsPanel />,
|
{
|
||||||
},
|
name: "Tanstack Router",
|
||||||
]}
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
/>
|
},
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
]}
|
||||||
|
/>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -559,6 +559,31 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
|
||||||
}
|
}
|
||||||
disabled={!settings.data || save.isPending}
|
disabled={!settings.data || save.isPending}
|
||||||
/>
|
/>
|
||||||
|
<RolePicker
|
||||||
|
label="Open SWE Review Chat"
|
||||||
|
description="Model used by the 'chat with this PR' assistant on the review page. Inherits the Agent default when unset."
|
||||||
|
models={models}
|
||||||
|
model={settings.data?.default_chat_model ?? null}
|
||||||
|
effort={settings.data?.default_chat_reasoning_effort ?? null}
|
||||||
|
inheritLabel="Agent default"
|
||||||
|
onInherit={() =>
|
||||||
|
settings.data &&
|
||||||
|
save.mutate({
|
||||||
|
...settings.data,
|
||||||
|
default_chat_model: null,
|
||||||
|
default_chat_reasoning_effort: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onChange={(model, effort) =>
|
||||||
|
settings.data &&
|
||||||
|
save.mutate({
|
||||||
|
...settings.data,
|
||||||
|
default_chat_model: model,
|
||||||
|
default_chat_reasoning_effort: effort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!settings.data || save.isPending}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
|
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
@ -573,8 +598,17 @@ interface RolePickerProps {
|
||||||
effort: string | null
|
effort: string | null
|
||||||
onChange: (model: string, effort: string) => void
|
onChange: (model: string, effort: string) => void
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
|
/**
|
||||||
|
* When set, the model dropdown gains a leading "inherit" option with this
|
||||||
|
* label. Selecting it calls {@link onInherit} (clearing the override); an
|
||||||
|
* unset `model` renders as this option.
|
||||||
|
*/
|
||||||
|
inheritLabel?: string
|
||||||
|
onInherit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INHERIT_VALUE = "__inherit__"
|
||||||
|
|
||||||
function RolePicker({
|
function RolePicker({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
|
|
@ -583,20 +617,30 @@ function RolePicker({
|
||||||
effort,
|
effort,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
|
inheritLabel,
|
||||||
|
onInherit,
|
||||||
}: RolePickerProps) {
|
}: RolePickerProps) {
|
||||||
const [localModel, setLocalModel] = useState<string>(model ?? "")
|
const inheritFallback = inheritLabel ? INHERIT_VALUE : ""
|
||||||
|
const [localModel, setLocalModel] = useState<string>(model ?? inheritFallback)
|
||||||
const [localEffort, setLocalEffort] = useState<string>(effort ?? "")
|
const [localEffort, setLocalEffort] = useState<string>(effort ?? "")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalModel(model ?? "")
|
setLocalModel(model ?? inheritFallback)
|
||||||
setLocalEffort(effort ?? "")
|
setLocalEffort(effort ?? "")
|
||||||
}, [model, effort])
|
}, [model, effort, inheritFallback])
|
||||||
|
|
||||||
|
const isInherit = localModel === INHERIT_VALUE
|
||||||
const selectedModel = models.find((m) => m.id === localModel)
|
const selectedModel = models.find((m) => m.id === localModel)
|
||||||
const availableEfforts = selectedModel?.efforts ?? []
|
const availableEfforts = selectedModel?.efforts ?? []
|
||||||
|
|
||||||
const handleModelChange = (value: string | null) => {
|
const handleModelChange = (value: string | null) => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
if (value === INHERIT_VALUE) {
|
||||||
|
setLocalModel(INHERIT_VALUE)
|
||||||
|
setLocalEffort("")
|
||||||
|
onInherit?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
const nextModel = models.find((m) => m.id === value)
|
const nextModel = models.find((m) => m.id === value)
|
||||||
if (!nextModel) return
|
if (!nextModel) return
|
||||||
const nextEffort = nextModel.efforts.includes(localEffort)
|
const nextEffort = nextModel.efforts.includes(localEffort)
|
||||||
|
|
@ -608,7 +652,7 @@ function RolePicker({
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEffortChange = (value: string | null) => {
|
const handleEffortChange = (value: string | null) => {
|
||||||
if (!value || !localModel) return
|
if (!value || !localModel || isInherit) return
|
||||||
setLocalEffort(value)
|
setLocalEffort(value)
|
||||||
onChange(localModel, value)
|
onChange(localModel, value)
|
||||||
}
|
}
|
||||||
|
|
@ -628,6 +672,9 @@ function RolePicker({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
{inheritLabel && (
|
||||||
|
<SelectItem value={INHERIT_VALUE}>{inheritLabel}</SelectItem>
|
||||||
|
)}
|
||||||
{models.map((m) => (
|
{models.map((m) => (
|
||||||
<SelectItem key={m.id} value={m.id}>
|
<SelectItem key={m.id} value={m.id}>
|
||||||
{m.label}
|
{m.label}
|
||||||
|
|
@ -638,7 +685,7 @@ function RolePicker({
|
||||||
<Select
|
<Select
|
||||||
value={localEffort}
|
value={localEffort}
|
||||||
onValueChange={handleEffortChange}
|
onValueChange={handleEffortChange}
|
||||||
disabled={disabled || !localModel}
|
disabled={disabled || !localModel || isInherit}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-28">
|
<SelectTrigger className="w-28">
|
||||||
<SelectValue placeholder="effort" />
|
<SelectValue placeholder="effort" />
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import type {
|
||||||
ReviewUserRef,
|
ReviewUserRef,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { Markdown } from "@/components/agents/ported"
|
import { Markdown } from "@/components/agents/ported"
|
||||||
|
import { ReviewChat } from "@/components/agents/ReviewChat"
|
||||||
import { useRegisterReviewSidebar } from "@/components/agents/ReviewSidebar"
|
import { useRegisterReviewSidebar } from "@/components/agents/ReviewSidebar"
|
||||||
import {
|
import {
|
||||||
useDiffOptions,
|
useDiffOptions,
|
||||||
|
|
@ -81,7 +82,9 @@ function findingSide(finding: ReviewFinding): "deletions" | "additions" {
|
||||||
return finding.side === "LEFT" ? "deletions" : "additions"
|
return finding.side === "LEFT" ? "deletions" : "additions"
|
||||||
}
|
}
|
||||||
|
|
||||||
function findingSelectedRange(finding: ReviewFinding): SelectedLineRange | null {
|
function findingSelectedRange(
|
||||||
|
finding: ReviewFinding
|
||||||
|
): SelectedLineRange | null {
|
||||||
if (finding.end_line === null) return null
|
if (finding.end_line === null) return null
|
||||||
const side = findingSide(finding)
|
const side = findingSide(finding)
|
||||||
return {
|
return {
|
||||||
|
|
@ -825,6 +828,94 @@ function Badgeish({ children }: { children: React.ReactNode }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REVIEW_PANEL_STORAGE_WIDTH = "open-swe.review-panel.width"
|
||||||
|
const REVIEW_PANEL_DEFAULT_WIDTH = 420
|
||||||
|
const REVIEW_PANEL_MIN_WIDTH = 360
|
||||||
|
// Keep at least this much room for the PR content column so the panel can grow
|
||||||
|
// wide without squeezing the diff/description below a usable width.
|
||||||
|
const REVIEW_PANEL_MIN_MAIN_WIDTH = 480
|
||||||
|
|
||||||
|
function reviewPanelMaxWidth(availableWidth?: number): number {
|
||||||
|
if (typeof window === "undefined") return REVIEW_PANEL_DEFAULT_WIDTH
|
||||||
|
const available = availableWidth ?? window.innerWidth
|
||||||
|
return Math.max(
|
||||||
|
REVIEW_PANEL_MIN_WIDTH,
|
||||||
|
available - REVIEW_PANEL_MIN_MAIN_WIDTH
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampReviewPanelWidth(width: number, availableWidth?: number): number {
|
||||||
|
return Math.min(
|
||||||
|
reviewPanelMaxWidth(availableWidth),
|
||||||
|
Math.max(REVIEW_PANEL_MIN_WIDTH, width)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredReviewPanelWidth(): number {
|
||||||
|
if (typeof window === "undefined") return REVIEW_PANEL_DEFAULT_WIDTH
|
||||||
|
const raw = window.localStorage.getItem(REVIEW_PANEL_STORAGE_WIDTH)
|
||||||
|
const parsed = raw ? Number(raw) : NaN
|
||||||
|
if (!Number.isFinite(parsed)) return REVIEW_PANEL_DEFAULT_WIDTH
|
||||||
|
return clampReviewPanelWidth(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewPanelResizeHandle({
|
||||||
|
width,
|
||||||
|
onResize,
|
||||||
|
}: {
|
||||||
|
width: number
|
||||||
|
onResize: (next: number) => void
|
||||||
|
}) {
|
||||||
|
const startRef = useRef<{ x: number; width: number } | null>(null)
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
startRef.current = { x: e.clientX, width }
|
||||||
|
setDragging(true)
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!startRef.current) return
|
||||||
|
onResize(startRef.current.width - (e.clientX - startRef.current.x))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
startRef.current = null
|
||||||
|
setDragging(false)
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragging) return
|
||||||
|
const prev = document.body.style.cursor
|
||||||
|
document.body.style.cursor = "col-resize"
|
||||||
|
return () => {
|
||||||
|
document.body.style.cursor = prev
|
||||||
|
}
|
||||||
|
}, [dragging])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 left-0 z-20 w-1 cursor-col-resize touch-none select-none",
|
||||||
|
"after:absolute after:inset-y-0 after:left-0 after:w-px after:bg-transparent after:transition-colors",
|
||||||
|
"hover:after:bg-border",
|
||||||
|
dragging && "after:bg-border"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SidePanel({
|
function SidePanel({
|
||||||
detail,
|
detail,
|
||||||
tab,
|
tab,
|
||||||
|
|
@ -852,130 +943,162 @@ function SidePanel({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [width, setWidthState] = useState(() => readStoredReviewPanelWidth())
|
||||||
|
const setWidth = useCallback((next: number) => {
|
||||||
|
const available = panelRef.current?.parentElement?.clientWidth
|
||||||
|
const clamped = clampReviewPanelWidth(next, available)
|
||||||
|
setWidthState(clamped)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem(REVIEW_PANEL_STORAGE_WIDTH, String(clamped))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Re-clamp against the real container width on mount and on window resize so
|
||||||
|
// the panel can never squeeze the PR content below its minimum.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const reclamp = () => setWidth(width)
|
||||||
|
reclamp()
|
||||||
|
window.addEventListener("resize", reclamp)
|
||||||
|
return () => window.removeEventListener("resize", reclamp)
|
||||||
|
}, [setWidth, width])
|
||||||
|
|
||||||
const bugs = detail.findings.filter((f) => f.group === "bug")
|
const bugs = detail.findings.filter((f) => f.group === "bug")
|
||||||
const flags = detail.findings.filter((f) => f.group !== "bug")
|
const flags = detail.findings.filter((f) => f.group !== "bug")
|
||||||
const openBugs = bugs.filter((f) => f.status === "open")
|
const openBugs = bugs.filter((f) => f.status === "open")
|
||||||
const openFlags = flags.filter((f) => f.status === "open")
|
const openFlags = flags.filter((f) => f.status === "open")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<div
|
||||||
className={cn(
|
ref={panelRef}
|
||||||
"sticky top-0 hidden h-full w-[420px] shrink-0 flex-col overflow-y-auto border-l border-border transition-opacity xl:flex",
|
style={{ width }}
|
||||||
dimmed && "pointer-events-none opacity-30"
|
className="sticky top-0 hidden h-full shrink-0 xl:flex"
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 border-b border-border px-3 py-2">
|
<ReviewPanelResizeHandle width={width} onResize={setWidth} />
|
||||||
{(
|
<aside
|
||||||
[
|
className={cn(
|
||||||
["info", "Info"],
|
"flex h-full w-full flex-col overflow-y-auto border-l border-border transition-opacity",
|
||||||
["chat", "Chat"],
|
dimmed && "pointer-events-none opacity-30"
|
||||||
] as const
|
)}
|
||||||
).map(([id, label]) => (
|
>
|
||||||
<button
|
<div className="flex items-center gap-1 border-b border-border px-3 py-2">
|
||||||
key={id}
|
{(
|
||||||
type="button"
|
[
|
||||||
onClick={() => onTabChange(id)}
|
["info", "Info"],
|
||||||
className={cn(
|
["chat", "Chat"],
|
||||||
"rounded-md px-2.5 py-1 text-xs transition-colors",
|
] as const
|
||||||
tab === id
|
).map(([id, label]) => (
|
||||||
? "bg-muted font-medium text-foreground"
|
<button
|
||||||
: "text-muted-foreground hover:bg-muted/50"
|
key={id}
|
||||||
)}
|
type="button"
|
||||||
>
|
onClick={() => onTabChange(id)}
|
||||||
{label}
|
className={cn(
|
||||||
</button>
|
"rounded-md px-2.5 py-1 text-xs transition-colors",
|
||||||
))}
|
tab === id
|
||||||
</div>
|
? "bg-muted font-medium text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted/50"
|
||||||
{tab === "chat" ? (
|
|
||||||
<div className="flex flex-1 items-center justify-center p-6 text-xs text-muted-foreground">
|
|
||||||
Coming Soon
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
<section className="px-3 py-3">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="font-medium">
|
|
||||||
{detail.status === "running"
|
|
||||||
? "PR analysis in progress"
|
|
||||||
: detail.status === "error"
|
|
||||||
? "PR analysis failed"
|
|
||||||
: "PR analysis complete"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => reReview.mutate()}
|
|
||||||
disabled={reReview.isPending || detail.status === "running"}
|
|
||||||
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<ArrowClockwiseIcon className="size-3" />
|
|
||||||
Re-review
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 space-y-1 text-[11px] text-muted-foreground">
|
|
||||||
<div>Reviewing commit {detail.head_sha.slice(0, 7) || "—"}</div>
|
|
||||||
{detail.watch && <div>Watching for new pushes</div>}
|
|
||||||
{reReview.error && (
|
|
||||||
<div className="text-destructive">{reReview.error.message}</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
</section>
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<FindingSection
|
{tab === "chat" ? (
|
||||||
icon={BugBeetleIcon}
|
<ReviewChat
|
||||||
label={`${openBugs.length} Bug${openBugs.length === 1 ? "" : "s"}`}
|
owner={detail.owner}
|
||||||
emptyLabel="No bugs found."
|
repo={detail.repo}
|
||||||
findings={bugs}
|
number={detail.number}
|
||||||
read={read}
|
|
||||||
onFindingClick={onFindingClick}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
<FindingSection
|
<div className="divide-y divide-border">
|
||||||
icon={FlagIcon}
|
<section className="px-3 py-3">
|
||||||
label={`${openFlags.length} Flag${openFlags.length === 1 ? "" : "s"}`}
|
<div className="flex items-center justify-between text-xs">
|
||||||
emptyLabel="No issues found."
|
<span className="font-medium">
|
||||||
findings={flags}
|
{detail.status === "running"
|
||||||
read={read}
|
? "PR analysis in progress"
|
||||||
onFindingClick={onFindingClick}
|
: detail.status === "error"
|
||||||
action={
|
? "PR analysis failed"
|
||||||
detail.findings.length > 0 ? (
|
: "PR analysis complete"}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMarkAllRead}
|
onClick={() => reReview.mutate()}
|
||||||
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground"
|
disabled={reReview.isPending || detail.status === "running"}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Mark all as read
|
<ArrowClockwiseIcon className="size-3" />
|
||||||
|
Re-review
|
||||||
</button>
|
</button>
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChecksSection checks={detail.checks} />
|
|
||||||
<PeopleSection
|
|
||||||
title="Reviewers"
|
|
||||||
people={detail.pr.requested_reviewers}
|
|
||||||
/>
|
|
||||||
<PeopleSection title="Assignees" people={detail.pr.assignees} />
|
|
||||||
<section className="px-3 py-3">
|
|
||||||
<h3 className="mb-2 text-xs font-medium">Labels</h3>
|
|
||||||
{detail.pr.labels.length === 0 ? (
|
|
||||||
<p className="text-[11px] text-muted-foreground">None</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{detail.pr.labels.map((label) => (
|
|
||||||
<span
|
|
||||||
key={label.name}
|
|
||||||
className="rounded-full border border-border px-2 py-0.5 text-[11px]"
|
|
||||||
>
|
|
||||||
{label.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mt-2 space-y-1 text-[11px] text-muted-foreground">
|
||||||
</section>
|
<div>Reviewing commit {detail.head_sha.slice(0, 7) || "—"}</div>
|
||||||
</div>
|
{detail.watch && <div>Watching for new pushes</div>}
|
||||||
)}
|
{reReview.error && (
|
||||||
</aside>
|
<div className="text-destructive">
|
||||||
|
{reReview.error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<FindingSection
|
||||||
|
icon={BugBeetleIcon}
|
||||||
|
label={`${openBugs.length} Bug${openBugs.length === 1 ? "" : "s"}`}
|
||||||
|
emptyLabel="No bugs found."
|
||||||
|
findings={bugs}
|
||||||
|
read={read}
|
||||||
|
onFindingClick={onFindingClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FindingSection
|
||||||
|
icon={FlagIcon}
|
||||||
|
label={`${openFlags.length} Flag${openFlags.length === 1 ? "" : "s"}`}
|
||||||
|
emptyLabel="No issues found."
|
||||||
|
findings={flags}
|
||||||
|
read={read}
|
||||||
|
onFindingClick={onFindingClick}
|
||||||
|
action={
|
||||||
|
detail.findings.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMarkAllRead}
|
||||||
|
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChecksSection checks={detail.checks} />
|
||||||
|
<PeopleSection
|
||||||
|
title="Reviewers"
|
||||||
|
people={detail.pr.requested_reviewers}
|
||||||
|
/>
|
||||||
|
<PeopleSection title="Assignees" people={detail.pr.assignees} />
|
||||||
|
<section className="px-3 py-3">
|
||||||
|
<h3 className="mb-2 text-xs font-medium">Labels</h3>
|
||||||
|
{detail.pr.labels.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground">None</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{detail.pr.labels.map((label) => (
|
||||||
|
<span
|
||||||
|
key={label.name}
|
||||||
|
className="rounded-full border border-border px-2 py-0.5 text-[11px]"
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue