mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
feat: PR review page (#1495)
* feat: PR review page * feat: manual review trigger card on admin page * feat: finding focus mode with floating card and dimmed backdrop * fix: dim only info panel, subtle block highlight for focused finding * refactor: move PR reviews into agents page with file-tree sidebar * fix: address review feedback - paginate list_reviews until enough accessible records collected - include rename/metadata-only files in diff API with empty hunks - remount ReviewBody on head_sha change so viewed-files state resets * fix: review tree background + finding card anchoring * fix: remove no-op PR size chip and dead check links * fix: parallel diff fetch, complete reReview type, shared github_headers --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
194e9cd60e
commit
bfcb714c5c
17 changed files with 2506 additions and 156 deletions
448
agent/dashboard/review_api.py
Normal file
448
agent/dashboard/review_api.py
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
"""Read API for the PR review UI.
|
||||
|
||||
Reviewer threads (``metadata.kind == "reviewer"``) hold the durable review
|
||||
state for a PR: identity (``pr``), findings, watch flag, and head SHA. These
|
||||
endpoints surface that state plus live PR details/diff fetched from GitHub
|
||||
with the App installation token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..reviewer_diff import parse_unified_diff
|
||||
from ..reviewer_findings import REVIEWER_THREAD_KIND
|
||||
from ..utils.github_app import get_github_app_installation_token
|
||||
from ..utils.github_checks import github_headers
|
||||
from ..utils.thread_ops import langgraph_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_GITHUB_API = "https://api.github.com"
|
||||
_GITHUB_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
|
||||
|
||||
DiffLineKind = Literal["context", "add", "del"]
|
||||
|
||||
_DIFF_NEW_FILE_RE = re.compile(r"^new file mode", re.MULTILINE)
|
||||
_DIFF_DELETED_FILE_RE = re.compile(r"^deleted file mode", re.MULTILINE)
|
||||
_DIFF_RENAME_RE = re.compile(r"^rename from ", re.MULTILINE)
|
||||
|
||||
|
||||
async def _require_app_token() -> str:
|
||||
token = await get_github_app_installation_token()
|
||||
if not token:
|
||||
raise HTTPException(503, "GitHub App token unavailable")
|
||||
return token
|
||||
|
||||
|
||||
async def _github_get(
|
||||
path: str, token: str, *, accept: str | None = None, params: dict[str, Any] | None = None
|
||||
) -> Any:
|
||||
headers = github_headers(token)
|
||||
if accept:
|
||||
headers["Accept"] = accept
|
||||
async with httpx.AsyncClient(timeout=_GITHUB_TIMEOUT) as client:
|
||||
response = await client.get(f"{_GITHUB_API}{path}", headers=headers, params=params)
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(404, "not found on GitHub")
|
||||
if response.status_code >= 400:
|
||||
logger.warning("GitHub GET %s failed: %s", path, response.status_code)
|
||||
raise HTTPException(502, f"GitHub request failed ({response.status_code})")
|
||||
if accept and "json" not in accept:
|
||||
return response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def reviewer_thread_id(owner: str, repo: str, pr_number: int) -> str:
|
||||
import uuid
|
||||
|
||||
stable_key = f"{owner}/{repo}/pr/{pr_number}/reviewer"
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
|
||||
|
||||
|
||||
def _findings_list(metadata: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
findings = metadata.get("findings")
|
||||
if not isinstance(findings, list):
|
||||
return []
|
||||
return [f for f in findings if isinstance(f, dict) and isinstance(f.get("id"), str)]
|
||||
|
||||
|
||||
def _serialize_finding(finding: dict[str, Any], head_sha: str | None) -> dict[str, Any]:
|
||||
last_confirmed = finding.get("last_confirmed_sha")
|
||||
outdated = bool(
|
||||
head_sha
|
||||
and isinstance(last_confirmed, str)
|
||||
and last_confirmed
|
||||
and last_confirmed != head_sha
|
||||
)
|
||||
interactions = finding.get("interactions")
|
||||
return {
|
||||
"id": finding.get("id"),
|
||||
"severity": finding.get("severity", "low"),
|
||||
"confidence": finding.get("confidence", "medium"),
|
||||
"category": finding.get("category", ""),
|
||||
"title": finding.get("title") or "",
|
||||
"description": finding.get("description", ""),
|
||||
"suggestion": finding.get("suggestion"),
|
||||
"file": finding.get("file", ""),
|
||||
"start_line": finding.get("start_line"),
|
||||
"end_line": finding.get("end_line"),
|
||||
"side": finding.get("side", "RIGHT"),
|
||||
"in_diff": bool(finding.get("in_diff", True)),
|
||||
"status": finding.get("status", "open"),
|
||||
"outdated": outdated,
|
||||
"resolution_note": finding.get("resolution_note"),
|
||||
"diff_hunk": finding.get("diff_hunk"),
|
||||
"github_thread_resolved": bool(finding.get("github_thread_resolved")),
|
||||
"github_review_comment_id": (
|
||||
finding["github_review_comment_id"]
|
||||
if isinstance(finding.get("github_review_comment_id"), int)
|
||||
else None
|
||||
),
|
||||
"interactions": interactions if isinstance(interactions, list) else [],
|
||||
}
|
||||
|
||||
|
||||
_BUG_SEVERITIES = frozenset({"high", "critical"})
|
||||
|
||||
|
||||
def classify_finding(finding: dict[str, Any]) -> Literal["bug", "investigate", "informational"]:
|
||||
"""Map our severity/confidence model onto the UI's Bugs/Flags split."""
|
||||
severity = finding.get("severity", "low")
|
||||
confidence = finding.get("confidence", "medium")
|
||||
if severity in _BUG_SEVERITIES and confidence == "high":
|
||||
return "bug"
|
||||
if severity != "low":
|
||||
return "investigate"
|
||||
return "informational"
|
||||
|
||||
|
||||
def _finding_counts(findings: list[dict[str, Any]]) -> dict[str, int]:
|
||||
counts = {"open": 0, "resolved": 0, "dismissed": 0, "bugs": 0, "flags": 0}
|
||||
for finding in findings:
|
||||
status = finding.get("status", "open")
|
||||
if status in counts:
|
||||
counts[status] += 1
|
||||
if status == "open":
|
||||
if classify_finding(finding) == "bug":
|
||||
counts["bugs"] += 1
|
||||
else:
|
||||
counts["flags"] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def _run_status(thread: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||
if thread.get("status") == "busy":
|
||||
return "running"
|
||||
latest = metadata.get("latest_run_status")
|
||||
if latest in {"pending", "running"}:
|
||||
return "running"
|
||||
if latest in {"error", "failed", "timeout", "interrupted"}:
|
||||
return "error"
|
||||
return "idle"
|
||||
|
||||
|
||||
def _thread_review_summary(thread: dict[str, Any]) -> dict[str, Any] | None:
|
||||
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||
pr = metadata.get("pr")
|
||||
if not isinstance(pr, dict):
|
||||
return None
|
||||
owner = pr.get("owner")
|
||||
name = pr.get("name")
|
||||
number = pr.get("number")
|
||||
if not (isinstance(owner, str) and isinstance(name, str) and isinstance(number, int)):
|
||||
return None
|
||||
findings = _findings_list(metadata)
|
||||
updated_at = thread.get("updated_at")
|
||||
return {
|
||||
"thread_id": thread.get("thread_id"),
|
||||
"owner": owner,
|
||||
"repo": name,
|
||||
"full_name": f"{owner}/{name}",
|
||||
"number": number,
|
||||
"title": pr.get("title") or f"PR #{number}",
|
||||
"url": pr.get("url") or f"https://github.com/{owner}/{name}/pull/{number}",
|
||||
"head_ref": pr.get("head_ref") or "",
|
||||
"base_ref": pr.get("base_ref") or "",
|
||||
"head_sha": metadata.get("head_sha") or "",
|
||||
"watch": bool(metadata.get("watch")),
|
||||
"status": _run_status(thread, metadata),
|
||||
"counts": _finding_counts(findings),
|
||||
"updated_at": updated_at if isinstance(updated_at, str) else None,
|
||||
}
|
||||
|
||||
|
||||
async def list_reviews(
|
||||
limit: int = 100,
|
||||
*,
|
||||
is_accessible: Callable[[dict[str, Any]], Awaitable[bool]] | None = None,
|
||||
page_size: int = 100,
|
||||
max_scan: int = 1000,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List review summaries, newest first.
|
||||
|
||||
When ``is_accessible`` is given, keeps paging through reviewer threads
|
||||
until ``limit`` accessible summaries are collected (or ``max_scan``
|
||||
threads have been examined), so inaccessible records don't crowd
|
||||
accessible ones out of a single fixed-size page.
|
||||
"""
|
||||
client = langgraph_client()
|
||||
summaries: list[dict[str, Any]] = []
|
||||
offset = 0
|
||||
while len(summaries) < limit and offset < max_scan:
|
||||
threads = await client.threads.search(
|
||||
metadata={"kind": REVIEWER_THREAD_KIND},
|
||||
limit=page_size,
|
||||
offset=offset,
|
||||
sort_by="updated_at",
|
||||
sort_order="desc",
|
||||
)
|
||||
if not threads:
|
||||
break
|
||||
for thread in threads:
|
||||
if not isinstance(thread, dict):
|
||||
continue
|
||||
summary = _thread_review_summary(thread)
|
||||
if not summary:
|
||||
continue
|
||||
if is_accessible is not None and not await is_accessible(summary):
|
||||
continue
|
||||
summaries.append(summary)
|
||||
if len(summaries) >= limit:
|
||||
break
|
||||
if len(threads) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
return summaries
|
||||
|
||||
|
||||
def _user_ref(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
login = value.get("login")
|
||||
if not isinstance(login, str):
|
||||
return None
|
||||
return {"login": login, "avatar_url": value.get("avatar_url")}
|
||||
|
||||
|
||||
def _serialize_pr_details(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
labels = payload.get("labels")
|
||||
state = payload.get("state")
|
||||
if payload.get("merged"):
|
||||
state = "merged"
|
||||
elif payload.get("draft"):
|
||||
state = "draft"
|
||||
return {
|
||||
"state": state if isinstance(state, str) else "open",
|
||||
"title": payload.get("title") or "",
|
||||
"body": payload.get("body") or "",
|
||||
"additions": payload.get("additions") or 0,
|
||||
"deletions": payload.get("deletions") or 0,
|
||||
"changed_files": payload.get("changed_files") or 0,
|
||||
"commits": payload.get("commits") or 0,
|
||||
"head_sha": (payload.get("head") or {}).get("sha") or "",
|
||||
"head_ref": (payload.get("head") or {}).get("ref") or "",
|
||||
"base_ref": (payload.get("base") or {}).get("ref") or "",
|
||||
"author": _user_ref(payload.get("user")),
|
||||
"assignees": [
|
||||
user
|
||||
for user in (_user_ref(value) for value in payload.get("assignees") or [])
|
||||
if user is not None
|
||||
],
|
||||
"requested_reviewers": [
|
||||
user
|
||||
for user in (_user_ref(value) for value in payload.get("requested_reviewers") or [])
|
||||
if user is not None
|
||||
],
|
||||
"labels": [
|
||||
{"name": label.get("name"), "color": label.get("color")}
|
||||
for label in (labels if isinstance(labels, list) else [])
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_check_runs(owner: str, repo: str, sha: str, token: str) -> list[dict[str, Any]]:
|
||||
if not sha:
|
||||
return []
|
||||
try:
|
||||
payload = await _github_get(
|
||||
f"/repos/{owner}/{repo}/commits/{sha}/check-runs",
|
||||
token,
|
||||
params={"per_page": 50},
|
||||
)
|
||||
except HTTPException:
|
||||
return []
|
||||
runs = payload.get("check_runs") if isinstance(payload, dict) else None
|
||||
out: list[dict[str, Any]] = []
|
||||
for run in runs if isinstance(runs, list) else []:
|
||||
if not isinstance(run, dict):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"name": run.get("name") or "",
|
||||
"status": run.get("status") or "",
|
||||
"conclusion": run.get("conclusion"),
|
||||
"url": run.get("html_url"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def get_review(owner: str, repo: str, pr_number: int) -> dict[str, Any]:
|
||||
thread_id = reviewer_thread_id(owner, repo, pr_number)
|
||||
client = langgraph_client()
|
||||
try:
|
||||
thread = await client.threads.get(thread_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(404, "review not found") from exc
|
||||
if not isinstance(thread, dict):
|
||||
raise HTTPException(404, "review not found")
|
||||
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||
summary = _thread_review_summary(thread)
|
||||
if not summary:
|
||||
raise HTTPException(404, "review not found")
|
||||
|
||||
token = await _require_app_token()
|
||||
pr_payload = await _github_get(f"/repos/{owner}/{repo}/pulls/{pr_number}", token)
|
||||
details = _serialize_pr_details(pr_payload if isinstance(pr_payload, dict) else {})
|
||||
head_sha = details["head_sha"] or summary["head_sha"]
|
||||
checks = await _fetch_check_runs(owner, repo, head_sha, token)
|
||||
|
||||
findings = [_serialize_finding(finding, head_sha) for finding in _findings_list(metadata)]
|
||||
findings.sort(
|
||||
key=lambda f: (
|
||||
f["status"] != "open",
|
||||
{"bug": 0, "investigate": 1, "informational": 2}[classify_finding(f)],
|
||||
f["file"],
|
||||
f["start_line"] or 0,
|
||||
)
|
||||
)
|
||||
for finding in findings:
|
||||
finding["group"] = classify_finding(finding)
|
||||
|
||||
return {**summary, "pr": details, "checks": checks, "findings": findings}
|
||||
|
||||
|
||||
def parse_diff_files(diff_text: str) -> list[dict[str, Any]]:
|
||||
"""Parse a unified diff into renderable per-file hunk/line records.
|
||||
|
||||
Iterates the raw ``diff --git`` sections so metadata-only changes
|
||||
(pure renames, mode changes) still appear, with empty hunks.
|
||||
"""
|
||||
raw_sections = _split_file_sections(diff_text)
|
||||
parsed_by_file = {file_diff.file: file_diff for file_diff in parse_unified_diff(diff_text)}
|
||||
files: list[dict[str, Any]] = []
|
||||
for path, section in raw_sections.items():
|
||||
status = "modified"
|
||||
if _DIFF_NEW_FILE_RE.search(section):
|
||||
status = "added"
|
||||
elif _DIFF_DELETED_FILE_RE.search(section):
|
||||
status = "deleted"
|
||||
elif _DIFF_RENAME_RE.search(section):
|
||||
status = "renamed"
|
||||
file_diff = parsed_by_file.get(path)
|
||||
hunks = []
|
||||
additions = 0
|
||||
deletions = 0
|
||||
for hunk in file_diff.hunks if file_diff else ():
|
||||
lines: list[dict[str, Any]] = []
|
||||
old_line = hunk.old_start
|
||||
new_line = hunk.new_start
|
||||
body_lines = hunk.body.splitlines()
|
||||
for raw in body_lines[1:]:
|
||||
if raw.startswith("+"):
|
||||
lines.append({"kind": "add", "new_line": new_line, "text": raw[1:]})
|
||||
new_line += 1
|
||||
additions += 1
|
||||
elif raw.startswith("-"):
|
||||
lines.append({"kind": "del", "old_line": old_line, "text": raw[1:]})
|
||||
old_line += 1
|
||||
deletions += 1
|
||||
elif raw.startswith("\\"):
|
||||
continue
|
||||
else:
|
||||
lines.append(
|
||||
{
|
||||
"kind": "context",
|
||||
"old_line": old_line,
|
||||
"new_line": new_line,
|
||||
"text": raw[1:] if raw.startswith(" ") else raw,
|
||||
}
|
||||
)
|
||||
old_line += 1
|
||||
new_line += 1
|
||||
hunks.append(
|
||||
{
|
||||
"header": body_lines[0] if body_lines else "",
|
||||
"old_start": hunk.old_start,
|
||||
"new_start": hunk.new_start,
|
||||
"lines": lines,
|
||||
}
|
||||
)
|
||||
files.append(
|
||||
{
|
||||
"path": path,
|
||||
"status": status,
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
"hunks": hunks,
|
||||
}
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def _split_file_sections(diff_text: str) -> dict[str, str]:
|
||||
sections: dict[str, str] = {}
|
||||
current_file: str | None = None
|
||||
current_lines: list[str] = []
|
||||
header_re = re.compile(r"^diff --git a/(?P<a>.+?) b/(?P<b>.+?)$")
|
||||
for line in diff_text.splitlines():
|
||||
match = header_re.match(line)
|
||||
if match:
|
||||
if current_file is not None:
|
||||
sections[current_file] = "\n".join(current_lines)
|
||||
current_file = match.group("b")
|
||||
current_lines = [line]
|
||||
elif current_file is not None:
|
||||
current_lines.append(line)
|
||||
if current_file is not None:
|
||||
sections[current_file] = "\n".join(current_lines)
|
||||
return sections
|
||||
|
||||
|
||||
async def get_review_diff(owner: str, repo: str, pr_number: int) -> dict[str, Any]:
|
||||
token = await _require_app_token()
|
||||
diff_text = await _github_get(
|
||||
f"/repos/{owner}/{repo}/pulls/{pr_number}",
|
||||
token,
|
||||
accept="application/vnd.github.diff",
|
||||
)
|
||||
files = parse_diff_files(diff_text if isinstance(diff_text, str) else "")
|
||||
return {
|
||||
"files": files,
|
||||
"total_additions": sum(f["additions"] for f in files),
|
||||
"total_deletions": sum(f["deletions"] for f in files),
|
||||
}
|
||||
|
||||
|
||||
async def trigger_re_review(owner: str, repo: str, pr_number: int, login: str) -> dict[str, Any]:
|
||||
from ..utils.slack import GitHubPrRef
|
||||
from ..webapp import trigger_pr_review_from_ref
|
||||
|
||||
pr_ref = GitHubPrRef(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
number=pr_number,
|
||||
url=f"https://github.com/{owner}/{repo}/pull/{pr_number}",
|
||||
)
|
||||
result = await trigger_pr_review_from_ref(pr_ref, source="dashboard", github_login=login)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(502, str(result.get("error") or "could not trigger review"))
|
||||
return result
|
||||
|
|
@ -58,6 +58,12 @@ from .profiles import (
|
|||
upsert_profile,
|
||||
)
|
||||
from .repo_access import require_repo_access_for_user
|
||||
from .review_api import (
|
||||
get_review,
|
||||
get_review_diff,
|
||||
list_reviews,
|
||||
trigger_re_review,
|
||||
)
|
||||
from .review_style_jobs import (
|
||||
cancel_review_style_analysis,
|
||||
start_bootstrap_analysis,
|
||||
|
|
@ -703,6 +709,61 @@ async def api_list_review_styles(
|
|||
return out
|
||||
|
||||
|
||||
@router.get("/reviews")
|
||||
async def api_list_reviews(
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> list[dict[str, Any]]:
|
||||
login = session["sub"]
|
||||
access_cache: dict[str, bool] = {}
|
||||
|
||||
async def is_accessible(summary: dict[str, Any]) -> bool:
|
||||
full_name = summary["full_name"]
|
||||
if full_name not in access_cache:
|
||||
try:
|
||||
await require_repo_access_for_user(login, full_name)
|
||||
access_cache[full_name] = True
|
||||
except HTTPException as exc:
|
||||
if exc.status_code not in {403, 404}:
|
||||
raise
|
||||
access_cache[full_name] = False
|
||||
return access_cache[full_name]
|
||||
|
||||
return await list_reviews(is_accessible=is_accessible)
|
||||
|
||||
|
||||
@router.get("/reviews/{owner}/{repo}/{pr_number}")
|
||||
async def api_get_review(
|
||||
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(owner, repo, pr_number)
|
||||
|
||||
|
||||
@router.get("/reviews/{owner}/{repo}/{pr_number}/diff")
|
||||
async def api_get_review_diff(
|
||||
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_diff(owner, repo, pr_number)
|
||||
|
||||
|
||||
@router.post("/reviews/{owner}/{repo}/{pr_number}/re-review")
|
||||
async def api_re_review(
|
||||
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 trigger_re_review(owner, repo, pr_number, session["sub"])
|
||||
|
||||
|
||||
@router.post("/review-styles")
|
||||
async def api_create_review_style(
|
||||
body: ReviewStyleCreate,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class ReviewStyleSamples:
|
|||
reviews_scanned: int = 0
|
||||
|
||||
|
||||
def _github_headers(token: str) -> dict[str, str]:
|
||||
def github_headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
|
|
@ -157,7 +157,7 @@ async def collect_review_samples(
|
|||
) -> ReviewStyleSamples:
|
||||
"""Sample recent merged PR feedback to identify reviewer style."""
|
||||
full_name = f"{owner}/{repo}"
|
||||
headers = _github_headers(token)
|
||||
headers = github_headers(token)
|
||||
|
||||
raw_entries: list[tuple[str, int, ReviewSample]] = []
|
||||
reviewer_counts: Counter[str] = Counter()
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ _GITHUB_API_BASE = "https://api.github.com"
|
|||
CheckConclusion = Literal["success", "neutral", "failure"]
|
||||
|
||||
|
||||
def _github_headers(token: str) -> dict[str, str]:
|
||||
def github_headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
|
|
@ -67,7 +67,7 @@ async def create_review_check_run(
|
|||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url, headers=_github_headers(token), json=payload, timeout=30
|
||||
url, headers=github_headers(token), json=payload, timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
|
|
@ -105,7 +105,7 @@ async def complete_review_check_run(
|
|||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.patch(
|
||||
url, headers=_github_headers(token), json=payload, timeout=30
|
||||
url, headers=github_headers(token), json=payload, timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
|
|
|
|||
125
tests/test_review_api.py
Normal file
125
tests/test_review_api.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from agent.dashboard.review_api import (
|
||||
_finding_counts,
|
||||
_serialize_finding,
|
||||
_thread_review_summary,
|
||||
classify_finding,
|
||||
parse_diff_files,
|
||||
reviewer_thread_id,
|
||||
)
|
||||
from agent.webapp import generate_reviewer_thread_id
|
||||
|
||||
DIFF = """\
|
||||
diff --git a/src/app.py b/src/app.py
|
||||
index 111..222 100644
|
||||
--- a/src/app.py
|
||||
+++ b/src/app.py
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
-x = 1
|
||||
+x = 2
|
||||
+y = 3
|
||||
print(x)
|
||||
diff --git a/new.txt b/new.txt
|
||||
new file mode 100644
|
||||
index 000..333
|
||||
--- /dev/null
|
||||
+++ b/new.txt
|
||||
@@ -0,0 +1,2 @@
|
||||
+hello
|
||||
+world
|
||||
diff --git a/gone.txt b/gone.txt
|
||||
deleted file mode 100644
|
||||
index 444..000
|
||||
--- a/gone.txt
|
||||
+++ /dev/null
|
||||
@@ -1,1 +0,0 @@
|
||||
-bye
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_diff_files_statuses_and_counts():
|
||||
files = parse_diff_files(DIFF)
|
||||
by_path = {f["path"]: f for f in files}
|
||||
assert by_path["src/app.py"]["status"] == "modified"
|
||||
assert by_path["src/app.py"]["additions"] == 2
|
||||
assert by_path["src/app.py"]["deletions"] == 1
|
||||
assert by_path["new.txt"]["status"] == "added"
|
||||
assert by_path["new.txt"]["additions"] == 2
|
||||
assert by_path["gone.txt"]["status"] == "deleted"
|
||||
assert by_path["gone.txt"]["deletions"] == 1
|
||||
|
||||
|
||||
def test_parse_diff_files_line_numbers():
|
||||
files = parse_diff_files(DIFF)
|
||||
app = next(f for f in files if f["path"] == "src/app.py")
|
||||
lines = app["hunks"][0]["lines"]
|
||||
assert lines[0] == {"kind": "context", "old_line": 1, "new_line": 1, "text": "import os"}
|
||||
assert lines[1] == {"kind": "del", "old_line": 2, "text": "x = 1"}
|
||||
assert lines[2] == {"kind": "add", "new_line": 2, "text": "x = 2"}
|
||||
assert lines[3] == {"kind": "add", "new_line": 3, "text": "y = 3"}
|
||||
assert lines[4] == {"kind": "context", "old_line": 3, "new_line": 4, "text": "print(x)"}
|
||||
|
||||
|
||||
def test_classify_finding():
|
||||
assert classify_finding({"severity": "critical", "confidence": "high"}) == "bug"
|
||||
assert classify_finding({"severity": "high", "confidence": "high"}) == "bug"
|
||||
assert classify_finding({"severity": "high", "confidence": "medium"}) == "investigate"
|
||||
assert classify_finding({"severity": "medium", "confidence": "high"}) == "investigate"
|
||||
assert classify_finding({"severity": "low", "confidence": "high"}) == "informational"
|
||||
|
||||
|
||||
def test_finding_counts_only_open_in_groups():
|
||||
findings = [
|
||||
{"id": "f_1", "severity": "high", "confidence": "high", "status": "open"},
|
||||
{"id": "f_2", "severity": "medium", "confidence": "high", "status": "open"},
|
||||
{"id": "f_3", "severity": "high", "confidence": "high", "status": "resolved"},
|
||||
{"id": "f_4", "severity": "low", "confidence": "low", "status": "dismissed"},
|
||||
]
|
||||
counts = _finding_counts(findings)
|
||||
assert counts == {"open": 2, "resolved": 1, "dismissed": 1, "bugs": 1, "flags": 1}
|
||||
|
||||
|
||||
def test_serialize_finding_outdated():
|
||||
finding = {"id": "f_1", "last_confirmed_sha": "aaa"}
|
||||
assert _serialize_finding(finding, "bbb")["outdated"] is True
|
||||
assert _serialize_finding(finding, "aaa")["outdated"] is False
|
||||
assert _serialize_finding(finding, None)["outdated"] is False
|
||||
assert _serialize_finding({"id": "f_2"}, "bbb")["outdated"] is False
|
||||
|
||||
|
||||
def test_thread_review_summary():
|
||||
thread = {
|
||||
"thread_id": "t1",
|
||||
"status": "idle",
|
||||
"updated_at": "2026-06-10T00:00:00Z",
|
||||
"metadata": {
|
||||
"kind": "reviewer",
|
||||
"pr": {
|
||||
"owner": "acme",
|
||||
"name": "repo",
|
||||
"number": 7,
|
||||
"title": "Fix things",
|
||||
"head_ref": "fix",
|
||||
"base_ref": "main",
|
||||
},
|
||||
"head_sha": "abc",
|
||||
"watch": True,
|
||||
"latest_run_status": "success",
|
||||
"findings": [{"id": "f_1", "severity": "high", "confidence": "high", "status": "open"}],
|
||||
},
|
||||
}
|
||||
summary = _thread_review_summary(thread)
|
||||
assert summary is not None
|
||||
assert summary["owner"] == "acme"
|
||||
assert summary["number"] == 7
|
||||
assert summary["status"] == "idle"
|
||||
assert summary["watch"] is True
|
||||
assert summary["counts"]["bugs"] == 1
|
||||
|
||||
|
||||
def test_thread_review_summary_requires_pr_meta():
|
||||
assert _thread_review_summary({"metadata": {"kind": "reviewer"}}) is None
|
||||
|
||||
|
||||
def test_reviewer_thread_id_matches_webapp():
|
||||
assert reviewer_thread_id("acme", "repo", 7) == generate_reviewer_thread_id("acme", "repo", 7)
|
||||
6
ui/public/gh-open-in-open-swe-dark.svg
Normal file
6
ui/public/gh-open-in-open-swe-dark.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="158" height="28" fill="none" viewBox="0 0 158 28">
|
||||
<rect width="158" height="28" fill="#0d1117" rx="6"/>
|
||||
<rect width="157" height="27" x=".5" y=".5" stroke="#f0f6fc" stroke-opacity=".14" rx="5.5"/>
|
||||
<g transform="translate(8 6) scale(0.125)" fill="#fff"><path d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/><path d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/><path d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/><path d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/></g>
|
||||
<text x="30" y="18.5" fill="#f0f6fc" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" font-size="12" font-weight="500">Open in Open SWE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
6
ui/public/gh-open-in-open-swe-light.svg
Normal file
6
ui/public/gh-open-in-open-swe-light.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="158" height="28" fill="none" viewBox="0 0 158 28">
|
||||
<rect width="158" height="28" fill="#fff" rx="6"/>
|
||||
<rect width="157" height="27" x=".5" y=".5" stroke="#171c24" stroke-opacity=".14" rx="5.5"/>
|
||||
<g transform="translate(8 6) scale(0.125)" fill="#151b23"><path d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/><path d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/><path d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/><path d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/></g>
|
||||
<text x="30" y="18.5" fill="#151b23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" font-size="12" font-weight="500">Open in Open SWE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
import { Link } from "@tanstack/react-router";
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import {
|
||||
IoArrowBackOutline,
|
||||
IoCloudOutline,
|
||||
|
|
@ -6,25 +6,25 @@ import {
|
|||
IoOptionsOutline,
|
||||
IoSettingsOutline,
|
||||
IoStatsChartOutline,
|
||||
} from "react-icons/io5";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
} from "react-icons/io5"
|
||||
import type { ComponentType, SVGProps } from "react"
|
||||
|
||||
import type { SessionUser } from "@/lib/api";
|
||||
import { SidebarUserMenu } from "@/components/SidebarUserMenu";
|
||||
import type { SessionUser } from "@/lib/api"
|
||||
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
|
||||
import {
|
||||
SidebarCollapseButton,
|
||||
SidebarFrame,
|
||||
useSidebarLayout,
|
||||
} from "@/components/sidebar-layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from "@/components/sidebar-layout"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type IconType = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
type IconType = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: IconType;
|
||||
adminOnly?: boolean;
|
||||
to: string
|
||||
label: string
|
||||
icon: IconType
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
const NAV: Array<NavItem> = [
|
||||
|
|
@ -33,12 +33,15 @@ const NAV: Array<NavItem> = [
|
|||
{ to: "/review", label: "Open SWE Review", icon: IoGitPullRequestOutline },
|
||||
{ to: "/usage", label: "Usage", icon: IoStatsChartOutline },
|
||||
{ to: "/admin", label: "Admin", icon: IoSettingsOutline, adminOnly: true },
|
||||
];
|
||||
]
|
||||
|
||||
export function AppSidebar({ user }: { user: SessionUser }) {
|
||||
const layout = useSidebarLayout();
|
||||
const layout = useSidebarLayout()
|
||||
return (
|
||||
<SidebarFrame {...layout} className="border-r border-border bg-sidebar text-sidebar-foreground">
|
||||
<SidebarFrame
|
||||
{...layout}
|
||||
className="border-r border-border bg-sidebar text-sidebar-foreground"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 pt-5 pb-4">
|
||||
<Link
|
||||
to="/my-settings"
|
||||
|
|
@ -56,14 +59,14 @@ export function AppSidebar({ user }: { user: SessionUser }) {
|
|||
onClick={layout.closeOnMobile}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs/relaxed text-muted-foreground transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<IoArrowBackOutline className="size-4" />
|
||||
<span>Back to Agents</span>
|
||||
</Link>
|
||||
{NAV.filter((n) => !n.adminOnly || user.is_admin).map((item) => {
|
||||
const Icon = item.icon;
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
|
|
@ -71,16 +74,17 @@ export function AppSidebar({ user }: { user: SessionUser }) {
|
|||
onClick={layout.closeOnMobile}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs/relaxed text-muted-foreground transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium",
|
||||
className:
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground font-medium",
|
||||
}}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
|
|
@ -88,5 +92,5 @@ export function AppSidebar({ user }: { user: SessionUser }) {
|
|||
<SidebarUserMenu user={user} />
|
||||
</div>
|
||||
</SidebarFrame>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ function PanelResizeHandle({
|
|||
)
|
||||
}
|
||||
|
||||
function treeThemeStyle(): React.CSSProperties {
|
||||
export function treeThemeStyle(): React.CSSProperties {
|
||||
return {
|
||||
"--trees-theme-sidebar-bg": "var(--ui-surface)",
|
||||
"--trees-theme-sidebar-fg": "var(--ui-text)",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ import type { ComponentType, SVGProps } from "react"
|
|||
import type { SessionUser } from "@/lib/api"
|
||||
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
||||
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
|
||||
import {
|
||||
ReviewFileTree,
|
||||
ReviewSidebarProvider,
|
||||
useReviewSidebarData,
|
||||
} from "@/components/agents/ReviewSidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
SidebarCollapseButton,
|
||||
|
|
@ -84,6 +89,7 @@ interface AgentsSidebarProps {
|
|||
const NAV = [
|
||||
{ to: "/agents/automations", label: "Automations", icon: LightningIcon },
|
||||
{ to: "/my-settings", label: "Dashboard", icon: ChartLineUpIcon },
|
||||
{ to: "/agents/reviews", label: "Reviews", icon: GitPullRequestIcon },
|
||||
] as const
|
||||
|
||||
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||
|
|
@ -92,6 +98,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
|||
useSeedAgentThreadDetails(threads, activeThreadId)
|
||||
const groups = groupThreads(threads)
|
||||
const layout = useSidebarLayout()
|
||||
const reviewSidebar = useReviewSidebarData()
|
||||
|
||||
return (
|
||||
<SidebarFrame
|
||||
|
|
@ -141,34 +148,38 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
|||
})}
|
||||
</nav>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
<ThreadGroup
|
||||
label="Today"
|
||||
threads={groups.today}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
/>
|
||||
<ThreadGroup
|
||||
label="Last 7 days"
|
||||
threads={groups.last7}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
defaultCollapsed
|
||||
/>
|
||||
<ThreadGroup
|
||||
label="Last 30 days"
|
||||
threads={groups.last30}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
defaultCollapsed
|
||||
/>
|
||||
<ThreadGroup
|
||||
label="Older"
|
||||
threads={groups.older}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
/>
|
||||
</div>
|
||||
{reviewSidebar ? (
|
||||
<ReviewFileTree data={reviewSidebar} />
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
<ThreadGroup
|
||||
label="Today"
|
||||
threads={groups.today}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
/>
|
||||
<ThreadGroup
|
||||
label="Last 7 days"
|
||||
threads={groups.last7}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
defaultCollapsed
|
||||
/>
|
||||
<ThreadGroup
|
||||
label="Last 30 days"
|
||||
threads={groups.last30}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
defaultCollapsed
|
||||
/>
|
||||
<ThreadGroup
|
||||
label="Older"
|
||||
threads={groups.older}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-2">
|
||||
<SidebarUserMenu user={user} showSettingsLink />
|
||||
|
|
@ -410,9 +421,11 @@ export function AgentsShell({
|
|||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
|
||||
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
|
||||
<div className="flex min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
<ReviewSidebarProvider>
|
||||
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
|
||||
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
|
||||
<div className="flex min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
</ReviewSidebarProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
131
ui/src/components/agents/ReviewSidebar.tsx
Normal file
131
ui/src/components/agents/ReviewSidebar.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { createContext, useContext, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
FileTree,
|
||||
useFileTree,
|
||||
useFileTreeSelection,
|
||||
} from "@pierre/trees/react"
|
||||
|
||||
import type { GitStatusEntry } from "@pierre/trees"
|
||||
import type { ReviewDiffFile } from "@/lib/api"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { treeThemeStyle } from "@/components/agents/AgentGitPanel"
|
||||
|
||||
export interface ReviewSidebarData {
|
||||
title: string
|
||||
files: Array<ReviewDiffFile> | null
|
||||
selected: string | null
|
||||
viewed: Set<string>
|
||||
onSelect: (path: string) => void
|
||||
}
|
||||
|
||||
const ReviewSidebarContext = createContext<{
|
||||
data: ReviewSidebarData | null
|
||||
setData: (data: ReviewSidebarData | null) => void
|
||||
} | null>(null)
|
||||
|
||||
export function ReviewSidebarProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [data, setData] = useState<ReviewSidebarData | null>(null)
|
||||
const value = useMemo(() => ({ data, setData }), [data])
|
||||
return (
|
||||
<ReviewSidebarContext.Provider value={value}>
|
||||
{children}
|
||||
</ReviewSidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useReviewSidebarData(): ReviewSidebarData | null {
|
||||
return useContext(ReviewSidebarContext)?.data ?? null
|
||||
}
|
||||
|
||||
export function useRegisterReviewSidebar(data: ReviewSidebarData) {
|
||||
const setData = useContext(ReviewSidebarContext)?.setData
|
||||
useEffect(() => {
|
||||
if (!setData) return
|
||||
setData(data)
|
||||
return () => setData(null)
|
||||
}, [setData, data])
|
||||
}
|
||||
|
||||
export function ReviewFileTree({ data }: { data: ReviewSidebarData }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col pb-2">
|
||||
<div className="px-4 py-1 text-[10px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase">
|
||||
{data.title}
|
||||
</div>
|
||||
{!data.files ? (
|
||||
<div className="px-4 pt-1">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ReviewFileTreeExplorer
|
||||
files={data.files}
|
||||
selected={data.selected}
|
||||
onSelect={data.onSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewFileTreeExplorer({
|
||||
files,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
files: Array<ReviewDiffFile>
|
||||
selected: string | null
|
||||
onSelect: (path: string) => void
|
||||
}) {
|
||||
const paths = useMemo(() => files.map((file) => file.path), [files])
|
||||
const gitStatus = useMemo<Array<GitStatusEntry>>(
|
||||
() => files.map((file) => ({ path: file.path, status: file.status })),
|
||||
[files]
|
||||
)
|
||||
|
||||
const { model } = useFileTree({
|
||||
paths,
|
||||
gitStatus,
|
||||
initialExpansion: "open",
|
||||
flattenEmptyDirectories: true,
|
||||
icons: "standard",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
model.resetPaths(paths)
|
||||
}, [model, paths])
|
||||
|
||||
useEffect(() => {
|
||||
model.setGitStatus(gitStatus)
|
||||
}, [model, gitStatus])
|
||||
|
||||
const selection = useFileTreeSelection(model)
|
||||
useEffect(() => {
|
||||
const path = selection[0]
|
||||
if (path) onSelect(path)
|
||||
}, [selection, onSelect])
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
model.scrollToPath(selected, { focus: false })
|
||||
}
|
||||
}, [model, selected])
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1">
|
||||
<FileTree
|
||||
model={model}
|
||||
style={
|
||||
{
|
||||
height: "100%",
|
||||
...treeThemeStyle(),
|
||||
"--trees-theme-sidebar-bg": "transparent",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -259,6 +259,129 @@ export interface AgentInstructions {
|
|||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type FindingSeverity = "low" | "medium" | "high" | "critical";
|
||||
export type FindingConfidence = "low" | "medium" | "high";
|
||||
export type FindingStatus = "open" | "resolved" | "dismissed";
|
||||
export type FindingGroup = "bug" | "investigate" | "informational";
|
||||
|
||||
export interface FindingInteraction {
|
||||
kind: "human_reply" | "bot_reply";
|
||||
author?: string;
|
||||
body?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ReviewFinding {
|
||||
id: string;
|
||||
severity: FindingSeverity;
|
||||
confidence: FindingConfidence;
|
||||
category: string;
|
||||
title: string;
|
||||
description: string;
|
||||
suggestion: string | null;
|
||||
file: string;
|
||||
start_line: number | null;
|
||||
end_line: number | null;
|
||||
side: "LEFT" | "RIGHT";
|
||||
in_diff: boolean;
|
||||
status: FindingStatus;
|
||||
outdated: boolean;
|
||||
resolution_note: string | null;
|
||||
diff_hunk: string | null;
|
||||
github_thread_resolved: boolean;
|
||||
github_review_comment_id: number | null;
|
||||
interactions: Array<FindingInteraction>;
|
||||
group: FindingGroup;
|
||||
}
|
||||
|
||||
export interface ReviewCounts {
|
||||
open: number;
|
||||
resolved: number;
|
||||
dismissed: number;
|
||||
bugs: number;
|
||||
flags: number;
|
||||
}
|
||||
|
||||
export interface ReviewSummary {
|
||||
thread_id: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
head_ref: string;
|
||||
base_ref: string;
|
||||
head_sha: string;
|
||||
watch: boolean;
|
||||
status: "running" | "error" | "idle";
|
||||
counts: ReviewCounts;
|
||||
updated_at: string | null;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
export interface ReviewUserRef {
|
||||
login: string;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export interface ReviewCheckRun {
|
||||
name: string;
|
||||
status: string;
|
||||
conclusion: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface ReviewPrDetails {
|
||||
state: string;
|
||||
title: string;
|
||||
body: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changed_files: number;
|
||||
commits: number;
|
||||
head_sha: string;
|
||||
head_ref: string;
|
||||
base_ref: string;
|
||||
author: ReviewUserRef | null;
|
||||
assignees: Array<ReviewUserRef>;
|
||||
requested_reviewers: Array<ReviewUserRef>;
|
||||
labels: Array<{ name: string; color: string | null }>;
|
||||
}
|
||||
|
||||
export interface ReviewDetail extends ReviewSummary {
|
||||
pr: ReviewPrDetails;
|
||||
checks: Array<ReviewCheckRun>;
|
||||
findings: Array<ReviewFinding>;
|
||||
}
|
||||
|
||||
export interface ReviewDiffLine {
|
||||
kind: "context" | "add" | "del";
|
||||
old_line?: number;
|
||||
new_line?: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ReviewDiffHunk {
|
||||
header: string;
|
||||
old_start: number;
|
||||
new_start: number;
|
||||
lines: Array<ReviewDiffLine>;
|
||||
}
|
||||
|
||||
export interface ReviewDiffFile {
|
||||
path: string;
|
||||
status: "added" | "deleted" | "modified" | "renamed";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
hunks: Array<ReviewDiffHunk>;
|
||||
}
|
||||
|
||||
export interface ReviewDiffPayload {
|
||||
files: Array<ReviewDiffFile>;
|
||||
total_additions: number;
|
||||
total_deletions: number;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<SessionUser>("/me"),
|
||||
options: () => request<OptionsPayload>("/options"),
|
||||
|
|
@ -347,6 +470,25 @@ export const api = {
|
|||
`/admin/user-mappings/${encodeURIComponent(github_login)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
listReviews: () => request<Array<ReviewSummary>>("/reviews"),
|
||||
getReview: (owner: string, repo: string, number: number) =>
|
||||
request<ReviewDetail>(
|
||||
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}`,
|
||||
),
|
||||
getReviewDiff: (owner: string, repo: string, number: number) =>
|
||||
request<ReviewDiffPayload>(
|
||||
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/diff`,
|
||||
),
|
||||
reReview: (owner: string, repo: string, number: number) =>
|
||||
request<{
|
||||
success: boolean;
|
||||
queued: boolean;
|
||||
thread_id: string;
|
||||
pr_url: string;
|
||||
}>(
|
||||
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/re-review`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
|||
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
|
||||
import { Route as AgentsInstructionsRouteImport } from './routes/agents_.instructions'
|
||||
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
|
||||
import { Route as AgentsReviewsIndexRouteImport } from './routes/agents/reviews/index'
|
||||
import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index'
|
||||
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
|
||||
import { Route as AgentsAutomationsNewRouteImport } from './routes/agents/automations/new'
|
||||
import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId'
|
||||
import { Route as AgentsReviewsOwnerRepoNumberRouteImport } from './routes/agents/reviews/$owner.$repo.$number'
|
||||
|
||||
const UsageRoute = UsageRouteImport.update({
|
||||
id: '/usage',
|
||||
|
|
@ -92,6 +94,11 @@ const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
|
|||
path: '/$threadId',
|
||||
getParentRoute: () => AgentsRoute,
|
||||
} as any)
|
||||
const AgentsReviewsIndexRoute = AgentsReviewsIndexRouteImport.update({
|
||||
id: '/reviews/',
|
||||
path: '/reviews/',
|
||||
getParentRoute: () => AgentsRoute,
|
||||
} as any)
|
||||
const AgentsAutomationsIndexRoute = AgentsAutomationsIndexRouteImport.update({
|
||||
id: '/automations/',
|
||||
path: '/automations/',
|
||||
|
|
@ -113,6 +120,12 @@ const AgentsAutomationsScheduleIdRoute =
|
|||
path: '/automations/$scheduleId',
|
||||
getParentRoute: () => AgentsRoute,
|
||||
} as any)
|
||||
const AgentsReviewsOwnerRepoNumberRoute =
|
||||
AgentsReviewsOwnerRepoNumberRouteImport.update({
|
||||
id: '/reviews/$owner/$repo/$number',
|
||||
path: '/reviews/$owner/$repo/$number',
|
||||
getParentRoute: () => AgentsRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
|
|
@ -132,6 +145,8 @@ export interface FileRoutesByFullPath {
|
|||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||
'/agents/automations/': typeof AgentsAutomationsIndexRoute
|
||||
'/agents/reviews/': typeof AgentsReviewsIndexRoute
|
||||
'/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
|
|
@ -150,6 +165,8 @@ export interface FileRoutesByTo {
|
|||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||
'/agents/automations': typeof AgentsAutomationsIndexRoute
|
||||
'/agents/reviews': typeof AgentsReviewsIndexRoute
|
||||
'/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
|
|
@ -170,6 +187,8 @@ export interface FileRoutesById {
|
|||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||
'/agents/automations/': typeof AgentsAutomationsIndexRoute
|
||||
'/agents/reviews/': typeof AgentsReviewsIndexRoute
|
||||
'/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
|
|
@ -191,6 +210,8 @@ export interface FileRouteTypes {
|
|||
| '/agents/automations/new'
|
||||
| '/review/repositories/$owner'
|
||||
| '/agents/automations/'
|
||||
| '/agents/reviews/'
|
||||
| '/agents/reviews/$owner/$repo/$number'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
|
|
@ -209,6 +230,8 @@ export interface FileRouteTypes {
|
|||
| '/agents/automations/new'
|
||||
| '/review/repositories/$owner'
|
||||
| '/agents/automations'
|
||||
| '/agents/reviews'
|
||||
| '/agents/reviews/$owner/$repo/$number'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
|
|
@ -228,6 +251,8 @@ export interface FileRouteTypes {
|
|||
| '/agents/automations/new'
|
||||
| '/review_/repositories/$owner'
|
||||
| '/agents/automations/'
|
||||
| '/agents/reviews/'
|
||||
| '/agents/reviews/$owner/$repo/$number'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
|
|
@ -338,6 +363,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AgentsThreadIdRouteImport
|
||||
parentRoute: typeof AgentsRoute
|
||||
}
|
||||
'/agents/reviews/': {
|
||||
id: '/agents/reviews/'
|
||||
path: '/reviews'
|
||||
fullPath: '/agents/reviews/'
|
||||
preLoaderRoute: typeof AgentsReviewsIndexRouteImport
|
||||
parentRoute: typeof AgentsRoute
|
||||
}
|
||||
'/agents/automations/': {
|
||||
id: '/agents/automations/'
|
||||
path: '/automations'
|
||||
|
|
@ -366,6 +398,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AgentsAutomationsScheduleIdRouteImport
|
||||
parentRoute: typeof AgentsRoute
|
||||
}
|
||||
'/agents/reviews/$owner/$repo/$number': {
|
||||
id: '/agents/reviews/$owner/$repo/$number'
|
||||
path: '/reviews/$owner/$repo/$number'
|
||||
fullPath: '/agents/reviews/$owner/$repo/$number'
|
||||
preLoaderRoute: typeof AgentsReviewsOwnerRepoNumberRouteImport
|
||||
parentRoute: typeof AgentsRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -375,6 +414,8 @@ interface AgentsRouteChildren {
|
|||
AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute
|
||||
AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute
|
||||
AgentsAutomationsIndexRoute: typeof AgentsAutomationsIndexRoute
|
||||
AgentsReviewsIndexRoute: typeof AgentsReviewsIndexRoute
|
||||
AgentsReviewsOwnerRepoNumberRoute: typeof AgentsReviewsOwnerRepoNumberRoute
|
||||
}
|
||||
|
||||
const AgentsRouteChildren: AgentsRouteChildren = {
|
||||
|
|
@ -383,6 +424,8 @@ const AgentsRouteChildren: AgentsRouteChildren = {
|
|||
AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute,
|
||||
AgentsAutomationsNewRoute: AgentsAutomationsNewRoute,
|
||||
AgentsAutomationsIndexRoute: AgentsAutomationsIndexRoute,
|
||||
AgentsReviewsIndexRoute: AgentsReviewsIndexRoute,
|
||||
AgentsReviewsOwnerRepoNumberRoute: AgentsReviewsOwnerRepoNumberRoute,
|
||||
}
|
||||
|
||||
const AgentsRouteWithChildren =
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, Navigate, createFileRoute } from "@tanstack/react-router"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
import type {
|
||||
DatadogConnectBody,
|
||||
|
|
@ -8,41 +8,41 @@ import type {
|
|||
ModelOption,
|
||||
TeamSettings,
|
||||
UserMapping,
|
||||
} from "@/lib/api";
|
||||
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
} from "@/lib/api"
|
||||
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
} from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { api } from "@/lib/api"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
||||
export const Route = createFileRoute("/admin")({ component: AdminPage });
|
||||
export const Route = createFileRoute("/admin")({ component: AdminPage })
|
||||
|
||||
function AdminPage() {
|
||||
const session = useSession();
|
||||
const session = useSession()
|
||||
|
||||
const options = useQuery({
|
||||
queryKey: ["options"],
|
||||
queryFn: api.options,
|
||||
enabled: !!session.data?.is_admin,
|
||||
});
|
||||
})
|
||||
|
||||
if (session.isLoading) {
|
||||
return (
|
||||
<main className="p-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</main>
|
||||
);
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />;
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
|
|
@ -52,41 +52,129 @@ function AdminPage() {
|
|||
>
|
||||
<GlobalDefaultsSection models={options.data?.models ?? []} />
|
||||
|
||||
<TriggerReviewSection />
|
||||
|
||||
<ObservabilityCredentialsSection />
|
||||
|
||||
<UserMappingsSection enabled={!!session.data.is_admin} />
|
||||
</AppShell>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PR_URL_RE = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/
|
||||
|
||||
function TriggerReviewSection() {
|
||||
const [url, setUrl] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
const match = PR_URL_RE.exec(url.trim())
|
||||
if (!match) return null
|
||||
const [, owner, repo, number] = match
|
||||
if (!owner || !repo || !number) return null
|
||||
return { owner, repo, number: Number(number) }
|
||||
}, [url])
|
||||
|
||||
const trigger = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!parsed) throw new Error("invalid PR URL")
|
||||
return api.reReview(parsed.owner, parsed.repo, parsed.number)
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setError(null)
|
||||
setMessage(
|
||||
result.queued
|
||||
? "Review queued — a run is already in progress on this PR."
|
||||
: "Review started."
|
||||
)
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
setMessage(null)
|
||||
setError(e.message)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Trigger a review"
|
||||
description="Manually start an Open SWE Review run on a pull request. The repository must be enabled for review."
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="https://github.com/owner/repo/pull/123"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value)
|
||||
setMessage(null)
|
||||
setError(null)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => trigger.mutate()}
|
||||
disabled={!parsed || trigger.isPending}
|
||||
>
|
||||
{trigger.isPending ? "Starting…" : "Start review"}
|
||||
</Button>
|
||||
</div>
|
||||
{url.trim() && !parsed && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter a full PR URL like https://github.com/owner/repo/pull/123
|
||||
</p>
|
||||
)}
|
||||
{message && parsed && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{message}{" "}
|
||||
<Link
|
||||
to="/agents/reviews/$owner/$repo/$number"
|
||||
params={{
|
||||
owner: parsed.owner,
|
||||
repo: parsed.repo,
|
||||
number: String(parsed.number),
|
||||
}}
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
View review
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const mappings = useQuery({
|
||||
queryKey: ["adminUserMappings", page],
|
||||
queryFn: () => api.adminListUserMappings(page, PAGE_SIZE),
|
||||
enabled,
|
||||
});
|
||||
})
|
||||
|
||||
const total = mappings.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const total = mappings.data?.total ?? 0
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
useEffect(() => {
|
||||
if (!mappings.isFetching && page > pageCount) {
|
||||
setPage(pageCount);
|
||||
setPage(pageCount)
|
||||
}
|
||||
}, [mappings.isFetching, page, pageCount]);
|
||||
}, [mappings.isFetching, page, pageCount])
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (gh: string) => api.adminDeleteUserMapping(gh),
|
||||
onSuccess: () => void mappings.refetch(),
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
})
|
||||
|
||||
const items = mappings.data?.items ?? [];
|
||||
const items = mappings.data?.items ?? []
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
|
|
@ -131,7 +219,8 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
|||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{total} mapping{total === 1 ? "" : "s"} · page {page} of {pageCount}
|
||||
{total} mapping{total === 1 ? "" : "s"} · page {page} of{" "}
|
||||
{pageCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
|
@ -155,60 +244,62 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
|||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ObservabilityCredentialsSection() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
const creds = useQuery({
|
||||
queryKey: ["teamCredentials"],
|
||||
queryFn: api.getTeamCredentials,
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [ddSite, setDdSite] = useState("datadoghq.com");
|
||||
const [ddApiKey, setDdApiKey] = useState("");
|
||||
const [ddAppKey, setDdAppKey] = useState("");
|
||||
const [lsApiKey, setLsApiKey] = useState("");
|
||||
const [lsEndpoint, setLsEndpoint] = useState("");
|
||||
const [ddSite, setDdSite] = useState("datadoghq.com")
|
||||
const [ddApiKey, setDdApiKey] = useState("")
|
||||
const [ddAppKey, setDdAppKey] = useState("")
|
||||
const [lsApiKey, setLsApiKey] = useState("")
|
||||
const [lsEndpoint, setLsEndpoint] = useState("")
|
||||
|
||||
const onError = (e: Error) => setError(e.message);
|
||||
const onSuccess = (saved: Awaited<ReturnType<typeof api.getTeamCredentials>>) => {
|
||||
qc.setQueryData(["teamCredentials"], saved);
|
||||
setError(null);
|
||||
};
|
||||
const onError = (e: Error) => setError(e.message)
|
||||
const onSuccess = (
|
||||
saved: Awaited<ReturnType<typeof api.getTeamCredentials>>
|
||||
) => {
|
||||
qc.setQueryData(["teamCredentials"], saved)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const connectDd = useMutation({
|
||||
mutationFn: (body: DatadogConnectBody) => api.connectDatadog(body),
|
||||
onSuccess: (saved) => {
|
||||
onSuccess(saved);
|
||||
setDdApiKey("");
|
||||
setDdAppKey("");
|
||||
onSuccess(saved)
|
||||
setDdApiKey("")
|
||||
setDdAppKey("")
|
||||
},
|
||||
onError,
|
||||
});
|
||||
})
|
||||
const disconnectDd = useMutation({
|
||||
mutationFn: () => api.disconnectDatadog(),
|
||||
onSuccess,
|
||||
onError,
|
||||
});
|
||||
})
|
||||
const connectLs = useMutation({
|
||||
mutationFn: (body: LangSmithConnectBody) => api.connectLangSmith(body),
|
||||
onSuccess: (saved) => {
|
||||
onSuccess(saved);
|
||||
setLsApiKey("");
|
||||
onSuccess(saved)
|
||||
setLsApiKey("")
|
||||
},
|
||||
onError,
|
||||
});
|
||||
})
|
||||
const disconnectLs = useMutation({
|
||||
mutationFn: () => api.disconnectLangSmith(),
|
||||
onSuccess,
|
||||
onError,
|
||||
});
|
||||
})
|
||||
|
||||
const datadog = creds.data?.datadog;
|
||||
const langsmith = creds.data?.langsmith;
|
||||
const busy = creds.isLoading;
|
||||
const datadog = creds.data?.datadog
|
||||
const langsmith = creds.data?.langsmith
|
||||
const busy = creds.isLoading
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
|
|
@ -333,30 +424,30 @@ function ObservabilityCredentialsSection() {
|
|||
</div>
|
||||
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
|
||||
</SettingsSection>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
const settings = useQuery({
|
||||
queryKey: ["teamSettings"],
|
||||
queryFn: api.getTeamSettings,
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [defaultRepoDraft, setDefaultRepoDraft] = useState("");
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [defaultRepoDraft, setDefaultRepoDraft] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultRepoDraft(settings.data?.default_repo ?? "");
|
||||
}, [settings.data?.default_repo]);
|
||||
setDefaultRepoDraft(settings.data?.default_repo ?? "")
|
||||
}, [settings.data?.default_repo])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
|
||||
onSuccess: (saved) => {
|
||||
qc.setQueryData(["teamSettings"], saved);
|
||||
setError(null);
|
||||
qc.setQueryData(["teamSettings"], saved)
|
||||
setError(null)
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
})
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
|
|
@ -385,7 +476,9 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
|
|||
description="Model used by delegated main-agent tasks."
|
||||
models={models}
|
||||
model={settings.data?.default_agent_subagent_model ?? null}
|
||||
effort={settings.data?.default_agent_subagent_reasoning_effort ?? null}
|
||||
effort={
|
||||
settings.data?.default_agent_subagent_reasoning_effort ?? null
|
||||
}
|
||||
onChange={(model, effort) =>
|
||||
settings.data &&
|
||||
save.mutate({
|
||||
|
|
@ -437,7 +530,9 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
|
|||
description="Model used by delegated reviewer tasks."
|
||||
models={models}
|
||||
model={settings.data?.default_reviewer_subagent_model ?? null}
|
||||
effort={settings.data?.default_reviewer_subagent_reasoning_effort ?? null}
|
||||
effort={
|
||||
settings.data?.default_reviewer_subagent_reasoning_effort ?? null
|
||||
}
|
||||
onChange={(model, effort) =>
|
||||
settings.data &&
|
||||
save.mutate({
|
||||
|
|
@ -451,17 +546,17 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
|
|||
</div>
|
||||
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
|
||||
</SettingsSection>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
interface RolePickerProps {
|
||||
label: string;
|
||||
description: string;
|
||||
models: Array<ModelOption>;
|
||||
model: string | null;
|
||||
effort: string | null;
|
||||
onChange: (model: string, effort: string) => void;
|
||||
disabled: boolean;
|
||||
label: string
|
||||
description: string
|
||||
models: Array<ModelOption>
|
||||
model: string | null
|
||||
effort: string | null
|
||||
onChange: (model: string, effort: string) => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function RolePicker({
|
||||
|
|
@ -473,34 +568,34 @@ function RolePicker({
|
|||
onChange,
|
||||
disabled,
|
||||
}: RolePickerProps) {
|
||||
const [localModel, setLocalModel] = useState<string>(model ?? "");
|
||||
const [localEffort, setLocalEffort] = useState<string>(effort ?? "");
|
||||
const [localModel, setLocalModel] = useState<string>(model ?? "")
|
||||
const [localEffort, setLocalEffort] = useState<string>(effort ?? "")
|
||||
|
||||
useEffect(() => {
|
||||
setLocalModel(model ?? "");
|
||||
setLocalEffort(effort ?? "");
|
||||
}, [model, effort]);
|
||||
setLocalModel(model ?? "")
|
||||
setLocalEffort(effort ?? "")
|
||||
}, [model, effort])
|
||||
|
||||
const selectedModel = models.find((m) => m.id === localModel);
|
||||
const availableEfforts = selectedModel?.efforts ?? [];
|
||||
const selectedModel = models.find((m) => m.id === localModel)
|
||||
const availableEfforts = selectedModel?.efforts ?? []
|
||||
|
||||
const handleModelChange = (value: string | null) => {
|
||||
if (!value) return;
|
||||
const nextModel = models.find((m) => m.id === value);
|
||||
if (!nextModel) return;
|
||||
if (!value) return
|
||||
const nextModel = models.find((m) => m.id === value)
|
||||
if (!nextModel) return
|
||||
const nextEffort = nextModel.efforts.includes(localEffort)
|
||||
? localEffort
|
||||
: nextModel.default_effort;
|
||||
setLocalModel(value);
|
||||
setLocalEffort(nextEffort);
|
||||
onChange(value, nextEffort);
|
||||
};
|
||||
: nextModel.default_effort
|
||||
setLocalModel(value)
|
||||
setLocalEffort(nextEffort)
|
||||
onChange(value, nextEffort)
|
||||
}
|
||||
|
||||
const handleEffortChange = (value: string | null) => {
|
||||
if (!value || !localModel) return;
|
||||
setLocalEffort(value);
|
||||
onChange(localModel, value);
|
||||
};
|
||||
if (!value || !localModel) return
|
||||
setLocalEffort(value)
|
||||
onChange(localModel, value)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
|
|
@ -508,7 +603,11 @@ function RolePicker({
|
|||
description={description}
|
||||
control={
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={localModel} onValueChange={handleModelChange} disabled={disabled}>
|
||||
<Select
|
||||
value={localModel}
|
||||
onValueChange={handleModelChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -539,5 +638,5 @@ function RolePicker({
|
|||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ function AgentsLayout() {
|
|||
})
|
||||
const [, section, threadId] = pathname.split("/")
|
||||
const activeThreadId =
|
||||
section === "agents" && threadId && threadId !== "automations"
|
||||
section === "agents" &&
|
||||
threadId &&
|
||||
threadId !== "automations" &&
|
||||
threadId !== "reviews"
|
||||
? threadId
|
||||
: undefined
|
||||
|
||||
|
|
|
|||
1144
ui/src/routes/agents/reviews/$owner.$repo.$number.tsx
Normal file
1144
ui/src/routes/agents/reviews/$owner.$repo.$number.tsx
Normal file
File diff suppressed because it is too large
Load diff
125
ui/src/routes/agents/reviews/index.tsx
Normal file
125
ui/src/routes/agents/reviews/index.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { Link, createFileRoute } from "@tanstack/react-router"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
BugBeetleIcon,
|
||||
FlagIcon,
|
||||
GitPullRequestIcon,
|
||||
} from "@phosphor-icons/react"
|
||||
|
||||
import type { ReviewSummary } from "@/lib/api"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { api } from "@/lib/api"
|
||||
import { useSession } from "@/lib/session"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export const Route = createFileRoute("/agents/reviews/")({
|
||||
component: ReviewsPage,
|
||||
})
|
||||
|
||||
function statusBadge(review: ReviewSummary) {
|
||||
if (review.status === "running") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-[var(--ui-text-muted)]">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-amber-500" />
|
||||
Reviewing
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (review.status === "error") {
|
||||
return <span className="text-xs text-[var(--ui-danger)]">Failed</span>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function ReviewsPage() {
|
||||
const session = useSession()
|
||||
const reviews = useQuery({
|
||||
queryKey: ["reviews"],
|
||||
queryFn: api.listReviews,
|
||||
enabled: !!session.data,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.some((r) => r.status === "running") ? 5000 : false,
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="min-w-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-3xl px-6 py-8">
|
||||
<h1 className="font-heading text-lg font-medium text-[var(--ui-text)]">
|
||||
PR Reviews
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-[var(--ui-text-muted)]">
|
||||
Pull requests reviewed by Open SWE Review. Click into one for the full
|
||||
analysis.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)]">
|
||||
{reviews.isLoading && (
|
||||
<div className="p-4">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{reviews.error && (
|
||||
<p className="px-4 py-3 text-xs text-[var(--ui-danger)]">
|
||||
{reviews.error.message}
|
||||
</p>
|
||||
)}
|
||||
{reviews.data && reviews.data.length === 0 && (
|
||||
<p className="px-4 py-3 text-xs text-[var(--ui-text-muted)]">
|
||||
No reviews yet. Enable repositories under Open SWE Review settings
|
||||
and open a PR.
|
||||
</p>
|
||||
)}
|
||||
<div className="divide-y divide-[var(--ui-border)]">
|
||||
{(reviews.data ?? []).map((review) => (
|
||||
<Link
|
||||
key={review.thread_id}
|
||||
to="/agents/reviews/$owner/$repo/$number"
|
||||
params={{
|
||||
owner: review.owner,
|
||||
repo: review.repo,
|
||||
number: String(review.number),
|
||||
}}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3 transition-colors hover:bg-[var(--ui-sidebar-hover)]"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<GitPullRequestIcon className="size-4 shrink-0 text-[var(--ui-text-muted)]" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium text-[var(--ui-text)]">
|
||||
{review.title}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--ui-text-muted)]">
|
||||
{review.owner}/{review.repo}#{review.number}
|
||||
{review.head_ref && (
|
||||
<span className="ml-2 font-mono text-[11px]">
|
||||
{review.head_ref}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||
{statusBadge(review)}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
review.counts.bugs > 0
|
||||
? "text-[var(--ui-danger)]"
|
||||
: "text-[var(--ui-text-muted)]"
|
||||
)}
|
||||
>
|
||||
<BugBeetleIcon className="size-3.5" />
|
||||
{review.counts.bugs}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[var(--ui-text-muted)]">
|
||||
<FlagIcon className="size-3.5" />
|
||||
{review.counts.flags}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue