open-swe/agent/tools/add_finding.py
Adam Moussa 62d9945df4
refactor: consolidate reviewer modules into agent/review/
Part of the domain-reorg adoption (build plan step C2): fork content,
upstream layout. Nine 1:1 module moves (reviewer_diff/eval_store/
findings/groups/publish/reconcile/trace_context + review_style_
collector/guidance) into agent/review/, with internal relative
imports re-wired to the new package depth. agent/review/__init__.py
mirrors upstream's thin re-export shim (one of the 21 verified "A"
structural adds).

Rewrote the 38 grep hits across importer files (agent/{analyzer,
ci_autofix,reviewer,webapp}.py, agent/dashboard/*, agent/middleware/
settle_review_check.py, agent/tools/*, agent/utils/github_feedback.py,
agent/webhooks/github.py, evals/reviewer/*, and the reviewer test
suite) to point at agent.review.*; 4 of the 38 hits were name
collisions (list_reviewer_findings, reviewer_outcomes,
_reviewer_thread_id, reviewer_thread_id — not the moved modules) and
were left untouched. tests/test_github_checks.py's module-alias
import (`from agent import reviewer_publish`) follows upstream's own
`from agent.review import publish as reviewer_publish` pattern so
downstream `reviewer_publish.*` call sites needed no changes.
agent/reviewer.py and agent/webapp.py stay in place per the hard
rule (fork content, import-only rewire) and are not part of this
package.

Gates: ruff check + ruff format --check, pytest --co -q (1637
collected), full unit suite (1637 passed), and the reviewer/findings
suite in isolation (pytest -k "review or finding", 421 passed).
2026-07-17 13:52:03 -04:00

192 lines
7.7 KiB
Python

"""Tool: ``add_finding``. Records one review finding on the reviewer thread."""
from __future__ import annotations
from typing import Any
from langgraph.config import get_config
from ..review.diff import is_range_in_diff
from ..review.findings import (
DEFAULT_FINDING_TITLE,
MAX_SUGGESTION_LINES,
Confidence,
DiffSide,
Finding,
ReviewerThreadMissingError,
Severity,
append_finding,
clip_suggestion,
get_thread_id_from_runtime,
new_finding,
normalize_finding_title,
resolve_review_head_sha,
thread_missing_tool_result,
)
async def add_finding(
severity: str,
confidence: str,
category: str,
file: str,
title: str,
description: str,
start_line: int | None = None,
end_line: int | None = None,
suggestion: str | None = None,
side: str = "RIGHT",
) -> dict[str, Any]:
"""Record a review finding on the reviewer thread.
Findings persist on the reviewer thread's metadata so they survive sandbox
eviction and are queryable across runs by the watch-mode reconciliation
flow and the future UI.
**When to use:** Once per distinct issue you find while reviewing the
diff. Prefer one finding per issue, with a concise generated ``title`` that
names the failure mode, a clear ``description`` body, and, when you can
offer a concrete fix, a ``suggestion`` that exactly replaces lines
``start_line..end_line``.
**In-diff only:** ``start_line..end_line`` must be inside the PR diff.
Findings anchored to lines outside the diff are rejected (out-of-diff
findings are disabled). File-level findings (both ``start_line`` and
``end_line`` None) are accepted but won't render as inline GitHub
comments — only use when the issue truly isn't anchored to a line.
Args:
severity: One of ``low``, ``medium``, ``high``, ``critical``.
confidence: One of ``low``, ``medium``, ``high``.
category: Short category label (``correctness``, ``security``, ``perf``,
``style``, ``flag``, etc.). Free-form; used for grouping in the UI.
file: Repo-relative path of the file the finding refers to.
title: Concise generated headline for the finding. Name the failure mode
in roughly 4-10 words; do not copy or truncate the description.
description: Markdown body the user sees. Do not repeat ``title`` as the
first line.
start_line: 1-based line in the new (post-PR) file where the
relevant range begins. For a single-line finding, this is the
line the issue is about. For a multi-line finding, this is the
first line of the relevant range. Omit (with ``end_line``) for
file-level findings.
end_line: 1-based line where the relevant range ends. GitHub
anchors the inline comment at ``end_line`` and renders the
``start_line..end_line`` span as the highlighted snippet, so
choose ``end_line`` as the *last* line that matters — typically
the line the comment is most directly about. For a single-line
finding, set ``end_line == start_line`` (or omit it). Prefer
the natural range of the issue over a single line: GitHub
shows context above ``end_line``, so a one-line anchor often
buries the issue under unrelated context. Defaults to
``start_line`` when omitted.
suggestion: Replacement text for ``start_line..end_line``. When set,
the published GitHub comment includes a ```suggestion``` block so
the user can click "Commit suggestion". **Only set this for small,
obvious fixes that fit in 4 lines or fewer** (e.g. a one-liner
rename, a missing guard, a typo). Longer suggestions are dropped
because they read as rewrites rather than reviews — leave those
cases as a description-only finding so the author can decide how
to fix it.
side: ``RIGHT`` (post-PR file, default) or ``LEFT`` (base file). Almost
always ``RIGHT``.
Returns:
Dictionary with ``success``, ``finding_id`` and (on rejection) ``error``.
"""
if start_line is not None and end_line is None:
end_line = start_line
if start_line is None and end_line is not None:
start_line = end_line
normalized_title = normalize_finding_title(title)
if normalized_title == DEFAULT_FINDING_TITLE:
return {"success": False, "error": "title must be a non-empty generated headline"}
if severity not in {"low", "medium", "high", "critical"}:
return {"success": False, "error": f"Invalid severity: {severity}"}
if confidence not in {"low", "medium", "high"}:
return {"success": False, "error": f"Invalid confidence: {confidence}"}
if side not in {"LEFT", "RIGHT"}:
return {"success": False, "error": f"Invalid side: {side}"}
if start_line is not None and end_line is not None and end_line < start_line:
return {"success": False, "error": "end_line must be >= start_line"}
config = get_config()
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
diff_line_set = configurable.get("diff_line_set") if isinstance(configurable, dict) else None
diff_text = configurable.get("diff_text", "") if isinstance(configurable, dict) else ""
in_diff = not isinstance(diff_line_set, dict) or is_range_in_diff(
diff_line_set, file, start_line, end_line, side=_cast_side(side)
)
if not in_diff:
return {
"success": False,
"in_diff": False,
"error": (
"Out-of-diff findings are disabled. This finding's lines are not "
"part of this PR's diff. Only file findings anchored to a line the "
"PR changed. Do not re-anchor or retry."
),
}
diff_hunk: str | None = None
if isinstance(diff_text, str) and diff_text:
from ..review.diff import extract_diff_hunk
diff_hunk = extract_diff_hunk(diff_text, file, start_line, end_line)
clipped_suggestion, suggestion_dropped = clip_suggestion(suggestion)
thread_id = get_thread_id_from_runtime()
try:
head_sha = await resolve_review_head_sha(thread_id, configurable)
except ReviewerThreadMissingError as exc:
return thread_missing_tool_result(exc)
finding: Finding = new_finding(
severity=_cast_severity(severity),
confidence=_cast_confidence(confidence),
category=category,
file=file,
start_line=start_line,
end_line=end_line,
description=description,
sha=head_sha,
title=normalized_title,
side=_cast_side(side),
suggestion=clipped_suggestion,
diff_hunk=diff_hunk,
in_diff=in_diff,
)
try:
append_result = await append_finding(thread_id, finding)
except ReviewerThreadMissingError as exc:
return thread_missing_tool_result(exc)
result: dict[str, Any] = {
"success": True,
"finding_id": append_result["finding"]["id"],
"duplicate": not append_result["created"],
}
if suggestion_dropped:
result["suggestion_dropped"] = True
result["warning"] = (
f"Suggestion exceeded the {MAX_SUGGESTION_LINES}-line cap and was "
"dropped — the finding was recorded with description only. Only "
"include `suggestion` for small, obvious fixes."
)
return result
def _cast_severity(value: str) -> Severity:
return value # type: ignore[return-value]
def _cast_confidence(value: str) -> Confidence:
return value # type: ignore[return-value]
def _cast_side(value: str) -> DiffSide:
return value # type: ignore[return-value]