open-swe/agent/reviewer_findings.py
Johannes du Plessis 3e105f5027
fix: Reviews tab — anchored finding card, paginated list, file tree truncation (#1507)
* fix: Reviews tab — anchored finding card, paginated list, file tree truncation

- Finding card now tracks the diff anchor while scrolling instead of staying
  frozen in the viewport; auto-hides when its diff card collapses (including
  collapse via mark-as-viewed) and on click outside
- Checks section capped with max height + scroll
- /reviews paginated (page size 20) with has_more, filtered to the current
  user's PRs by default with an All toggle; PR author login now stored in
  reviewer thread metadata
- File tree truncation marker overlapped filenames because the sidebar bg
  was transparent; use the opaque sidebar color

* fix: finding card tracks anchor 1:1 while scrolling

Drop the vertical viewport clamp — it pinned the card at the clamp
boundary while the highlighted lines kept scrolling, breaking the
attachment.

* fix: anchor finding card with Base UI popover

Replace manual fixed-position tracking (laggy: setState per scroll
frame) with a Popover anchored to the finding's diff row. Floating UI
tracks the anchor outside React renders, so the card moves 1:1 with
the content and scrolls out of view with it. Unanchored findings keep
the fixed top-right card.

* fix: lock finding card to diff scroll

Replace the Base UI popover (async repositioning, paints a frame behind
native scroll) with a card absolutely positioned inside the scroll
container, so it scrolls with the diff in the same compositor frame.
Scroll moves to the ReviewBody root, side panel becomes sticky.
Position recomputes only on layout shifts via ResizeObserver.

---------

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

630 lines
21 KiB
Python

"""Findings storage for the reviewer agent.
Findings live in LangGraph thread metadata under the canonical reviewer thread
for a PR. This file owns the Finding schema and the read/write helpers that
the reviewer's tools and webhook handlers go through.
Why thread metadata: it survives sandbox eviction, is queryable cross-thread
via the langgraph SDK (a future UI lists all reviewer threads by filtering on
``metadata.kind == "reviewer"``), and matches existing patterns the codebase
already uses for durable non-secret run state like ``sandbox_id``.
"""
from __future__ import annotations
import logging
import uuid
from collections.abc import Callable
from typing import Any, Literal, TypedDict, cast
from langgraph.config import get_config
from langgraph_sdk import get_client
from langgraph_sdk.errors import NotFoundError as LangGraphSDKNotFoundError
logger = logging.getLogger(__name__)
class ReviewerThreadMissingError(RuntimeError):
"""The reviewer thread backing findings storage does not exist.
Raised instead of the SDK's ``NotFoundError`` so tool wrappers can return a
structured do-not-retry result: the thread won't appear on retry (evicted,
eval-mode, or never created), and blind retries burn the whole run.
"""
def __init__(self, thread_id: str, original: Exception) -> None:
super().__init__(f"Reviewer thread {thread_id!r} not found: {original}")
self.thread_id = thread_id
REVIEWER_THREAD_KIND = "reviewer"
# Suggestions are only useful when the reader can scan them at a glance and
# accept with one click. Anything longer reads as the reviewer rewriting the
# code for the author and clutters the comment. We cap at 4 lines and drop
# longer suggestions; the description still gets posted on its own.
MAX_SUGGESTION_LINES = 4
MAX_FINDING_TITLE_LENGTH = 120
DEFAULT_FINDING_TITLE = "Code review finding"
def clip_suggestion(suggestion: str | None) -> tuple[str | None, bool]:
"""Return (suggestion_or_none, was_dropped). Drops if over the line cap."""
if not suggestion:
return suggestion, False
if suggestion.count("\n") + 1 > MAX_SUGGESTION_LINES:
return None, True
return suggestion, False
def normalize_finding_title(title: str | None, description: str = "") -> str:
"""Return a compact finding title suitable for a review comment headline."""
raw = title.strip() if isinstance(title, str) else ""
if not raw and description:
raw = description.strip().split("\n", 1)[0].strip()
compact = " ".join(raw.split())
if not compact:
return DEFAULT_FINDING_TITLE
if len(compact) > MAX_FINDING_TITLE_LENGTH:
return f"{compact[: MAX_FINDING_TITLE_LENGTH - 3].rstrip()}..."
return compact
Severity = Literal["low", "medium", "high", "critical"]
Confidence = Literal["low", "medium", "high"]
FindingStatus = Literal["open", "resolved", "dismissed"]
DiffSide = Literal["LEFT", "RIGHT"]
SurfaceState = Literal["not_surfaced", "surfaced", "resolve_pending", "resolved", "error"]
InteractionKind = Literal["human_reply", "bot_reply"]
SEVERITY_ORDER: dict[Severity, int] = {
"low": 0,
"medium": 1,
"high": 2,
"critical": 3,
}
# Confidence is recorded on every finding for post-hoc calibration analysis
# but does not gate publication — the system prompt's defensibility bar is
# the discipline.
class Finding(TypedDict, total=False):
"""A single review finding.
All fields are optional at the TypedDict level so partial updates and
legacy findings without generated titles are representable.
"""
id: str
severity: Severity
confidence: Confidence
category: str
title: str
file: str
start_line: int | None
end_line: int | None
side: DiffSide
in_diff: bool
description: str
suggestion: str | None
status: FindingStatus
first_seen_sha: str
last_confirmed_sha: str
github_review_id: int | None
github_review_comment_id: int | None
github_review_comment_ids: list[int]
github_review_thread_id: str | None
github_review_thread_ids: list[str]
github_review_run_id: str | None
github_thread_resolved: bool
github_resolved_thread_ids: list[str]
github_posted_resolution_comment_ids: list[int]
last_human_reply_at: str | None
last_human_reply_author: str | None
last_human_reply_body: str | None
last_reconciliation_note: str | None
resolution_note: str | None
diff_hunk: str | None
fingerprint: str
anchor: FindingAnchor
surface: FindingSurface
interactions: list[FindingInteraction]
class FindingAnchor(TypedDict):
file: str
start_line: int | None
end_line: int | None
side: DiffSide
class FindingSurface(TypedDict, total=False):
finding_id: str
state: SurfaceState
github_review_id: int | None
github_review_comment_id: int | None
github_review_thread_id: str | None
severity_threshold_at_publish: Severity | None
surfaced_at_sha: str | None
last_github_sync_at: str | None
last_error: str | None
class FindingInteraction(TypedDict, total=False):
kind: InteractionKind
github_comment_id: int | None
github_parent_comment_id: int | None
author: str
body: str
created_at: str
needs_reassessment: bool
class ReviewerPRMeta(TypedDict, total=False):
"""PR identity stored on reviewer thread metadata, used by the UI."""
owner: str
name: str
number: int
url: str
title: str
head_ref: str
base_ref: str
author: str
class ReviewerSlackThread(TypedDict, total=False):
"""Slack thread that initiated this review — used to post a completion reply."""
channel_id: str
thread_ts: str
def new_finding_id() -> str:
"""Return a stable, short, URL-friendly finding id (``f_<hex>``)."""
return f"f_{uuid.uuid4().hex[:10]}"
def new_finding(
*,
severity: Severity,
category: str,
file: str,
start_line: int | None,
end_line: int | None,
description: str,
sha: str,
title: str | None = None,
confidence: Confidence = "medium",
side: DiffSide = "RIGHT",
suggestion: str | None = None,
diff_hunk: str | None = None,
finding_id: str | None = None,
in_diff: bool = True,
) -> Finding:
"""Construct a fully-populated ``Finding`` ready to persist."""
resolved_id = finding_id or new_finding_id()
anchor: FindingAnchor = {
"file": file,
"start_line": start_line,
"end_line": end_line,
"side": side,
}
surface: FindingSurface = {
"finding_id": resolved_id,
"state": "not_surfaced",
"github_review_id": None,
"github_review_comment_id": None,
"github_review_thread_id": None,
"severity_threshold_at_publish": None,
"surfaced_at_sha": None,
"last_github_sync_at": None,
"last_error": None,
}
finding: Finding = {
"id": resolved_id,
"severity": severity,
"confidence": confidence,
"category": category,
"file": file,
"start_line": start_line,
"end_line": end_line,
"side": side,
"in_diff": in_diff,
"description": description,
"suggestion": suggestion,
"status": "open",
"first_seen_sha": sha,
"last_confirmed_sha": sha,
"github_review_id": None,
"github_review_comment_id": None,
"github_review_comment_ids": [],
"github_review_thread_id": None,
"github_review_thread_ids": [],
"github_review_run_id": None,
"github_thread_resolved": False,
"github_resolved_thread_ids": [],
"github_posted_resolution_comment_ids": [],
"last_human_reply_at": None,
"last_human_reply_author": None,
"last_human_reply_body": None,
"last_reconciliation_note": None,
"resolution_note": None,
"diff_hunk": diff_hunk,
"fingerprint": _finding_fingerprint(file, start_line, end_line, description),
"anchor": anchor,
"surface": surface,
"interactions": [],
}
if title is not None:
finding["title"] = normalize_finding_title(title)
return finding
def _finding_fingerprint(
file: str,
start_line: int | None,
end_line: int | None,
description: str,
) -> str:
normalized_description = " ".join(description.strip().lower().split())
return f"{file}:{start_line or ''}:{end_line or ''}:{normalized_description[:160]}"
def _coerce_finding(value: Any) -> Finding | None:
if not isinstance(value, dict):
return None
if "id" not in value or not isinstance(value["id"], str):
return None
return cast(Finding, value)
def _coerce_findings_list(value: Any) -> list[Finding]:
if not isinstance(value, list):
return []
out: list[Finding] = []
for entry in value:
finding = _coerce_finding(entry)
if finding is not None:
out.append(finding)
return out
def get_thread_id_from_runtime() -> str:
"""Return the thread id from the current LangGraph runnable config."""
config = get_config()
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
if not isinstance(thread_id, str) or not thread_id:
msg = "No thread_id available in runtime config"
raise RuntimeError(msg)
return thread_id
async def get_thread_metadata(thread_id: str) -> dict[str, Any]:
"""Fetch the current metadata for a thread.
Raises :class:`ReviewerThreadMissingError` when the thread does not exist
(swallowing it as ``{}`` made tools report misleading results like "No
finding found" instead of the do-not-retry contract). Other transient
failures still degrade to ``{}``.
"""
client = get_client()
try:
thread = await client.threads.get(thread_id)
except LangGraphSDKNotFoundError as exc:
raise ReviewerThreadMissingError(thread_id, exc) from exc
except Exception: # noqa: BLE001
logger.exception("Failed to fetch thread metadata for %s", thread_id)
return {}
metadata = thread.get("metadata") if isinstance(thread, dict) else None
return metadata if isinstance(metadata, dict) else {}
async def resolve_review_head_sha(thread_id: str, configurable: dict[str, Any]) -> str:
"""Return the current PR head SHA for a reviewer run.
A push that lands while a reviewer run is in flight is delivered as a queued
message into that run, whose frozen ``configurable`` still names the head the
run was created for. The dispatching webhook records the current head in
thread metadata, so prefer that; fall back to the run's config when metadata
carries no head (first review, eval, tests).
"""
config_head = configurable.get("head_sha") if isinstance(configurable, dict) else None
config_head = config_head if isinstance(config_head, str) else ""
if not thread_id:
return config_head
metadata = await get_thread_metadata(thread_id)
meta_head = metadata.get("head_sha")
return meta_head if isinstance(meta_head, str) and meta_head else config_head
async def list_findings(thread_id: str) -> list[Finding]:
"""Return all findings persisted on the reviewer thread."""
metadata = await get_thread_metadata(thread_id)
return _coerce_findings_list(metadata.get("findings"))
async def get_finding(thread_id: str, finding_id: str) -> Finding | None:
"""Return one finding by id, or ``None`` if not present."""
findings = await list_findings(thread_id)
for finding in findings:
if finding.get("id") == finding_id:
return finding
return None
async def replace_findings(thread_id: str, findings: list[Finding]) -> None:
"""Overwrite the findings list on a thread's metadata.
Prefer :func:`mutate_findings` for read-modify-write updates: it re-reads the
freshest persisted list immediately before mutating, which shrinks the
lost-update window a blind overwrite here leaves open.
"""
client = get_client()
try:
await client.threads.update(thread_id=thread_id, metadata={"findings": findings})
except LangGraphSDKNotFoundError as exc:
raise ReviewerThreadMissingError(thread_id, exc) from exc
def thread_missing_tool_result(exc: ReviewerThreadMissingError) -> dict[str, Any]:
"""Structured tool result for a missing reviewer thread.
Returned (not raised) so the agent sees an explicit do-not-retry contract
instead of an empty error blob it retries against.
"""
return {
"success": False,
"error": "thread_not_found",
"thread_id": exc.thread_id,
"note": (
"Reviewer findings storage is unavailable. Do not retry; report the "
"blocker and include intended findings inline in the final message."
),
"detail": str(exc),
}
async def mutate_findings(
thread_id: str,
mutator: Callable[[list[Finding]], bool],
) -> list[Finding]:
"""Read the latest findings, apply ``mutator`` in place, persist iff changed.
Centralizes the read-modify-write so every mutation operates on the freshest
persisted list rather than a stale in-memory snapshot. ``mutator`` edits the
list in place and returns ``True`` when it changed something; we only write
on change, so a no-op mutation never clobbers a concurrent update.
"""
findings = await list_findings(thread_id)
if mutator(findings):
await replace_findings(thread_id, findings)
return findings
async def append_finding(thread_id: str, finding: Finding) -> Finding:
"""Append a finding and persist the new list."""
def _append(findings: list[Finding]) -> bool:
findings.append(finding)
return True
await mutate_findings(thread_id, _append)
return finding
async def update_finding_fields(
thread_id: str,
finding_id: str,
updates: dict[str, Any],
) -> Finding | None:
"""Apply field updates to one finding by id and persist."""
captured: dict[str, Finding] = {}
def _apply(findings: list[Finding]) -> bool:
for finding in findings:
if finding.get("id") == finding_id:
finding.update(updates)
captured["finding"] = finding
return True
return False
await mutate_findings(thread_id, _apply)
return captured.get("finding")
async def update_finding_surface(
thread_id: str,
finding_id: str,
updates: dict[str, Any],
) -> Finding | None:
"""Apply updates to the nested surface record and legacy GitHub fields."""
captured: dict[str, Finding] = {}
def _apply(findings: list[Finding]) -> bool:
for finding in findings:
if finding.get("id") != finding_id:
continue
surface = _coerce_surface(finding, finding_id)
surface.update(updates)
finding["surface"] = surface
_sync_legacy_surface_fields(finding, surface)
captured["finding"] = finding
return True
return False
await mutate_findings(thread_id, _apply)
return captured.get("finding")
async def append_finding_interaction(
thread_id: str,
finding_id: str,
interaction: FindingInteraction,
) -> Finding | None:
"""Persist a GitHub review-thread interaction on one finding."""
captured: dict[str, Finding] = {}
def _apply(findings: list[Finding]) -> bool:
for finding in findings:
if finding.get("id") != finding_id:
continue
captured["finding"] = finding
interactions = finding.get("interactions")
if not isinstance(interactions, list):
interactions = []
github_comment_id = interaction.get("github_comment_id")
if isinstance(github_comment_id, int) and any(
isinstance(item, dict) and item.get("github_comment_id") == github_comment_id
for item in interactions
):
return False
interactions.append(interaction)
finding["interactions"] = interactions
return True
return False
await mutate_findings(thread_id, _apply)
return captured.get("finding")
def _coerce_surface(finding: Finding, finding_id: str) -> FindingSurface:
surface = finding.get("surface")
if isinstance(surface, dict):
coerced = cast(FindingSurface, dict(surface))
else:
coerced = {"finding_id": finding_id}
if not coerced.get("state"):
if finding.get("github_thread_resolved"):
coerced["state"] = "resolved"
elif isinstance(finding.get("github_review_comment_id"), int):
coerced["state"] = "surfaced"
else:
coerced["state"] = "not_surfaced"
coerced.setdefault("github_review_id", finding.get("github_review_id"))
coerced.setdefault("github_review_comment_id", finding.get("github_review_comment_id"))
coerced.setdefault("github_review_thread_id", finding.get("github_review_thread_id"))
coerced.setdefault("severity_threshold_at_publish", None)
coerced.setdefault("surfaced_at_sha", None)
coerced.setdefault("last_github_sync_at", None)
coerced.setdefault("last_error", None)
return coerced
def _sync_legacy_surface_fields(finding: Finding, surface: FindingSurface) -> None:
review_id = surface.get("github_review_id")
if isinstance(review_id, int) or review_id is None:
finding["github_review_id"] = review_id
comment_id = surface.get("github_review_comment_id")
if isinstance(comment_id, int) or comment_id is None:
finding["github_review_comment_id"] = comment_id
thread_id = surface.get("github_review_thread_id")
if isinstance(thread_id, str) or thread_id is None:
finding["github_review_thread_id"] = thread_id
if surface.get("state") == "resolved":
finding["github_thread_resolved"] = True
async def set_reviewer_thread_metadata(
thread_id: str,
*,
pr: ReviewerPRMeta | None = None,
last_reviewed_sha: str | None = None,
head_sha: str | None = None,
watch: bool | None = None,
findings: list[Finding] | None = None,
slack_thread: ReviewerSlackThread | None = None,
extra: dict[str, Any] | None = None,
) -> None:
"""Persist reviewer-thread-level metadata.
Always sets ``kind=reviewer`` so the future UI can list reviewer threads by
filtering on metadata. Only includes the fields the caller passed in
(langgraph metadata updates merge rather than overwrite).
``head_sha`` records the current PR head the dispatching webhook is acting
on. A push that lands mid-run is queued into the still-running run, whose
frozen config can't be updated; persisting the head here lets the reviewer
tools resolve the live head via ``resolve_review_head_sha``.
"""
client = get_client()
metadata: dict[str, Any] = {"kind": REVIEWER_THREAD_KIND}
if pr is not None:
metadata["pr"] = pr
if last_reviewed_sha is not None:
metadata["last_reviewed_sha"] = last_reviewed_sha
if head_sha is not None:
metadata["head_sha"] = head_sha
if watch is not None:
metadata["watch"] = watch
if findings is not None:
metadata["findings"] = findings
if slack_thread is not None:
metadata["slack_thread"] = slack_thread
if extra:
metadata.update(extra)
try:
await client.threads.update(thread_id=thread_id, metadata=metadata)
except LangGraphSDKNotFoundError as exc:
raise ReviewerThreadMissingError(thread_id, exc) from exc
def get_thread_watch_flag(metadata: dict[str, Any]) -> bool:
return bool(metadata.get("watch"))
def get_thread_last_reviewed_sha(metadata: dict[str, Any]) -> str | None:
value = metadata.get("last_reviewed_sha")
return value if isinstance(value, str) and value else None
def get_thread_pr_meta(metadata: dict[str, Any]) -> ReviewerPRMeta | None:
pr = metadata.get("pr")
if not isinstance(pr, dict):
return None
return cast(ReviewerPRMeta, pr)
def get_thread_slack_ref(metadata: dict[str, Any]) -> ReviewerSlackThread | None:
slack_thread = metadata.get("slack_thread")
if not isinstance(slack_thread, dict):
return None
channel_id = slack_thread.get("channel_id")
thread_ts = slack_thread.get("thread_ts")
if not isinstance(channel_id, str) or not isinstance(thread_ts, str):
return None
if not channel_id or not thread_ts:
return None
return cast(ReviewerSlackThread, slack_thread)
def filter_findings_for_publish(
findings: list[Finding],
*,
severity_threshold: Severity = "medium",
cap: int = 4,
) -> list[Finding]:
"""Return findings to surface to GitHub.
- status must be ``open``
- severity must be at or above ``severity_threshold``
- sorted by severity descending, then file/start_line for stable ordering
- capped at ``cap`` to avoid review spam
"""
severity_rank = SEVERITY_ORDER[severity_threshold]
eligible = [
finding
for finding in findings
if finding.get("status", "open") == "open"
and SEVERITY_ORDER.get(finding.get("severity", "low"), 0) >= severity_rank
]
eligible.sort(
key=lambda f: (
-SEVERITY_ORDER.get(f.get("severity", "low"), 0),
f.get("file", ""),
f.get("start_line") or 0,
)
)
return eligible[:cap]