mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
* feat(reviewer): surface dashboard UI link on PR reviews [INF-0000] Post a transient "review in progress" comment (with an "Open in Web" dashboard link) when a reviewer run starts, then delete it once the review lands. The published review body now carries the same "Open in Web" link, so the link persists on the review itself. The transient comment's id is tracked in reviewer thread metadata (status_comment_id) so it can be deleted on completion. * refactor(reviewer): inline dashboard URL helper, drop redundant future import [INF-0000]
1906 lines
71 KiB
Python
1906 lines
71 KiB
Python
"""Unit tests for the publish_review rendering and orchestration helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from agent.reviewer_findings import Finding, new_finding
|
|
from agent.reviewer_publish import (
|
|
clear_review_started_comment,
|
|
fetch_pr_review_threads,
|
|
open_swe_review_exists,
|
|
parse_review_comment_marker,
|
|
post_pull_request_review,
|
|
post_review_started_comment,
|
|
render_inline_comment_body,
|
|
render_inline_comment_payload,
|
|
render_resolution_comment,
|
|
render_review_body,
|
|
render_status_comment,
|
|
reply_to_review_comment,
|
|
resolve_review_thread,
|
|
review_summary_marker,
|
|
status_comment_marker,
|
|
)
|
|
|
|
|
|
def _f(**overrides: Any) -> Finding:
|
|
base = new_finding(
|
|
severity="high",
|
|
confidence="high",
|
|
category="correctness",
|
|
file="src/foo.py",
|
|
start_line=10,
|
|
end_line=10,
|
|
description="boom",
|
|
sha="abc",
|
|
)
|
|
base.update(overrides) # type: ignore[arg-type]
|
|
return base
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_publish_review_pr_state() -> Iterator[None]:
|
|
with (
|
|
patch("agent.tools.publish_review.fetch_pr_review_threads", AsyncMock(return_value=[])),
|
|
patch("agent.tools.publish_review.replace_findings", AsyncMock()),
|
|
patch("agent.tools.publish_review.open_swe_review_exists", AsyncMock(return_value=False)),
|
|
patch("agent.tools.publish_review.clear_review_started_comment", AsyncMock()),
|
|
patch(
|
|
"agent.tools.publish_review.resolve_review_head_sha",
|
|
AsyncMock(
|
|
side_effect=lambda thread_id, configurable: configurable.get("head_sha") or ""
|
|
),
|
|
),
|
|
):
|
|
yield
|
|
|
|
|
|
def test_render_inline_comment_body_without_suggestion() -> None:
|
|
body = render_inline_comment_body(_f(description="just text"))
|
|
assert "<!-- open-swe-review-comment" in body
|
|
assert '"id":"f_' in body
|
|
assert "just text" in body
|
|
assert "Was this helpful?" in body
|
|
assert "👍 or 👎" in body
|
|
|
|
|
|
def test_render_inline_comment_body_with_suggestion_appends_block() -> None:
|
|
body = render_inline_comment_body(
|
|
_f(description="needs fix", suggestion="x = 1\nx += 1"),
|
|
)
|
|
assert "needs fix" in body
|
|
assert "```suggestion" in body
|
|
assert "x = 1\nx += 1" in body
|
|
|
|
|
|
def test_render_inline_comment_body_uses_severity_emoji_and_bold_title() -> None:
|
|
body = render_inline_comment_body(_f(severity="critical", description="Null deref"))
|
|
assert "🔴 **Null deref**" in body
|
|
|
|
|
|
def test_render_inline_comment_body_uses_generated_title() -> None:
|
|
description = "This request can fail because the new path skips auth token refresh."
|
|
body = render_inline_comment_body(_f(title="Refresh token skipped", description=description))
|
|
|
|
assert "🟠 **Refresh token skipped**" in body
|
|
assert description in body
|
|
assert "This request can fail because the new path skips auth token refresh" in body
|
|
|
|
|
|
def test_render_inline_comment_body_does_not_duplicate_first_line() -> None:
|
|
body = render_inline_comment_body(
|
|
_f(description="Short summary line\n\nLonger detail paragraph."),
|
|
)
|
|
assert "**Short summary line**" in body
|
|
assert "Longer detail paragraph." in body
|
|
assert body.count("Short summary line") == 1
|
|
|
|
|
|
def test_render_inline_comment_body_does_not_duplicate_generated_title() -> None:
|
|
body = render_inline_comment_body(
|
|
_f(
|
|
title="Short summary line", description="Short summary line\n\nLonger detail paragraph."
|
|
),
|
|
)
|
|
assert "**Short summary line**" in body
|
|
assert "Longer detail paragraph." in body
|
|
assert body.count("Short summary line") == 1
|
|
|
|
|
|
def test_render_inline_comment_body_single_line_has_no_detail() -> None:
|
|
body = render_inline_comment_body(_f(description="just text"))
|
|
assert body.count("just text") == 1
|
|
|
|
|
|
def test_render_inline_comment_body_line_reference_range() -> None:
|
|
assert "*(Refers to lines 10-12)*" in render_inline_comment_body(_f(start_line=10, end_line=12))
|
|
assert "*(Refers to line 10)*" in render_inline_comment_body(_f(start_line=10, end_line=10))
|
|
|
|
|
|
def test_render_resolution_comment_resolved_uses_note() -> None:
|
|
body = render_resolution_comment(_f(status="resolved"), "resolved", note="Fixed at line 5")
|
|
assert body == "✅ **Resolved**: Fixed at line 5"
|
|
|
|
|
|
def test_render_resolution_comment_returns_none_without_agent_note() -> None:
|
|
body = render_resolution_comment(_f(status="resolved"), "resolved")
|
|
assert body is None
|
|
|
|
|
|
def test_render_resolution_comment_dismissed_uses_note() -> None:
|
|
body = render_resolution_comment(_f(status="dismissed"), "dismissed", note="Intended behavior")
|
|
assert body == "❌ **Dismissed**: Intended behavior"
|
|
|
|
|
|
def test_render_resolution_comment_uses_stored_resolution_note() -> None:
|
|
finding = _f(status="resolved", resolution_note="The guard now returns before indexing.")
|
|
body = render_resolution_comment(finding, "resolved")
|
|
assert body == "✅ **Resolved**: The guard now returns before indexing."
|
|
|
|
|
|
def test_parse_review_comment_marker_accepts_valid_marker() -> None:
|
|
finding = _f(
|
|
id="f_marker",
|
|
file="agent/webapp.py",
|
|
start_line=10,
|
|
end_line=12,
|
|
side="RIGHT",
|
|
)
|
|
marker = parse_review_comment_marker(render_inline_comment_body(finding))
|
|
|
|
assert marker == {
|
|
"id": "f_marker",
|
|
"file_path": "agent/webapp.py",
|
|
"start_line": 10,
|
|
"end_line": 12,
|
|
"side": "RIGHT",
|
|
}
|
|
|
|
|
|
def test_parse_review_comment_marker_rejects_malformed_marker() -> None:
|
|
assert parse_review_comment_marker("plain body") is None
|
|
assert parse_review_comment_marker("<!-- open-swe-review-comment {} -->") is None
|
|
assert (
|
|
parse_review_comment_marker(
|
|
'<!-- open-swe-review-comment {"id":"f1","file_path":"x.py","side":"BAD"} -->'
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_render_inline_comment_payload_single_line() -> None:
|
|
payload = render_inline_comment_payload(_f(start_line=10, end_line=10))
|
|
assert payload is not None
|
|
assert payload["path"] == "src/foo.py"
|
|
assert payload["line"] == 10
|
|
assert payload["side"] == "RIGHT"
|
|
assert "boom" in payload["body"]
|
|
assert "<!-- open-swe-review-comment" in payload["body"]
|
|
|
|
|
|
def test_render_inline_comment_payload_multi_line_uses_start_fields() -> None:
|
|
payload = render_inline_comment_payload(_f(start_line=8, end_line=12))
|
|
assert payload is not None
|
|
assert payload["start_line"] == 8
|
|
assert payload["start_side"] == "RIGHT"
|
|
assert payload["line"] == 12
|
|
|
|
|
|
def test_render_inline_comment_payload_returns_none_for_file_level() -> None:
|
|
payload = render_inline_comment_payload(_f(start_line=None, end_line=None))
|
|
assert payload is None
|
|
|
|
|
|
def test_render_review_body_with_findings_uses_potential_issue_phrasing() -> None:
|
|
body = render_review_body(pr_number=123, surfaced_count=2)
|
|
assert body.startswith("**Open SWE Review** found 2 potential issues.")
|
|
assert "<!-- open-swe-reviewer pr=123 -->" in body
|
|
|
|
|
|
def test_render_review_body_singular_finding() -> None:
|
|
body = render_review_body(pr_number=123, surfaced_count=1)
|
|
assert body.startswith("**Open SWE Review** found 1 potential issue.")
|
|
|
|
|
|
def test_render_review_body_no_findings_message() -> None:
|
|
body = render_review_body(pr_number=99, surfaced_count=0)
|
|
assert "## ✅ Open SWE Review: No issues found" in body
|
|
assert "Open SWE reviewed this PR and found no potential bugs to report." in body
|
|
|
|
|
|
def test_render_status_comment_reviewing_includes_ui_link(monkeypatch: Any) -> None:
|
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dash.example")
|
|
body = render_status_comment(pr_number=7, thread_id="tid-1")
|
|
assert "🔍 Open SWE Review: in progress" in body
|
|
assert "[Open in Web](https://dash.example/agents/tid-1)" in body
|
|
assert status_comment_marker(7) in body
|
|
|
|
|
|
def test_render_review_body_includes_ui_link() -> None:
|
|
body = render_review_body(
|
|
pr_number=7, surfaced_count=0, ui_url="https://dash.example/agents/tid-1"
|
|
)
|
|
assert "[Open in Web](https://dash.example/agents/tid-1)" in body
|
|
|
|
|
|
def test_render_review_body_orders_ui_link_before_trace() -> None:
|
|
body = render_review_body(
|
|
pr_number=7,
|
|
surfaced_count=1,
|
|
ui_url="https://dash.example/agents/tid-1",
|
|
trace_url="https://trace.example/x",
|
|
)
|
|
assert "[Open in Web](https://dash.example/agents/tid-1) • [View Open SWE trace]" in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_review_started_comment_posts_and_persists_id() -> None:
|
|
post = AsyncMock(return_value=4242)
|
|
set_meta = AsyncMock()
|
|
with (
|
|
patch("agent.reviewer_publish.get_thread_metadata", AsyncMock(return_value={})),
|
|
patch("agent.reviewer_publish.post_status_comment", post),
|
|
patch("agent.reviewer_publish.delete_status_comment", AsyncMock()) as delete,
|
|
patch("agent.reviewer_publish.set_reviewer_thread_metadata", set_meta),
|
|
):
|
|
cid = await post_review_started_comment(
|
|
thread_id="tid", owner="o", repo="r", pr_number=7, token="t"
|
|
)
|
|
assert cid == 4242
|
|
delete.assert_not_called()
|
|
post.assert_awaited_once()
|
|
set_meta.assert_awaited_once_with("tid", extra={"status_comment_id": 4242})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_review_started_comment_deletes_lingering_before_reposting() -> None:
|
|
post = AsyncMock(return_value=500)
|
|
delete = AsyncMock(return_value=True)
|
|
with (
|
|
patch(
|
|
"agent.reviewer_publish.get_thread_metadata",
|
|
AsyncMock(return_value={"status_comment_id": 99}),
|
|
),
|
|
patch("agent.reviewer_publish.post_status_comment", post),
|
|
patch("agent.reviewer_publish.delete_status_comment", delete),
|
|
patch("agent.reviewer_publish.set_reviewer_thread_metadata", AsyncMock()),
|
|
):
|
|
cid = await post_review_started_comment(
|
|
thread_id="tid", owner="o", repo="r", pr_number=7, token="t"
|
|
)
|
|
assert cid == 500
|
|
delete.assert_awaited_once()
|
|
assert delete.await_args.kwargs["comment_id"] == 99
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_review_started_comment_deletes_and_clears_metadata() -> None:
|
|
delete = AsyncMock(return_value=True)
|
|
set_meta = AsyncMock()
|
|
with (
|
|
patch(
|
|
"agent.reviewer_publish.get_thread_metadata",
|
|
AsyncMock(return_value={"status_comment_id": 99}),
|
|
),
|
|
patch("agent.reviewer_publish.delete_status_comment", delete),
|
|
patch("agent.reviewer_publish.set_reviewer_thread_metadata", set_meta),
|
|
):
|
|
await clear_review_started_comment(thread_id="tid", owner="o", repo="r", token="t")
|
|
delete.assert_awaited_once()
|
|
assert delete.await_args.kwargs["comment_id"] == 99
|
|
set_meta.assert_awaited_once_with("tid", extra={"status_comment_id": None})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_review_started_comment_noop_without_tracked_id() -> None:
|
|
delete = AsyncMock()
|
|
set_meta = AsyncMock()
|
|
with (
|
|
patch("agent.reviewer_publish.get_thread_metadata", AsyncMock(return_value={})),
|
|
patch("agent.reviewer_publish.delete_status_comment", delete),
|
|
patch("agent.reviewer_publish.set_reviewer_thread_metadata", set_meta),
|
|
):
|
|
await clear_review_started_comment(thread_id="tid", owner="o", repo="r", token="t")
|
|
delete.assert_not_called()
|
|
set_meta.assert_not_called()
|
|
|
|
|
|
def test_render_review_body_with_only_out_of_diff_findings() -> None:
|
|
body = render_review_body(
|
|
pr_number=7,
|
|
surfaced_count=0,
|
|
out_of_diff_findings=[
|
|
_f(title="Caller passes stale arg", description="boom", file="x/caller.py")
|
|
],
|
|
)
|
|
assert "No issues found" not in body
|
|
assert "found no issues in the changed lines" in body
|
|
assert "<details>" in body
|
|
assert "1 out-of-diff finding</summary>" in body
|
|
assert "**Caller passes stale arg**" in body
|
|
assert "`x/caller.py" in body
|
|
|
|
|
|
def test_render_review_body_combines_inline_and_out_of_diff() -> None:
|
|
body = render_review_body(
|
|
pr_number=7,
|
|
surfaced_count=2,
|
|
out_of_diff_findings=[_f(title="A"), _f(title="B")],
|
|
)
|
|
assert "found 2 potential issues." in body
|
|
assert "2 out-of-diff findings</summary>" in body
|
|
assert "<!-- open-swe-reviewer pr=7 -->" in body
|
|
|
|
|
|
def test_render_review_body_includes_trace_link_when_provided() -> None:
|
|
body = render_review_body(
|
|
pr_number=123,
|
|
surfaced_count=0,
|
|
trace_url="https://smith.langchain.com/o/t/project/p/t/thread-id",
|
|
)
|
|
assert "[View Open SWE trace](https://smith.langchain.com/o/t/project/p/t/thread-id)" in body
|
|
assert body.endswith("<!-- open-swe-reviewer pr=123 -->")
|
|
|
|
|
|
def test_publish_review_eval_mode_does_not_call_github() -> None:
|
|
from agent.tools.publish_review import publish_review
|
|
|
|
findings = [
|
|
_f(id="f_high", severity="high", file="a.py", start_line=1, end_line=1),
|
|
_f(id="f_low", severity="low", file="b.py", start_line=2, end_line=2),
|
|
]
|
|
|
|
with (
|
|
patch(
|
|
"agent.tools.publish_review.get_config",
|
|
return_value={
|
|
"configurable": {
|
|
"thread_id": "tid",
|
|
"repo": {"owner": "o", "name": "r"},
|
|
"pr_number": 7,
|
|
"head_sha": "sha",
|
|
"reviewer_eval": True,
|
|
},
|
|
"metadata": {},
|
|
},
|
|
),
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=findings)),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", AsyncMock()) as set_meta,
|
|
patch("agent.tools.publish_review.get_github_token") as get_token,
|
|
patch("agent.tools.publish_review.post_pull_request_review", AsyncMock()) as post_review,
|
|
):
|
|
result = publish_review()
|
|
|
|
assert result["success"] is True
|
|
assert result["dry_run"] is True
|
|
assert result["surfaced_count"] == 1
|
|
assert result["hidden_count"] == 1
|
|
get_token.assert_not_called()
|
|
post_review.assert_not_called()
|
|
set_meta.assert_awaited_once_with("tid", last_reviewed_sha="sha")
|
|
|
|
|
|
def test_publish_review_forwards_trace_link_config_override() -> None:
|
|
from agent.tools.publish_review import publish_review
|
|
|
|
publish_async = AsyncMock(return_value={"success": True})
|
|
with (
|
|
patch(
|
|
"agent.tools.publish_review.get_config",
|
|
return_value={
|
|
"configurable": {
|
|
"thread_id": "reviewer-thread-id",
|
|
"repo": {"owner": "o", "name": "r"},
|
|
"pr_number": 7,
|
|
"head_sha": "sha",
|
|
"review_trace_link_enabled": False,
|
|
},
|
|
"metadata": {},
|
|
},
|
|
),
|
|
patch("agent.tools.publish_review.get_github_token", return_value="token"),
|
|
patch("agent.tools.publish_review._publish_review_async", publish_async),
|
|
):
|
|
result = publish_review()
|
|
|
|
assert result == {"success": True}
|
|
assert publish_async.call_args.kwargs["trace_link_config_override"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_review_trace_url_enabled_by_team_setting() -> None:
|
|
from agent.tools.publish_review import _resolve_review_trace_url
|
|
|
|
with (
|
|
patch(
|
|
"agent.tools.publish_review.get_team_review_trace_links_enabled",
|
|
AsyncMock(return_value=True),
|
|
),
|
|
patch(
|
|
"agent.tools.publish_review.get_langsmith_trace_url",
|
|
return_value="https://smith/t",
|
|
),
|
|
):
|
|
url = await _resolve_review_trace_url("reviewer-thread-id", None)
|
|
|
|
assert url == "https://smith/t"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_review_trace_url_disabled_by_team_setting() -> None:
|
|
from agent.tools.publish_review import _resolve_review_trace_url
|
|
|
|
trace_url = MagicMock(return_value="https://smith/t")
|
|
with (
|
|
patch(
|
|
"agent.tools.publish_review.get_team_review_trace_links_enabled",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch("agent.tools.publish_review.get_langsmith_trace_url", trace_url),
|
|
):
|
|
url = await _resolve_review_trace_url("reviewer-thread-id", None)
|
|
|
|
assert url is None
|
|
trace_url.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_review_trace_url_config_override_skips_team_lookup() -> None:
|
|
from agent.tools.publish_review import _resolve_review_trace_url
|
|
|
|
team_lookup = AsyncMock(return_value=True)
|
|
trace_url = MagicMock(return_value="https://smith/t")
|
|
with (
|
|
patch("agent.tools.publish_review.get_team_review_trace_links_enabled", team_lookup),
|
|
patch("agent.tools.publish_review.get_langsmith_trace_url", trace_url),
|
|
):
|
|
url = await _resolve_review_trace_url("reviewer-thread-id", False)
|
|
|
|
assert url is None
|
|
team_lookup.assert_not_called()
|
|
trace_url.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_review_thread_returns_true_on_success() -> None:
|
|
response = MagicMock()
|
|
response.json.return_value = {
|
|
"data": {"resolveReviewThread": {"thread": {"id": "T_1", "isResolved": True}}}
|
|
}
|
|
response.raise_for_status.return_value = None
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
ok = await resolve_review_thread(thread_node_id="T_1", token="t")
|
|
assert ok is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_pr_review_threads_handles_null_repository() -> None:
|
|
"""GitHub returns ``repository: null`` when the token can't read the repo
|
|
(SAML, expired token, private/deleted). ``dict.get(k, {})`` does not coalesce
|
|
explicit null, so the fetch must guard against it and return collected threads."""
|
|
response = MagicMock()
|
|
response.json.return_value = {"data": {"repository": None}}
|
|
response.raise_for_status.return_value = None
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
threads = await fetch_pr_review_threads(owner="o", repo="r", pr_number=1, token="t")
|
|
assert threads == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_pull_request_review_non_dict_body_surfaces_status_and_excerpt() -> None:
|
|
"""A non-dict GitHub response body must surface status code + body excerpt
|
|
via ``_error`` rather than collapsing to a bare ``None`` (which the
|
|
user-facing tool would render as the unhelpful ``Failed to POST PR review``)."""
|
|
response = MagicMock()
|
|
response.status_code = 200
|
|
response.json.return_value = ["unexpected", "list", "body"]
|
|
response.text = '["unexpected", "list", "body"]'
|
|
response.raise_for_status.return_value = None
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
result = await post_pull_request_review(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=1,
|
|
head_sha="sha",
|
|
body="b",
|
|
inline_comments=[],
|
|
token="t",
|
|
)
|
|
|
|
assert isinstance(result, dict)
|
|
assert "_error" in result
|
|
err = result["_error"]
|
|
assert "HTTP 200" in err
|
|
assert "non-dict" in err
|
|
assert "unexpected" in err
|
|
# The bare legacy string must not be the only signal anymore.
|
|
assert err != "Failed to POST PR review"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_review_thread_returns_false_on_graphql_errors() -> None:
|
|
response = MagicMock()
|
|
response.json.return_value = {"errors": [{"message": "no perms"}]}
|
|
response.raise_for_status.return_value = None
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
ok = await resolve_review_thread(thread_node_id="T_1", token="t")
|
|
assert ok is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_skips_findings_already_published() -> None:
|
|
"""Re-runs must not re-post findings that already have a github_review_comment_id."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
findings = [
|
|
_f(id="f_old", severity="high", file="a.py", github_review_comment_id=42),
|
|
_f(id="f_new", severity="high", file="b.py"),
|
|
]
|
|
|
|
list_async = AsyncMock(return_value=findings)
|
|
post_review = AsyncMock(return_value={"id": 999})
|
|
fetch_comments = AsyncMock(return_value=[])
|
|
set_metadata = AsyncMock()
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", list_async),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch("agent.tools.publish_review.fetch_review_comments", fetch_comments),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", set_metadata),
|
|
patch(
|
|
"agent.tools.publish_review._maybe_post_slack_completion_reply",
|
|
new_callable=AsyncMock,
|
|
),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["surfaced_count"] == 1
|
|
posted = post_review.await_args.kwargs["inline_comments"]
|
|
paths = {c["path"] for c in posted}
|
|
assert paths == {"b.py"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_skips_post_on_re_review_with_no_new_findings() -> None:
|
|
"""Re-review with nothing new to surface must not spam another comment."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
# All findings already have github_review_comment_id from the prior publish
|
|
# (so none are "unpublished"), plus one previously-resolved finding whose
|
|
# thread still needs to be resolved on GitHub.
|
|
findings = [
|
|
{
|
|
"id": "f_old",
|
|
"severity": "high",
|
|
"category": "correctness",
|
|
"file": "a.py",
|
|
"start_line": 1,
|
|
"end_line": 1,
|
|
"side": "RIGHT",
|
|
"description": "x",
|
|
"suggestion": None,
|
|
"status": "resolved",
|
|
"first_seen_sha": "s",
|
|
"last_confirmed_sha": "s",
|
|
"github_review_comment_id": 100,
|
|
},
|
|
]
|
|
list_async = AsyncMock(return_value=findings)
|
|
post_review = AsyncMock()
|
|
set_metadata = AsyncMock()
|
|
resolve_threads = AsyncMock(return_value=1)
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", list_async),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
resolve_threads,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", set_metadata),
|
|
patch(
|
|
"agent.tools.publish_review._maybe_post_slack_completion_reply",
|
|
new_callable=AsyncMock,
|
|
),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="newsha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=True,
|
|
)
|
|
|
|
post_review.assert_not_called()
|
|
resolve_threads.assert_awaited_once()
|
|
set_metadata.assert_awaited_once()
|
|
assert result["success"] is True
|
|
assert result["review_id"] is None
|
|
assert result["surfaced_count"] == 0
|
|
assert result["resolved_thread_count"] == 1
|
|
assert result["skipped_empty_re_review"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_surfaces_out_of_diff_finding_on_re_review() -> None:
|
|
"""A new out-of-diff finding has no inline comment, but the re-review
|
|
empty-summary suppression must not swallow it — it should post a summary
|
|
review carrying the collapsed out-of-diff dropdown."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
findings = [
|
|
_f(
|
|
id="f_ood",
|
|
file="caller.py",
|
|
in_diff=False,
|
|
first_seen_sha="newsha",
|
|
github_review_comment_id=None,
|
|
github_review_id=None,
|
|
)
|
|
]
|
|
post_review = AsyncMock(return_value={"id": 555})
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=findings)),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
AsyncMock(return_value=0),
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", AsyncMock()),
|
|
patch("agent.tools.publish_review._maybe_post_slack_completion_reply", AsyncMock()),
|
|
patch("agent.tools.publish_review._resolve_review_trace_url", AsyncMock(return_value=None)),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="newsha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=True,
|
|
)
|
|
|
|
post_review.assert_awaited_once()
|
|
assert "out-of-diff" in post_review.await_args.kwargs["body"]
|
|
assert post_review.await_args.kwargs["inline_comments"] == []
|
|
assert result["success"] is True
|
|
assert result["surfaced_count"] == 0
|
|
assert result["out_of_diff_count"] == 1
|
|
assert "skipped_empty_re_review" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_skips_duplicate_empty_summary_when_open_swe_already_reviewed() -> (
|
|
None
|
|
):
|
|
"""A push landing mid-run is queued into the still-running first-review run,
|
|
whose configurable still says re_review=False. With nothing to surface, the
|
|
empty-review guard must key off the existing Open SWE review summary on the
|
|
PR (not the stale flag) so it does not post a duplicate "No issues found"."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
post_review = AsyncMock()
|
|
set_metadata = AsyncMock()
|
|
resolve_threads = AsyncMock(return_value=0)
|
|
review_exists = AsyncMock(return_value=True)
|
|
slack_reply = AsyncMock()
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=[])),
|
|
patch("agent.tools.publish_review.open_swe_review_exists", review_exists),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch("agent.tools.publish_review._resolve_threads_for_resolved_findings", resolve_threads),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", set_metadata),
|
|
patch("agent.tools.publish_review._maybe_post_slack_completion_reply", slack_reply),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="newsha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
post_review.assert_not_called()
|
|
review_exists.assert_awaited_once()
|
|
resolve_threads.assert_awaited_once()
|
|
slack_reply.assert_not_called()
|
|
assert result["success"] is True
|
|
assert result["review_id"] is None
|
|
assert result["surfaced_count"] == 0
|
|
assert result["skipped_empty_re_review"] is True
|
|
set_metadata.assert_awaited_once_with("tid", last_reviewed_sha="newsha")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_uses_resolved_head_sha_for_commit_and_last_reviewed() -> None:
|
|
"""A push that landed mid-run updates the live head in thread metadata.
|
|
publish_review must anchor the GitHub review to that head and advance
|
|
last_reviewed_sha to it, not the stale head frozen in the run config."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
finding = _f(id="f_new", file="b.py", start_line=2, end_line=2)
|
|
post_review = AsyncMock(return_value={"id": 4242})
|
|
set_metadata = AsyncMock()
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=[finding])),
|
|
patch(
|
|
"agent.tools.publish_review.resolve_review_head_sha",
|
|
AsyncMock(return_value="freshhead"),
|
|
),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch("agent.tools.publish_review.fetch_review_comments", AsyncMock(return_value=[])),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", set_metadata),
|
|
patch(
|
|
"agent.tools.publish_review._maybe_post_slack_completion_reply",
|
|
new_callable=AsyncMock,
|
|
),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="stalehead",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
langgraph_run_id="run-x",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert post_review.await_args.kwargs["head_sha"] == "freshhead"
|
|
final = set_metadata.await_args_list[-1]
|
|
assert final.args[0] == "tid"
|
|
assert final.kwargs["last_reviewed_sha"] == "freshhead"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_skips_review_existence_check_on_re_review() -> None:
|
|
"""When re_review is already True we know a prior review exists, so the
|
|
empty-review guard must short-circuit without an extra reviews API call."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
review_exists = AsyncMock(return_value=True)
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=[])),
|
|
patch("agent.tools.publish_review.open_swe_review_exists", review_exists),
|
|
patch("agent.tools.publish_review.post_pull_request_review", AsyncMock()) as post_review,
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="newsha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=True,
|
|
)
|
|
|
|
review_exists.assert_not_called()
|
|
post_review.assert_not_called()
|
|
assert result["skipped_empty_re_review"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_swe_review_exists_detects_summary_marker() -> None:
|
|
response = MagicMock()
|
|
response.json.return_value = [
|
|
{"id": 1, "body": "some human review"},
|
|
{"id": 2, "body": f"## ✅ Open SWE Review\n\n{review_summary_marker(7)}"},
|
|
]
|
|
response.raise_for_status.return_value = None
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.get = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
exists = await open_swe_review_exists(owner="o", repo="r", pr_number=7, token="t")
|
|
assert exists is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_swe_review_exists_false_without_marker() -> None:
|
|
response = MagicMock()
|
|
response.json.return_value = [{"id": 1, "body": "looks good to me"}]
|
|
response.raise_for_status.return_value = None
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.get = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
exists = await open_swe_review_exists(owner="o", repo="r", pr_number=7, token="t")
|
|
assert exists is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_swe_review_exists_fails_open_on_http_error() -> None:
|
|
import httpx
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.get = AsyncMock(side_effect=httpx.HTTPError("boom"))
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
exists = await open_swe_review_exists(owner="o", repo="r", pr_number=7, token="t")
|
|
assert exists is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_re_review_backfills_existing_marker_and_skips_duplicate_post() -> None:
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
finding = _f(id="f_old", first_seen_sha="oldsha", github_review_comment_id=None)
|
|
findings = [finding]
|
|
thread = {
|
|
"id": "THREAD_1",
|
|
"is_resolved": False,
|
|
"is_outdated": False,
|
|
"comments": [
|
|
{
|
|
"id": 101,
|
|
"author": "open-swe[bot]",
|
|
"body": render_inline_comment_body(finding),
|
|
"created_at": "2026-05-27T10:00:00Z",
|
|
}
|
|
],
|
|
}
|
|
post_review = AsyncMock()
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch(
|
|
"agent.tools.publish_review.fetch_pr_review_threads", AsyncMock(return_value=[thread])
|
|
),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=findings)),
|
|
patch("agent.reviewer_reconcile.list_findings", AsyncMock(return_value=findings)),
|
|
patch("agent.reviewer_reconcile.replace_findings", AsyncMock()),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="newsha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=True,
|
|
)
|
|
|
|
post_review.assert_not_called()
|
|
assert result["skipped_empty_re_review"] is True
|
|
assert findings[0]["github_review_comment_id"] == 101
|
|
assert findings[0]["github_review_thread_id"] == "THREAD_1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_re_review_backfills_and_resolves_duplicate_existing_threads() -> None:
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
finding = _f(
|
|
id="f_old",
|
|
first_seen_sha="oldsha",
|
|
github_review_comment_id=None,
|
|
status="resolved",
|
|
resolution_note="The duplicate threads are fixed by the latest commit.",
|
|
)
|
|
findings = [finding]
|
|
threads = [
|
|
{
|
|
"id": "THREAD_1",
|
|
"is_resolved": False,
|
|
"is_outdated": False,
|
|
"comments": [
|
|
{
|
|
"id": 101,
|
|
"author": "open-swe[bot]",
|
|
"body": render_inline_comment_body(finding),
|
|
"created_at": "2026-05-27T10:00:00Z",
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"id": "THREAD_2",
|
|
"is_resolved": False,
|
|
"is_outdated": False,
|
|
"comments": [
|
|
{
|
|
"id": 102,
|
|
"author": "open-swe[bot]",
|
|
"body": render_inline_comment_body(finding),
|
|
"created_at": "2026-05-27T10:01:00Z",
|
|
}
|
|
],
|
|
},
|
|
]
|
|
resolve_thread = AsyncMock(return_value=True)
|
|
reply_comment = AsyncMock(return_value={"id": 555})
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch(
|
|
"agent.tools.publish_review.fetch_pr_review_threads", AsyncMock(return_value=threads)
|
|
),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=findings)),
|
|
patch("agent.reviewer_reconcile.list_findings", AsyncMock(return_value=findings)),
|
|
patch("agent.reviewer_reconcile.replace_findings", AsyncMock()),
|
|
patch("agent.tools.publish_review.post_pull_request_review", AsyncMock()),
|
|
patch("agent.tools.publish_review.resolve_review_thread", resolve_thread),
|
|
patch(
|
|
"agent.tools.publish_review.fetch_review_thread_id_for_comment",
|
|
AsyncMock(return_value=None),
|
|
),
|
|
patch("agent.tools.publish_review.reply_to_review_comment", reply_comment),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="newsha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=True,
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["review_id"] is None
|
|
assert result["resolved_thread_count"] == 2
|
|
assert resolve_thread.await_count == 2
|
|
assert reply_comment.await_count == 2
|
|
assert (
|
|
reply_comment.await_args_list[0].kwargs["body"]
|
|
== "✅ **Resolved**: The duplicate threads are fixed by the latest commit."
|
|
)
|
|
assert findings[0]["github_review_comment_ids"] == [101, 102]
|
|
assert findings[0]["github_review_thread_ids"] == ["THREAD_1", "THREAD_2"]
|
|
assert findings[0]["github_resolved_thread_ids"] == ["THREAD_1", "THREAD_2"]
|
|
assert findings[0]["github_posted_resolution_comment_ids"] == [101, 102]
|
|
assert findings[0]["github_thread_resolved"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_backfills_from_threads_when_review_comments_are_empty() -> None:
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
finding = _f(id="f_new", first_seen_sha="sha")
|
|
findings = [finding]
|
|
thread = {
|
|
"id": "THREAD_1",
|
|
"is_resolved": False,
|
|
"is_outdated": False,
|
|
"comments": [
|
|
{
|
|
"id": 202,
|
|
"author": "open-swe[bot]",
|
|
"body": render_inline_comment_body(finding),
|
|
"created_at": "2026-05-27T10:00:00Z",
|
|
}
|
|
],
|
|
}
|
|
fetch_threads = AsyncMock(side_effect=[[], [thread]])
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.fetch_pr_review_threads", fetch_threads),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=findings)),
|
|
patch("agent.reviewer_reconcile.list_findings", AsyncMock(return_value=findings)),
|
|
patch("agent.reviewer_reconcile.replace_findings", AsyncMock()),
|
|
patch(
|
|
"agent.tools.publish_review.post_pull_request_review",
|
|
AsyncMock(return_value={"id": 999}),
|
|
),
|
|
patch("agent.tools.publish_review.fetch_review_comments", AsyncMock(return_value=[])),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
patch(
|
|
"agent.tools.publish_review._maybe_post_slack_completion_reply",
|
|
new_callable=AsyncMock,
|
|
),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["review_id"] == 999
|
|
assert fetch_threads.await_count == 2
|
|
assert findings[0]["github_review_id"] == 999
|
|
assert findings[0]["github_review_comment_id"] == 202
|
|
assert findings[0]["github_review_thread_id"] == "THREAD_1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_re_review_only_posts_current_head_unpublished_findings() -> None:
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
old = _f(id="f_old", first_seen_sha="oldsha", file="old.py")
|
|
new = _f(id="f_new", first_seen_sha="newsha", file="new.py")
|
|
findings = [old, new]
|
|
post_review = AsyncMock(return_value={"id": 888})
|
|
fetch_comments = AsyncMock(
|
|
return_value=[
|
|
{
|
|
"id": 303,
|
|
"path": "new.py",
|
|
"line": 10,
|
|
"body": render_inline_comment_body(new),
|
|
}
|
|
]
|
|
)
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=findings)),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch("agent.tools.publish_review.fetch_review_comments", fetch_comments),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="newsha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=True,
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["surfaced_count"] == 1
|
|
inline_comments = post_review.await_args.kwargs["inline_comments"]
|
|
assert [comment["path"] for comment in inline_comments] == ["new.py"]
|
|
assert old["github_review_id"] is None
|
|
assert new["github_review_id"] == 888
|
|
assert new["github_review_comment_id"] == 303
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_posts_summary_when_no_findings() -> None:
|
|
"""An empty findings list must still post a review so the user sees feedback."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
list_async = AsyncMock(return_value=[])
|
|
post_review = AsyncMock(return_value={"id": 555})
|
|
fetch_comments = AsyncMock(return_value=[])
|
|
set_metadata = AsyncMock()
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", list_async),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch("agent.tools.publish_review.fetch_review_comments", fetch_comments),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", set_metadata),
|
|
patch(
|
|
"agent.tools.publish_review._maybe_post_slack_completion_reply",
|
|
new_callable=AsyncMock,
|
|
),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["surfaced_count"] == 0
|
|
assert result["review_id"] == 555
|
|
post_review.assert_awaited_once()
|
|
posted_body = post_review.await_args.kwargs["body"]
|
|
posted_inline = post_review.await_args.kwargs["inline_comments"]
|
|
assert posted_inline == []
|
|
assert "No issues found" in posted_body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_posts_slack_reply_on_first_review_with_slack_ref() -> None:
|
|
"""A first review with a slack_thread metadata ref posts a one-line summary."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
metadata = {
|
|
"kind": "reviewer",
|
|
"slack_thread": {"channel_id": "C1", "thread_ts": "1234.5"},
|
|
}
|
|
slack_post = AsyncMock(return_value=True)
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=[])),
|
|
patch(
|
|
"agent.tools.publish_review.post_pull_request_review",
|
|
AsyncMock(return_value={"id": 42}),
|
|
),
|
|
patch("agent.tools.publish_review.fetch_review_comments", AsyncMock(return_value=[])),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
patch(
|
|
"agent.tools.publish_review.get_thread_metadata",
|
|
new_callable=AsyncMock,
|
|
return_value=metadata,
|
|
),
|
|
patch("agent.tools.publish_review.post_slack_thread_reply", slack_post),
|
|
):
|
|
await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
slack_post.assert_awaited_once()
|
|
args = slack_post.await_args.args
|
|
assert args[0] == "C1"
|
|
assert args[1] == "1234.5"
|
|
assert "No issues found" in args[2]
|
|
assert "https://github.com/o/r/pull/7#pullrequestreview-42" in args[2]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_uses_plural_findings_in_slack_reply() -> None:
|
|
"""Surfaced count > 1 should pluralize 'issues' in the slack summary."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
findings = [
|
|
_f(id="f1", file="a.py", start_line=1, end_line=1),
|
|
_f(id="f2", file="b.py", start_line=2, end_line=2),
|
|
]
|
|
metadata = {
|
|
"kind": "reviewer",
|
|
"slack_thread": {"channel_id": "C1", "thread_ts": "1234.5"},
|
|
}
|
|
slack_post = AsyncMock(return_value=True)
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=findings)),
|
|
patch(
|
|
"agent.tools.publish_review.post_pull_request_review",
|
|
AsyncMock(return_value={"id": 99}),
|
|
),
|
|
patch("agent.tools.publish_review.fetch_review_comments", AsyncMock(return_value=[])),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
patch(
|
|
"agent.tools.publish_review.get_thread_metadata",
|
|
new_callable=AsyncMock,
|
|
return_value=metadata,
|
|
),
|
|
patch("agent.tools.publish_review.post_slack_thread_reply", slack_post),
|
|
):
|
|
await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
slack_post.assert_awaited_once()
|
|
text = slack_post.await_args.args[2]
|
|
assert "found 2 potential issues" in text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_skips_slack_reply_on_re_review() -> None:
|
|
"""Re-reviews must NOT post to Slack even when slack_thread metadata is set."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
metadata = {
|
|
"kind": "reviewer",
|
|
"slack_thread": {"channel_id": "C1", "thread_ts": "1234.5"},
|
|
}
|
|
slack_post = AsyncMock(return_value=True)
|
|
get_metadata = AsyncMock(return_value=metadata)
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=[])),
|
|
patch(
|
|
"agent.tools.publish_review.post_pull_request_review",
|
|
AsyncMock(return_value={"id": 1}),
|
|
),
|
|
patch("agent.tools.publish_review.fetch_review_comments", AsyncMock(return_value=[])),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
patch("agent.tools.publish_review.get_thread_metadata", get_metadata),
|
|
patch("agent.tools.publish_review.post_slack_thread_reply", slack_post),
|
|
):
|
|
await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=True,
|
|
)
|
|
|
|
slack_post.assert_not_awaited()
|
|
# Re-review path should also avoid even fetching the slack metadata.
|
|
get_metadata.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_skips_slack_reply_when_no_slack_ref() -> None:
|
|
"""A review started from GitHub (no slack_thread metadata) must not post to Slack."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
slack_post = AsyncMock(return_value=True)
|
|
|
|
with (
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=[])),
|
|
patch(
|
|
"agent.tools.publish_review.post_pull_request_review",
|
|
AsyncMock(return_value={"id": 1}),
|
|
),
|
|
patch("agent.tools.publish_review.fetch_review_comments", AsyncMock(return_value=[])),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
patch(
|
|
"agent.tools.publish_review.get_thread_metadata",
|
|
new_callable=AsyncMock,
|
|
return_value={"kind": "reviewer"},
|
|
),
|
|
patch("agent.tools.publish_review.post_slack_thread_reply", slack_post),
|
|
):
|
|
await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
slack_post.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_pr_review_threads_parses_threads_and_comments() -> None:
|
|
"""GraphQL response is mapped into the simplified thread dicts."""
|
|
response = MagicMock()
|
|
response.json.return_value = {
|
|
"data": {
|
|
"repository": {
|
|
"pullRequest": {
|
|
"reviewThreads": {
|
|
"pageInfo": {"hasNextPage": False, "endCursor": None},
|
|
"nodes": [
|
|
{
|
|
"id": "THREAD_1",
|
|
"isResolved": True,
|
|
"isOutdated": False,
|
|
"path": "a/b.py",
|
|
"line": 37,
|
|
"originalLine": 37,
|
|
"comments": {
|
|
"nodes": [
|
|
{
|
|
"databaseId": 101,
|
|
"author": {"login": "open-swe[bot]"},
|
|
"authorAssociation": "MEMBER",
|
|
"body": "additionalTtlPrefixes removes lifecycle rules",
|
|
"createdAt": "2026-05-23T10:00:00Z",
|
|
},
|
|
{
|
|
"databaseId": 102,
|
|
"author": {"login": "human"},
|
|
"authorAssociation": "MEMBER",
|
|
"body": "We added defaults in the template",
|
|
"createdAt": "2026-05-24T11:00:00Z",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "THREAD_2",
|
|
"isResolved": False,
|
|
"isOutdated": False,
|
|
"path": "c.py",
|
|
"line": 9,
|
|
"originalLine": None,
|
|
"comments": {
|
|
"nodes": [
|
|
{
|
|
"databaseId": 201,
|
|
"author": {"login": "rev"},
|
|
"authorAssociation": "CONTRIBUTOR",
|
|
"body": "this looks fishy",
|
|
"createdAt": "2026-05-24T12:00:00Z",
|
|
}
|
|
]
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
response.raise_for_status.return_value = None
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
threads = await fetch_pr_review_threads(owner="o", repo="r", pr_number=1, token="t")
|
|
|
|
assert len(threads) == 2
|
|
assert threads[0]["id"] == "THREAD_1"
|
|
assert threads[0]["path"] == "a/b.py"
|
|
assert threads[0]["is_resolved"] is True
|
|
assert threads[0]["line"] == 37
|
|
assert len(threads[0]["comments"]) == 2
|
|
assert threads[0]["comments"][0]["id"] == 101
|
|
assert threads[0]["comments"][1]["author"] == "human"
|
|
assert "added defaults" in threads[0]["comments"][1]["body"]
|
|
assert threads[1]["is_resolved"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_pr_review_threads_returns_empty_on_http_error() -> None:
|
|
import httpx
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(side_effect=httpx.HTTPError("boom"))
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
threads = await fetch_pr_review_threads(owner="o", repo="r", pr_number=1, token="t")
|
|
assert threads == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_to_review_comment_posts_reply_payload() -> None:
|
|
response = MagicMock()
|
|
response.status_code = 201
|
|
response.json.return_value = {"id": 456, "body": "Thanks for the context."}
|
|
response.raise_for_status.return_value = None
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
result = await reply_to_review_comment(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
review_comment_id=123,
|
|
body="Thanks for the context.",
|
|
token="t",
|
|
)
|
|
|
|
assert result == {"id": 456, "body": "Thanks for the context."}
|
|
args = client_cm.post.await_args
|
|
assert args.args[0] == "https://api.github.com/repos/o/r/pulls/7/comments/123/replies"
|
|
assert args.kwargs["json"] == {"body": "Thanks for the context."}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_pull_request_review_tags_unresolved_anchor_on_422() -> None:
|
|
"""A GitHub 422 with 'Path could not be resolved' must be tagged as
|
|
``unresolved_anchor`` and carry the raw errors so the tool layer can act
|
|
on it (drop offending findings + retry) instead of bubbling an opaque
|
|
error string that the agent will only retry with identical args."""
|
|
import httpx
|
|
|
|
response = MagicMock()
|
|
response.status_code = 422
|
|
response.text = '{"errors":["Path could not be resolved"]}'
|
|
response.json.return_value = {"errors": ["Path could not be resolved"]}
|
|
response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Unprocessable Entity",
|
|
request=MagicMock(),
|
|
response=response,
|
|
)
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
result = await post_pull_request_review(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=1,
|
|
head_sha="sha",
|
|
body="b",
|
|
inline_comments=[{"path": "missing.py", "line": 1, "side": "RIGHT", "body": "x"}],
|
|
token="t",
|
|
)
|
|
|
|
assert isinstance(result, dict)
|
|
assert result.get("_error_kind") == "unresolved_anchor"
|
|
assert result.get("_status") == 422
|
|
assert result.get("_raw_errors") == ["Path could not be resolved"]
|
|
assert "HTTP 422" in result.get("_error", "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_pull_request_review_tags_unresolved_anchor_on_line_error() -> None:
|
|
"""A 'Line could not be resolved' 422 must also be tagged as
|
|
``unresolved_anchor`` so a line that's not in the diff is treated the same
|
|
way as a path that's not in the diff."""
|
|
import httpx
|
|
|
|
response = MagicMock()
|
|
response.status_code = 422
|
|
response.text = '{"errors":["Line could not be resolved"]}'
|
|
response.json.return_value = {"errors": ["Line could not be resolved"]}
|
|
response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Unprocessable Entity",
|
|
request=MagicMock(),
|
|
response=response,
|
|
)
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
result = await post_pull_request_review(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=1,
|
|
head_sha="sha",
|
|
body="b",
|
|
inline_comments=[],
|
|
token="t",
|
|
)
|
|
|
|
assert isinstance(result, dict)
|
|
assert result.get("_error_kind") == "unresolved_anchor"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_pull_request_review_does_not_tag_unrelated_422() -> None:
|
|
"""A 422 whose errors don't match the anchor patterns must NOT be tagged
|
|
as ``unresolved_anchor`` — the retry path is only safe for known
|
|
per-comment anchor failures."""
|
|
import httpx
|
|
|
|
response = MagicMock()
|
|
response.status_code = 422
|
|
response.text = '{"errors":["something else"]}'
|
|
response.json.return_value = {"errors": ["something else"]}
|
|
response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Unprocessable Entity",
|
|
request=MagicMock(),
|
|
response=response,
|
|
)
|
|
|
|
client_cm = AsyncMock()
|
|
client_cm.__aenter__.return_value = client_cm
|
|
client_cm.post = AsyncMock(return_value=response)
|
|
|
|
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
|
result = await post_pull_request_review(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=1,
|
|
head_sha="sha",
|
|
body="b",
|
|
inline_comments=[],
|
|
token="t",
|
|
)
|
|
|
|
assert isinstance(result, dict)
|
|
assert result.get("_error_kind") is None
|
|
assert result.get("_raw_errors") == ["something else"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_drops_unresolvable_findings_and_retries_once() -> None:
|
|
"""When GitHub rejects the batch with an ``unresolved_anchor`` 422, the
|
|
tool must filter the bad findings against the PR diff_line_set, re-POST
|
|
with only the valid ones, return ``success=True``, and report the dropped
|
|
finding ids via ``unresolvable_findings`` plus a corrective hint."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
findings = [
|
|
_f(id="f_good", severity="high", file="in_diff.py", start_line=10, end_line=10),
|
|
_f(id="f_bad", severity="high", file="not_in_diff.py", start_line=99, end_line=99),
|
|
]
|
|
# The PR diff only covers in_diff.py:10. f_bad anchors to a file/line not
|
|
# in the diff, so it must be dropped on retry.
|
|
diff_line_set = {"in_diff.py": {"RIGHT": {10}, "LEFT": set()}}
|
|
|
|
first_response = {
|
|
"_error": "HTTP 422: ...",
|
|
"_error_kind": "unresolved_anchor",
|
|
"_raw_errors": ["Path could not be resolved"],
|
|
"_status": 422,
|
|
}
|
|
retry_response = {"id": 7777}
|
|
post_review = AsyncMock(side_effect=[first_response, retry_response])
|
|
fetch_comments = AsyncMock(return_value=[])
|
|
set_metadata = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"agent.tools.publish_review.get_config",
|
|
return_value={
|
|
"configurable": {
|
|
"thread_id": "tid",
|
|
"diff_line_set": diff_line_set,
|
|
},
|
|
},
|
|
),
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch(
|
|
"agent.tools.publish_review.list_findings_async",
|
|
AsyncMock(return_value=findings),
|
|
),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch("agent.tools.publish_review.fetch_review_comments", fetch_comments),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch(
|
|
"agent.tools.publish_review._store_thread_ids_on_findings",
|
|
new_callable=AsyncMock,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", set_metadata),
|
|
patch(
|
|
"agent.tools.publish_review._maybe_post_slack_completion_reply",
|
|
new_callable=AsyncMock,
|
|
),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
assert post_review.await_count == 2
|
|
# Retry must contain only the in-diff finding.
|
|
retry_inline = post_review.await_args_list[1].kwargs["inline_comments"]
|
|
assert {c["path"] for c in retry_inline} == {"in_diff.py"}
|
|
assert result["success"] is True
|
|
assert result["review_id"] == 7777
|
|
assert result["surfaced_count"] == 1
|
|
assert result["unresolvable_findings"] == ["f_bad"]
|
|
assert "update_finding" in result["hint"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_reports_unresolvable_when_retry_still_fails() -> None:
|
|
"""If even the filtered retry fails, the tool surfaces
|
|
``success=False`` plus the offending finding ids and a hint — it must
|
|
NOT collapse into the opaque retry-with-same-args loop."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
findings = [
|
|
_f(id="f_good", severity="high", file="in_diff.py", start_line=10, end_line=10),
|
|
_f(id="f_bad", severity="high", file="not_in_diff.py", start_line=99, end_line=99),
|
|
]
|
|
diff_line_set = {"in_diff.py": {"RIGHT": {10}, "LEFT": set()}}
|
|
|
|
first_response = {
|
|
"_error": "HTTP 422: ...",
|
|
"_error_kind": "unresolved_anchor",
|
|
"_raw_errors": ["Path could not be resolved"],
|
|
"_status": 422,
|
|
}
|
|
retry_response = {"_error": "HTTP 500: boom"}
|
|
post_review = AsyncMock(side_effect=[first_response, retry_response])
|
|
|
|
with (
|
|
patch(
|
|
"agent.tools.publish_review.get_config",
|
|
return_value={
|
|
"configurable": {
|
|
"thread_id": "tid",
|
|
"diff_line_set": diff_line_set,
|
|
},
|
|
},
|
|
),
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch(
|
|
"agent.tools.publish_review.list_findings_async",
|
|
AsyncMock(return_value=findings),
|
|
),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["unresolvable_findings"] == ["f_bad"]
|
|
assert "update_finding" in result["hint"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_does_not_retry_when_no_findings_can_be_dropped() -> None:
|
|
"""When the unresolved_anchor 422 fires but the diff_line_set rules out
|
|
no findings (e.g., diff data unavailable), the tool must NOT retry — it
|
|
must surface the structured error so the agent stops looping."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
findings = [
|
|
_f(id="f_only", severity="high", file="in_diff.py", start_line=10, end_line=10),
|
|
]
|
|
# No cached diff_line_set, and the on-demand fetch fails — no way to tell
|
|
# which finding is bad.
|
|
first_response = {
|
|
"_error": "HTTP 422: ...",
|
|
"_error_kind": "unresolved_anchor",
|
|
"_raw_errors": ["Path could not be resolved"],
|
|
"_status": 422,
|
|
}
|
|
post_review = AsyncMock(return_value=first_response)
|
|
|
|
with (
|
|
patch(
|
|
"agent.tools.publish_review.get_config",
|
|
return_value={"configurable": {"thread_id": "tid"}},
|
|
),
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch(
|
|
"agent.tools.publish_review.list_findings_async",
|
|
AsyncMock(return_value=findings),
|
|
),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_diff_line_set",
|
|
new_callable=AsyncMock,
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
# Only one attempt — never retry blindly.
|
|
assert post_review.await_count == 1
|
|
assert result["success"] is False
|
|
assert result["unresolvable_findings"] == []
|
|
assert "update_finding" in result["hint"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_review_fetches_pr_diff_when_diff_line_set_missing() -> None:
|
|
"""Reviewer runs clear ``diff_line_set`` from config before the agent
|
|
starts, so the publish-time retry path must fall back to fetching the
|
|
PR's unified diff on demand and recomputing the line set — otherwise no
|
|
finding is ever droppable and the retry surfaces empty
|
|
``unresolvable_findings`` for the reachable production case."""
|
|
from agent.tools.publish_review import _publish_review_async
|
|
|
|
findings = [
|
|
_f(id="f_good", severity="high", file="in_diff.py", start_line=10, end_line=10),
|
|
_f(id="f_bad", severity="high", file="not_in_diff.py", start_line=99, end_line=99),
|
|
]
|
|
first_response = {
|
|
"_error": "HTTP 422: ...",
|
|
"_error_kind": "unresolved_anchor",
|
|
"_raw_errors": ["Path could not be resolved"],
|
|
"_status": 422,
|
|
}
|
|
retry_response = {"id": 9999}
|
|
post_review = AsyncMock(side_effect=[first_response, retry_response])
|
|
|
|
pr_diff = (
|
|
"diff --git a/in_diff.py b/in_diff.py\n"
|
|
"--- a/in_diff.py\n"
|
|
"+++ b/in_diff.py\n"
|
|
"@@ -1,1 +10,1 @@\n"
|
|
"+touched\n"
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"agent.tools.publish_review.get_config",
|
|
return_value={"configurable": {"thread_id": "tid"}},
|
|
),
|
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
|
patch(
|
|
"agent.tools.publish_review.list_findings_async",
|
|
AsyncMock(return_value=findings),
|
|
),
|
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
|
patch(
|
|
"agent.tools.publish_review.fetch_pr_diff",
|
|
AsyncMock(return_value=pr_diff),
|
|
),
|
|
patch("agent.tools.publish_review.fetch_review_comments", AsyncMock(return_value=[])),
|
|
patch(
|
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
|
new_callable=AsyncMock,
|
|
return_value=0,
|
|
),
|
|
patch(
|
|
"agent.tools.publish_review._store_thread_ids_on_findings",
|
|
new_callable=AsyncMock,
|
|
),
|
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
|
patch(
|
|
"agent.tools.publish_review._maybe_post_slack_completion_reply",
|
|
new_callable=AsyncMock,
|
|
),
|
|
):
|
|
result = await _publish_review_async(
|
|
owner="o",
|
|
repo="r",
|
|
pr_number=7,
|
|
head_sha="sha",
|
|
token="t",
|
|
severity_threshold="medium",
|
|
cap=15,
|
|
is_re_review=False,
|
|
)
|
|
|
|
assert post_review.await_count == 2
|
|
retry_inline = post_review.await_args_list[1].kwargs["inline_comments"]
|
|
assert {c["path"] for c in retry_inline} == {"in_diff.py"}
|
|
assert result["success"] is True
|
|
assert result["unresolvable_findings"] == ["f_bad"]
|