fix(reviewer): harden verdict enforcement

This commit is contained in:
Adam Moussa 2026-07-31 13:02:07 -04:00 • committed by Adam Moussa
parent eb91104db8
commit b2b5957233
6 changed files with 531 additions and 215 deletions

View file

@ -82,7 +82,7 @@ def normalize_finding_title(title: str | None, description: str = "") -> str:
Severity = Literal["low", "medium", "high", "critical"]
Confidence = Literal["low", "medium", "high"]
FindingStatus = Literal["open", "resolved", "dismissed"]
FindingStatus = Literal["open", "needs_reassessment", "resolved", "dismissed"]
DiffSide = Literal["LEFT", "RIGHT"]
SurfaceState = Literal["not_surfaced", "surfaced", "resolve_pending", "resolved", "error"]
InteractionKind = Literal["human_reply", "bot_reply"]

View file

@ -540,6 +540,28 @@ async def open_swe_review_exists(
params["page"] += 1
async def fetch_pull_request_head_sha(
*,
owner: str,
repo: str,
pr_number: int,
token: str,
) -> str | None:
"""Fetch the PR head directly from GitHub immediately before a verdict."""
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}"
async with github_client(token=token) as client:
try:
response = await github_request(client, "GET", url)
response.raise_for_status()
except httpx.HTTPError:
logger.exception("Failed to fetch live PR head for %s/%s#%s", owner, repo, pr_number)
return None
payload = response.json()
head = payload.get("head") if isinstance(payload, dict) else None
sha = head.get("sha") if isinstance(head, dict) else None
return sha if isinstance(sha, str) and sha else None
_REVIEW_EVENTS = {"COMMENT", "APPROVE", "REQUEST_CHANGES"}
@ -636,6 +658,33 @@ async def post_pull_request_review(
return {"_error": (f"HTTP {response.status_code}: non-dict response body: {body_excerpt}")}
async def update_pull_request_review_body(
*,
owner: str,
repo: str,
pr_number: int,
review_id: int,
body: str,
token: str,
) -> bool:
"""Replace a submitted review body with its recorded GitHub outcome."""
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}"
async with github_client(token=token) as client:
try:
response = await github_request(client, "PUT", url, json={"body": body})
response.raise_for_status()
except httpx.HTTPError:
logger.exception(
"Failed to update PR review body for %s/%s#%s review %s",
owner,
repo,
pr_number,
review_id,
)
return False
return True
async def dismiss_pull_request_review(
*,
owner: str,

View file

@ -176,12 +176,28 @@ def _sync_thread_status(finding: Finding, matches: list[ReviewThreadMatch]) -> b
if resolved_thread_ids != _str_list(finding.get("github_resolved_thread_ids")):
finding["github_resolved_thread_ids"] = resolved_thread_ids
if not all_resolved:
status = finding.get("status", "open")
if status in {"open", "needs_reassessment"}:
if status != "needs_reassessment":
finding["status"] = "needs_reassessment"
updated = True
note = (
"GitHub review thread was resolved or outdated by the pull request author; "
"reassess the finding before changing its status."
)
if finding.get("last_reconciliation_note") != note:
finding["last_reconciliation_note"] = note
updated = True
if isinstance(finding.get("id"), str):
surface = _coerce_surface(finding, str(finding["id"]))
if surface.get("state") != "surfaced":
surface["state"] = "surfaced"
updated = True
finding["surface"] = surface
return updated
if finding.get("status") == "open":
finding["status"] = "resolved"
updated = True
if not all_resolved:
return updated
if not finding.get("github_thread_resolved"):
finding["github_thread_resolved"] = True
updated = True

View file

@ -16,7 +16,10 @@ from ..review.findings import (
Finding,
ReviewerThreadMissingError,
Severity,
_coerce_findings_list,
_coerce_surface,
_finding_mutation_lock,
_get_thread_metadata_strict,
filter_findings_for_publish,
get_thread_id_from_runtime,
get_thread_last_reviewed_sha,
@ -35,6 +38,7 @@ from ..review.publish import (
clear_review_started_comment,
dismiss_pull_request_review,
fetch_pr_review_threads,
fetch_pull_request_head_sha,
fetch_review_comments,
fetch_review_thread_id_for_comment,
open_swe_review_exists,
@ -46,6 +50,7 @@ from ..review.publish import (
reply_to_review_comment,
resolve_review_thread,
settle_review_check_run,
update_pull_request_review_body,
)
from ..review.reconcile import reconcile_findings_with_review_threads
from ..utils.dashboard_links import dashboard_review_url
@ -90,16 +95,12 @@ async def publish_review(
mentioned in the review summary with a link to the web app, but are
not posted as inline PR comments.
verdict: Optional review verdict — ``"approve"`` or
``"request_changes"``. ``"request_changes"`` is honored ONLY when
this run was explicitly authorized to submit a verdict (the
triggering user asked for one). ``"approve"`` is also honored on a
run without that authorization when the review is clean — zero
open findings — so a clean review lands as a real APPROVE; with
open findings an unsolicited approve is downgraded to a plain
comment and the result carries ``verdict_ignored: true``. Never
describe an ignored verdict as an approval. Verdicts are also
downgraded to a comment when the PR was authored by Open SWE
itself (self-review).
``"request_changes"``. Explicitly requested verdicts are honored
subject to the safety checks below. Automatically authorized
verdicts must agree with authoritative finding state: approve
requires no open findings and request_changes requires at least
one. A downgraded verdict is posted as a comment review and carries
``verdict_ignored: true``.
Returns:
Dictionary with ``success``, ``review_id``, ``surfaced_count``,
``hidden_count``, ``resolved_thread_count``, and sometimes
@ -123,9 +124,10 @@ async def publish_review(
``verdict_submitted`` (GitHub confirmed the requested APPROVE/
REQUEST_CHANGES state) or ``verdict_ignored`` +
``verdict_ignored_reason`` (``"verdict_not_requested"``,
``"approve_with_open_findings"`` — an unsolicited approve on a run
with open findings — ``"self_review"``, ``"head_moved"`` — the
reviewed commit is no longer the PR head — or ``"author_unknown"``).
``"approve_with_open_findings"``,
``"request_changes_without_open_findings"``, ``"self_review"``,
``"head_moved"`` — the reviewed commit is no longer the PR head —
``"author_unknown"``, or ``"github_state_mismatch"``).
"""
if severity_threshold not in {"low", "medium", "high", "critical"}:
return {"success": False, "error": f"Invalid severity_threshold: {severity_threshold}"}
@ -182,24 +184,12 @@ async def publish_review(
if not token:
return {"success": False, "error": "No GitHub token available"}
# Verdict authorization is enforced here in code, not in the prompt.
# Explicit requests may submit either verdict. Automatic approvals require
# the dispatch-set verdict_authorized flag and still pass the clean-review
# gate in _publish_review_async.
verdict_not_requested = False
unsolicited_approve = False
if verdict is not None and configurable.get("verdict_requested") is not True:
if verdict == "approve" and configurable.get("verdict_authorized") is True:
unsolicited_approve = True
else:
logger.info(
"publish_review verdict %r dropped: run not authorized to submit verdicts "
"(pr_number=%s)",
verdict,
pr_number,
)
verdict = None
verdict_not_requested = True
if configurable.get("verdict_requested") is True:
verdict_authorization = "requested"
elif configurable.get("verdict_authorized") is True:
verdict_authorization = "consistent"
else:
verdict_authorization = "none"
try:
result = await _publish_review_async(
@ -215,12 +205,8 @@ async def publish_review(
trace_link_config_override=configurable.get("review_trace_link_enabled"),
verdict=verdict,
verdict_requester=str(configurable.get("github_login") or ""),
unsolicited_approve=unsolicited_approve,
verdict_authorization=verdict_authorization,
)
if verdict_not_requested:
result["verdict_ignored"] = True
result["verdict_ignored_reason"] = "verdict_not_requested"
result["verdict_submitted"] = False
return result
except ReviewerThreadMissingError as exc:
return thread_missing_tool_result(exc)
@ -320,10 +306,11 @@ async def _publish_review_async(
trace_link_config_override: object = None,
verdict: str | None = None,
verdict_requester: str = "",
unsolicited_approve: bool = False,
verdict_authorization: str = "requested",
) -> dict[str, Any]:
thread_id = get_thread_id_from_runtime()
verdict_ignored_reason: str | None = None
verdict_attempted = verdict is not None
reviewed_head_sha = head_sha
# The run config's head_sha is frozen at run creation; a push that arrived
# mid-run updated the live head in thread metadata. Prefer that so the
@ -331,56 +318,6 @@ async def _publish_review_async(
# reviewed, not the stale one this run was created for.
head_sha = await resolve_review_head_sha(thread_id, {"head_sha": head_sha})
if verdict is not None:
metadata = await get_thread_metadata(thread_id)
# A verdict is merge-affecting (a real APPROVE/REQUEST_CHANGES), so it
# must reflect exactly the commit the agent reviewed. If a push landed
# mid-run and moved the head, the resolved head no longer matches what
# this run examined — downgrade to a comment rather than stamp an
# approval onto unreviewed code (and let the push's own re-review submit
# a fresh verdict). A plain comment publish still safely retargets.
if reviewed_head_sha and head_sha and head_sha != reviewed_head_sha:
logger.info(
"publish_review verdict %r downgraded to comment: head moved %s -> %s "
"mid-run for %s/%s#%s",
verdict,
reviewed_head_sha,
head_sha,
owner,
repo,
pr_number,
)
verdict = None
verdict_ignored_reason = "head_moved"
else:
pr_author = _pr_author_from_thread(metadata)
bot_logins = {login.casefold() for login in INTERNAL_BOT_LOGINS}
if not pr_author:
# Fail closed: a verdict on a PR whose author we cannot confirm
# might be a self-review on an Open SWE PR. Downgrade rather than
# risk approving our own work.
logger.info(
"publish_review verdict %r downgraded to comment: PR author "
"unknown for %s/%s#%s",
verdict,
owner,
repo,
pr_number,
)
verdict = None
verdict_ignored_reason = "author_unknown"
elif pr_author.casefold() in bot_logins:
logger.info(
"publish_review verdict %r downgraded to comment: PR %s/%s#%s was "
"authored by internal bot %r (self-review)",
verdict,
owner,
repo,
pr_number,
pr_author,
)
verdict = None
verdict_ignored_reason = "self_review"
findings = await _backfill_findings_from_pr_threads(
thread_id=thread_id,
owner=owner,
@ -389,25 +326,6 @@ async def _publish_review_async(
token=token,
)
# An unsolicited approve (no verdict_requested on the run) is honored only
# for a clean review: any open finding — new, previously published, or
# below the surfacing threshold — means changes are effectively being
# requested, so the approve downgrades to a comment.
if verdict == "approve" and unsolicited_approve:
open_findings_count = sum(1 for f in findings if f.get("status", "open") == "open")
if open_findings_count:
logger.info(
"publish_review unsolicited approve downgraded to comment: %d open "
"finding(s) for %s/%s#%s",
open_findings_count,
owner,
repo,
pr_number,
)
verdict = None
verdict_ignored_reason = "approve_with_open_findings"
event = _VERDICT_EVENTS.get(verdict or "", "COMMENT")
review_trace_url = await _resolve_review_trace_url(thread_id, trace_link_config_override)
review_ui_url = dashboard_review_url(owner, repo, pr_number)
@ -465,7 +383,7 @@ async def _publish_review_async(
# plain comment publishes.
if (
not inline_comments
and verdict is None
and not verdict_attempted
and await _open_swe_already_reviewed(
thread_id=thread_id,
owner=owner,
@ -508,21 +426,15 @@ async def _publish_review_async(
skip_result["verdict_ignored_reason"] = verdict_ignored_reason
return skip_result
review_body = _decorate_review_body(
render_review_body(
pr_number=pr_number,
surfaced_count=len(inline_comments),
trace_url=review_trace_url,
ui_url=review_ui_url,
additional_findings_count=additional_findings_count,
),
verdict=verdict,
verdict_requester=verdict_requester,
verdict_ignored_reason=verdict_ignored_reason,
unsolicited_approve=unsolicited_approve,
review_body = render_review_body(
pr_number=pr_number,
surfaced_count=len(inline_comments),
trace_url=review_trace_url,
ui_url=review_ui_url,
additional_findings_count=additional_findings_count,
)
review_response = await post_pull_request_review(
review_response, event, verdict_ignored_reason, findings = await _post_review_guarded(
thread_id=thread_id,
owner=owner,
repo=repo,
pr_number=pr_number,
@ -530,7 +442,10 @@ async def _publish_review_async(
body=review_body,
inline_comments=inline_comments,
token=token,
event=event,
verdict=verdict,
verdict_authorization=verdict_authorization,
verdict_requester=verdict_requester,
reviewed_head_sha=reviewed_head_sha,
)
# If GitHub rejected the batch because one or more inline comments anchor
# to a file/line that's not in the PR diff, drop just those findings and
@ -550,20 +465,15 @@ async def _publish_review_async(
)
if dropped_ids and valid_with_payload:
retry_inline = [p for _, p in valid_with_payload]
retry_body = _decorate_review_body(
render_review_body(
pr_number=pr_number,
surfaced_count=len(retry_inline),
trace_url=review_trace_url,
ui_url=review_ui_url,
additional_findings_count=additional_findings_count,
),
verdict=verdict,
verdict_requester=verdict_requester,
verdict_ignored_reason=verdict_ignored_reason,
unsolicited_approve=unsolicited_approve,
retry_body = render_review_body(
pr_number=pr_number,
surfaced_count=len(retry_inline),
trace_url=review_trace_url,
ui_url=review_ui_url,
additional_findings_count=additional_findings_count,
)
retry_response = await post_pull_request_review(
retry_response, event, verdict_ignored_reason, findings = await _post_review_guarded(
thread_id=thread_id,
owner=owner,
repo=repo,
pr_number=pr_number,
@ -571,10 +481,14 @@ async def _publish_review_async(
body=retry_body,
inline_comments=retry_inline,
token=token,
event=event,
verdict=verdict,
verdict_authorization=verdict_authorization,
verdict_requester=verdict_requester,
reviewed_head_sha=reviewed_head_sha,
)
if isinstance(retry_response, dict) and "_error" not in retry_response:
review_response = retry_response
review_body = retry_body
inline_comments = retry_inline
eligible_with_payload = valid_with_payload
unresolvable_findings = dropped_ids
@ -598,20 +512,20 @@ async def _publish_review_async(
# diff. GitHub accepts a bodied review with zero inline comments, so
# post the authorized verdict rather than dropping it — the verdict
# must land even when the findings can't be anchored.
verdict_only_body = _decorate_review_body(
render_review_body(
pr_number=pr_number,
surfaced_count=0,
trace_url=review_trace_url,
ui_url=review_ui_url,
additional_findings_count=additional_findings_count,
),
verdict=verdict,
verdict_requester=verdict_requester,
verdict_ignored_reason=verdict_ignored_reason,
unsolicited_approve=unsolicited_approve,
verdict_only_body = render_review_body(
pr_number=pr_number,
surfaced_count=0,
trace_url=review_trace_url,
ui_url=review_ui_url,
additional_findings_count=additional_findings_count,
)
verdict_only_response = await post_pull_request_review(
(
verdict_only_response,
event,
verdict_ignored_reason,
findings,
) = await _post_review_guarded(
thread_id=thread_id,
owner=owner,
repo=repo,
pr_number=pr_number,
@ -619,10 +533,14 @@ async def _publish_review_async(
body=verdict_only_body,
inline_comments=[],
token=token,
event=event,
verdict=verdict,
verdict_authorization=verdict_authorization,
verdict_requester=verdict_requester,
reviewed_head_sha=reviewed_head_sha,
)
if isinstance(verdict_only_response, dict) and "_error" not in verdict_only_response:
review_response = verdict_only_response
review_body = verdict_only_body
inline_comments = []
eligible_with_payload = []
unresolvable_findings = dropped_ids
@ -665,19 +583,34 @@ async def _publish_review_async(
}
review_id = review_response.get("id") if isinstance(review_response, dict) else None
# Trust GitHub's recorded review state, not the event we asked for: GitHub
# can accept the POST (returning an id) yet land the review as COMMENTED
# (e.g. a same-identity re-approval). Only claim a verdict when the returned
# state actually matches. When the response omits state, fall back to the
# requested event so a valid submission isn't under-reported.
# Trust GitHub's recorded review state, not the event we asked for.
returned_state = review_response.get("state") if isinstance(review_response, dict) else None
recorded_state = returned_state.upper() if isinstance(returned_state, str) else ""
expected_state = _EVENT_TO_STATE.get(event)
if expected_state is None:
verdict_submitted = False
elif isinstance(returned_state, str) and returned_state:
verdict_submitted = returned_state.upper() == expected_state and review_id is not None
else:
verdict_submitted = review_id is not None
verdict_submitted = bool(
verdict_attempted
and expected_state
and recorded_state == expected_state
and review_id is not None
)
if verdict_attempted and not verdict_submitted and verdict_ignored_reason is None:
verdict_ignored_reason = "github_state_mismatch"
if verdict_attempted and isinstance(review_id, int) and recorded_state:
await update_pull_request_review_body(
owner=owner,
repo=repo,
pr_number=pr_number,
review_id=review_id,
body=_decorate_recorded_review_body(
review_body,
recorded_state=recorded_state,
verdict=verdict,
verdict_requester=verdict_requester,
verdict_submitted=verdict_submitted,
verdict_ignored_reason=verdict_ignored_reason,
),
token=token,
)
await _reconcile_last_verdict(
thread_id=thread_id,
owner=owner,
@ -776,6 +709,8 @@ async def _publish_review_async(
"hidden_count": max(len(open_unpublished) - len(inline_comments), 0),
"resolved_thread_count": resolved_thread_count,
}
if recorded_state:
result["review_state"] = recorded_state
if verdict_submitted:
result["verdict_submitted"] = True
result["verdict_event"] = event
@ -792,6 +727,110 @@ async def _publish_review_async(
return result
def _finding_blocks_verdict(finding: Finding) -> bool:
status = finding.get("status", "open")
if status in {"open", "needs_reassessment"}:
return True
interactions = finding.get("interactions")
if not isinstance(interactions, list) or not interactions:
return False
latest = interactions[-1]
return isinstance(latest, dict) and latest.get("needs_reassessment") is True
async def _post_review_guarded(
*,
thread_id: str,
owner: str,
repo: str,
pr_number: int,
head_sha: str,
body: str,
inline_comments: list[dict[str, Any]],
token: str,
verdict: str | None,
verdict_authorization: str,
verdict_requester: str,
reviewed_head_sha: str,
) -> tuple[dict[str, Any] | None, str, str | None, list[Finding]]:
if verdict is None:
response = await post_pull_request_review(
owner=owner,
repo=repo,
pr_number=pr_number,
head_sha=head_sha,
body=body,
inline_comments=inline_comments,
token=token,
event="COMMENT",
)
return response, "COMMENT", None, await list_findings_async(thread_id)
async with _finding_mutation_lock(thread_id):
metadata = await _get_thread_metadata_strict(thread_id)
findings = _coerce_findings_list(metadata.get("findings"))
submitted_verdict = verdict
ignored_reason: str | None = None
post_head_sha = head_sha
if verdict_authorization == "none":
submitted_verdict = None
ignored_reason = "verdict_not_requested"
elif verdict_authorization == "consistent":
open_count = sum(1 for finding in findings if _finding_blocks_verdict(finding))
if verdict == "approve" and open_count:
submitted_verdict = None
ignored_reason = "approve_with_open_findings"
elif verdict == "request_changes" and not open_count:
submitted_verdict = None
ignored_reason = "request_changes_without_open_findings"
if submitted_verdict is not None:
pr_author = _pr_author_from_thread(metadata)
bot_logins = {login.casefold() for login in INTERNAL_BOT_LOGINS}
if not pr_author:
submitted_verdict = None
ignored_reason = "author_unknown"
elif pr_author.casefold() in bot_logins:
submitted_verdict = None
ignored_reason = "self_review"
if submitted_verdict is not None and head_sha != reviewed_head_sha:
submitted_verdict = None
ignored_reason = "head_moved"
if submitted_verdict is not None:
live_head_sha = await fetch_pull_request_head_sha(
owner=owner,
repo=repo,
pr_number=pr_number,
token=token,
)
if live_head_sha is not None and live_head_sha != reviewed_head_sha:
submitted_verdict = None
ignored_reason = "head_moved"
post_head_sha = live_head_sha
event = _VERDICT_EVENTS.get(submitted_verdict or "", "COMMENT")
decorated_body = _decorate_review_body(
body,
verdict=submitted_verdict,
verdict_requester=verdict_requester,
verdict_ignored_reason=ignored_reason,
)
response = await post_pull_request_review(
owner=owner,
repo=repo,
pr_number=pr_number,
head_sha=post_head_sha,
body=decorated_body,
inline_comments=inline_comments,
token=token,
event=event,
)
return response, event, ignored_reason, findings
async def _open_swe_already_reviewed(
*,
thread_id: str,
@ -839,23 +878,39 @@ def _decorate_review_body(
verdict: str | None,
verdict_requester: str,
verdict_ignored_reason: str | None,
unsolicited_approve: bool = False,
) -> str:
"""Append verdict attribution / downgrade context to the review body."""
if verdict is not None:
if unsolicited_approve:
return f"{body}\n\nVerdict (`approve`) submitted — the review found no open issues."
requester = f"@{verdict_requester}" if verdict_requester else "the requester"
return f"{body}\n\nVerdict (`{verdict}`) submitted at the request of {requester}."
if verdict_ignored_reason == "self_review":
return (
f"{body}\n\n> Note: a review verdict was requested, but Open SWE does not "
"approve or request changes on its own pull requests. Published as a "
"comment review instead."
)
return f"{body}\n\nVerdict (`{verdict}`) requested by {requester}."
if verdict_ignored_reason:
reason = verdict_ignored_reason.replace("_", " ")
return f"{body}\n\n> Verdict withheld ({reason}). Published as a comment review."
return body
def _decorate_recorded_review_body(
body: str,
*,
recorded_state: str,
verdict: str | None,
verdict_requester: str,
verdict_submitted: bool,
verdict_ignored_reason: str | None,
) -> str:
if verdict_submitted and verdict is not None:
requester = f"@{verdict_requester}" if verdict_requester else "the requester"
return (
f"{body}\n\nReview outcome: **{recorded_state}**. "
f"Verdict (`{verdict}`) recorded for {requester}."
)
reason = (verdict_ignored_reason or "github_state_mismatch").replace("_", " ")
return (
f"{body}\n\nReview outcome: **{recorded_state}**. "
f"The requested verdict was withheld ({reason})."
)
async def _reconcile_last_verdict(
*,
thread_id: str,

View file

@ -2431,14 +2431,12 @@ async def test_publish_review_drops_verdict_when_not_requested() -> None:
):
result = await publish_review(verdict="request_changes")
assert publish_async.call_args.kwargs["verdict"] is None
assert result["verdict_ignored"] is True
assert result["verdict_ignored_reason"] == "verdict_not_requested"
assert result["verdict_submitted"] is False
assert publish_async.call_args.kwargs["verdict"] == "request_changes"
assert publish_async.call_args.kwargs["verdict_authorization"] == "none"
assert result["success"] is True
async def test_publish_review_forwards_unsolicited_approve() -> None:
"""A dispatch-authorized approve reaches the clean-review gate."""
async def test_publish_review_forwards_consistency_authorized_approve() -> None:
from agent.tools.publish_review import publish_review
publish_async = AsyncMock(
@ -2464,12 +2462,12 @@ async def test_publish_review_forwards_unsolicited_approve() -> None:
result = await publish_review(verdict="approve")
assert publish_async.call_args.kwargs["verdict"] == "approve"
assert publish_async.call_args.kwargs["unsolicited_approve"] is True
assert publish_async.call_args.kwargs["verdict_authorization"] == "consistent"
assert result["verdict_submitted"] is True
assert "verdict_ignored" not in result
async def test_publish_review_drops_unauthorized_unsolicited_approve() -> None:
async def test_publish_review_marks_unauthorized_approve_for_downgrade() -> None:
from agent.tools.publish_review import publish_review
publish_async = AsyncMock(return_value={"success": True, "review_id": 9})
@ -2491,11 +2489,9 @@ async def test_publish_review_drops_unauthorized_unsolicited_approve() -> None:
):
result = await publish_review(verdict="approve")
assert publish_async.call_args.kwargs["verdict"] is None
assert publish_async.call_args.kwargs["unsolicited_approve"] is False
assert result["verdict_ignored"] is True
assert result["verdict_ignored_reason"] == "verdict_not_requested"
assert result["verdict_submitted"] is False
assert publish_async.call_args.kwargs["verdict"] == "approve"
assert publish_async.call_args.kwargs["verdict_authorization"] == "none"
assert result["success"] is True
async def test_publish_review_forwards_authorized_verdict() -> None:
@ -2526,6 +2522,7 @@ async def test_publish_review_forwards_authorized_verdict() -> None:
assert publish_async.call_args.kwargs["verdict"] == "approve"
assert publish_async.call_args.kwargs["verdict_requester"] == "amoussa1229"
assert publish_async.call_args.kwargs["verdict_authorization"] == "requested"
assert result["verdict_submitted"] is True
assert "verdict_ignored" not in result
@ -2545,6 +2542,8 @@ def _verdict_publish_patches(
) -> list[Any]:
if thread_metadata is _UNSET_METADATA:
thread_metadata = {"pr": {"author": "external-contributor"}}
strict_metadata = dict(thread_metadata or {})
strict_metadata["findings"] = findings
return [
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)),
@ -2562,6 +2561,15 @@ def _verdict_publish_patches(
"agent.tools.publish_review.get_thread_metadata",
AsyncMock(return_value=thread_metadata or {}),
),
patch(
"agent.tools.publish_review._get_thread_metadata_strict",
AsyncMock(return_value=strict_metadata),
),
patch(
"agent.tools.publish_review.fetch_pull_request_head_sha",
AsyncMock(return_value="sha"),
),
patch("agent.tools.publish_review.update_pull_request_review_body", AsyncMock()),
patch(
"agent.tools.publish_review.dismiss_pull_request_review",
dismiss or AsyncMock(return_value=True),
@ -2576,7 +2584,7 @@ async def test_publish_async_approve_with_zero_findings_bypasses_empty_skip() ->
from agent.tools.publish_review import _publish_review_async
post_review = AsyncMock(return_value={"id": 999})
post_review = AsyncMock(return_value={"id": 999, "state": "APPROVED"})
with ExitStack() as stack:
for p in _verdict_publish_patches(findings=[], post_review=post_review):
stack.enter_context(p)
@ -2600,17 +2608,15 @@ async def test_publish_async_approve_with_zero_findings_bypasses_empty_skip() ->
assert "skipped_empty_re_review" not in result
assert post_review.await_args.kwargs["event"] == "APPROVE"
body = post_review.await_args.kwargs["body"]
assert "Verdict (`approve`) submitted at the request of @amoussa1229." in body
assert "Verdict (`approve`) requested by @amoussa1229." in body
async def test_publish_async_unsolicited_approve_clean_posts_approve() -> None:
"""A clean review (zero open findings) lands an unsolicited approve as a
real APPROVE, with automatic (not requester) attribution."""
async def test_publish_async_consistent_approve_clean_posts_approve() -> None:
from contextlib import ExitStack
from agent.tools.publish_review import _publish_review_async
post_review = AsyncMock(return_value={"id": 2001})
post_review = AsyncMock(return_value={"id": 2001, "state": "APPROVED"})
with ExitStack() as stack:
for p in _verdict_publish_patches(findings=[], post_review=post_review):
stack.enter_context(p)
@ -2625,7 +2631,7 @@ async def test_publish_async_unsolicited_approve_clean_posts_approve() -> None:
is_re_review=False,
verdict="approve",
verdict_requester="",
unsolicited_approve=True,
verdict_authorization="consistent",
)
assert result["success"] is True
@ -2633,19 +2639,16 @@ async def test_publish_async_unsolicited_approve_clean_posts_approve() -> None:
assert result["verdict_event"] == "APPROVE"
assert post_review.await_args.kwargs["event"] == "APPROVE"
body = post_review.await_args.kwargs["body"]
assert "Verdict (`approve`) submitted — the review found no open issues." in body
assert "at the request of" not in body
assert "Verdict (`approve`) requested by the requester." in body
async def test_publish_async_unsolicited_approve_with_open_findings_downgrades() -> None:
"""An unsolicited approve alongside open findings is downgraded to a
comment review — approving while requesting changes is contradictory."""
async def test_publish_async_consistent_approve_with_open_findings_downgrades() -> None:
from contextlib import ExitStack
from agent.tools.publish_review import _publish_review_async
findings = [_f(id="f_1", severity="high", file="a.py", start_line=1, end_line=1)]
post_review = AsyncMock(return_value={"id": 2002})
post_review = AsyncMock(return_value={"id": 2002, "state": "COMMENTED"})
with ExitStack() as stack:
for p in _verdict_publish_patches(findings=findings, post_review=post_review):
stack.enter_context(p)
@ -2660,7 +2663,7 @@ async def test_publish_async_unsolicited_approve_with_open_findings_downgrades()
is_re_review=False,
verdict="approve",
verdict_requester="",
unsolicited_approve=True,
verdict_authorization="consistent",
)
assert result["success"] is True
@ -2670,7 +2673,7 @@ async def test_publish_async_unsolicited_approve_with_open_findings_downgrades()
assert post_review.await_args.kwargs["event"] == "COMMENT"
async def test_publish_async_authorized_approve_ignores_open_findings_gate() -> None:
async def test_publish_async_requested_approve_ignores_open_findings_gate() -> None:
"""The explicit-request path is unchanged: an authorized approve is honored
even when open findings exist (the requester asked for the verdict)."""
from contextlib import ExitStack
@ -2678,7 +2681,7 @@ async def test_publish_async_authorized_approve_ignores_open_findings_gate() ->
from agent.tools.publish_review import _publish_review_async
findings = [_f(id="f_1", severity="high", file="a.py", start_line=1, end_line=1)]
post_review = AsyncMock(return_value={"id": 2003})
post_review = AsyncMock(return_value={"id": 2003, "state": "APPROVED"})
with ExitStack() as stack:
for p in _verdict_publish_patches(findings=findings, post_review=post_review):
stack.enter_context(p)
@ -2706,7 +2709,7 @@ async def test_publish_async_request_changes_maps_event() -> None:
from agent.tools.publish_review import _publish_review_async
findings = [_f(id="f_1", severity="high", file="a.py", start_line=1, end_line=1)]
post_review = AsyncMock(return_value={"id": 1000})
post_review = AsyncMock(return_value={"id": 1000, "state": "CHANGES_REQUESTED"})
with ExitStack() as stack:
for p in _verdict_publish_patches(findings=findings, post_review=post_review):
stack.enter_context(p)
@ -2733,7 +2736,7 @@ async def test_publish_async_self_review_downgrades_verdict_to_comment() -> None
from agent.tools.publish_review import _publish_review_async
post_review = AsyncMock(return_value={"id": 1001})
post_review = AsyncMock(return_value={"id": 1001, "state": "COMMENTED"})
with ExitStack() as stack:
for p in _verdict_publish_patches(
findings=[],
@ -2760,7 +2763,7 @@ async def test_publish_async_self_review_downgrades_verdict_to_comment() -> None
assert result["verdict_ignored_reason"] == "self_review"
assert post_review.await_args.kwargs["event"] == "COMMENT"
body = post_review.await_args.kwargs["body"]
assert "does not approve or request changes on its own pull requests" in body
assert "Verdict withheld (self review)" in body
async def test_publish_async_dismisses_stale_approval_on_new_findings() -> None:
@ -2964,14 +2967,17 @@ async def test_publish_async_verdict_lands_when_all_findings_out_of_diff() -> No
assert post_review.await_args.kwargs["inline_comments"] == []
async def test_publish_async_verdict_not_submitted_when_github_coerces_state() -> None:
@pytest.mark.parametrize("recorded_state", ["COMMENTED", "CHANGES_REQUESTED", None])
async def test_publish_async_verdict_not_submitted_when_github_state_mismatches(
recorded_state: str | None,
) -> None:
"""GitHub can accept the POST but land the review as COMMENTED; the result
must not claim a verdict in that case."""
from contextlib import ExitStack
from agent.tools.publish_review import _publish_review_async
post_review = AsyncMock(return_value={"id": 2005, "state": "COMMENTED"})
post_review = AsyncMock(return_value={"id": 2005, "state": recorded_state})
with ExitStack() as stack:
for p in _verdict_publish_patches(findings=[], post_review=post_review):
stack.enter_context(p)
@ -2990,3 +2996,145 @@ async def test_publish_async_verdict_not_submitted_when_github_coerces_state() -
assert result["success"] is True
assert result.get("verdict_submitted") is not True
assert result["verdict_ignored_reason"] == "github_state_mismatch"
if recorded_state is None:
assert "review_state" not in result
else:
assert result["review_state"] == recorded_state
assert "submitted" not in post_review.await_args.kwargs["body"].lower()
@pytest.mark.parametrize("finding_state", ["clean", "open", "needs_reassessment"])
@pytest.mark.parametrize("verdict", ["approve", "request_changes"])
@pytest.mark.parametrize("authorization", ["requested", "consistent", "none"])
async def test_publish_async_verdict_authorization_matrix(
authorization: str,
verdict: str,
finding_state: str,
) -> None:
from contextlib import ExitStack
from agent.tools.publish_review import _publish_review_async
findings = []
if finding_state != "clean":
finding = _f(id="f_matrix", severity="high", file="a.py", start_line=1, end_line=1)
finding["status"] = finding_state
findings = [finding]
has_open = finding_state != "clean"
should_submit = authorization == "requested" or (
authorization == "consistent"
and ((verdict == "approve" and not has_open) or (verdict == "request_changes" and has_open))
)
expected_event = (
("APPROVE" if verdict == "approve" else "REQUEST_CHANGES") if should_submit else "COMMENT"
)
recorded_state = {
"APPROVE": "APPROVED",
"REQUEST_CHANGES": "CHANGES_REQUESTED",
"COMMENT": "COMMENTED",
}[expected_event]
post_review = AsyncMock(return_value={"id": 4001, "state": recorded_state})
update_body = AsyncMock(return_value=True)
with ExitStack() as stack:
for patcher in _verdict_publish_patches(findings=findings, post_review=post_review):
stack.enter_context(patcher)
stack.enter_context(
patch("agent.tools.publish_review.update_pull_request_review_body", update_body)
)
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,
verdict=verdict,
verdict_authorization=authorization,
)
assert post_review.await_args.kwargs["event"] == expected_event
assert result.get("verdict_submitted") is should_submit
assert f"Review outcome: **{recorded_state}**" in update_body.await_args.kwargs["body"]
if not should_submit:
expected_reason = "verdict_not_requested"
if authorization == "consistent":
expected_reason = (
"approve_with_open_findings"
if verdict == "approve"
else "request_changes_without_open_findings"
)
assert result["verdict_ignored_reason"] == expected_reason
assert "Published as a comment review" in post_review.await_args.kwargs["body"]
async def test_publish_async_rechecks_live_head_immediately_before_verdict_post() -> None:
from contextlib import ExitStack
from agent.tools.publish_review import _publish_review_async
order: list[str] = []
lock_state = {"held": False}
class TrackingLock:
async def __aenter__(self) -> None:
lock_state["held"] = True
async def __aexit__(self, *_args: Any) -> None:
lock_state["held"] = False
async def snapshot(_thread_id: str) -> dict[str, Any]:
assert lock_state["held"] is True
order.append("snapshot")
return {"pr": {"author": "external-contributor"}, "findings": []}
async def fetch_live_head(**_kwargs: Any) -> str:
assert lock_state["held"] is True
order.append("head")
return "moved"
async def post_review(**kwargs: Any) -> dict[str, Any]:
assert lock_state["held"] is True
order.append("post")
assert kwargs["event"] == "COMMENT"
return {"id": 4002, "state": "COMMENTED"}
post = AsyncMock(side_effect=post_review)
with ExitStack() as stack:
for patcher in _verdict_publish_patches(findings=[], post_review=post):
stack.enter_context(patcher)
stack.enter_context(
patch(
"agent.tools.publish_review.fetch_pull_request_head_sha",
AsyncMock(side_effect=fetch_live_head),
)
)
stack.enter_context(
patch(
"agent.tools.publish_review._get_thread_metadata_strict",
AsyncMock(side_effect=snapshot),
)
)
stack.enter_context(
patch("agent.tools.publish_review._finding_mutation_lock", return_value=TrackingLock())
)
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,
verdict="approve",
verdict_authorization="consistent",
)
assert order == ["snapshot", "head", "post"]
assert lock_state["held"] is False
assert result["verdict_ignored_reason"] == "head_moved"

View file

@ -8,7 +8,7 @@ from agent.review.reconcile import reconcile_findings_with_review_threads
@pytest.mark.asyncio
async def test_reconcile_marks_resolved_github_thread_resolved() -> None:
async def test_reconcile_marks_author_resolved_thread_needs_reassessment() -> None:
findings = [
{
"id": "f1",
@ -35,8 +35,9 @@ async def test_reconcile_marks_resolved_github_thread_resolved() -> None:
],
)
assert result[0]["status"] == "resolved"
assert result[0]["github_thread_resolved"] is True
assert result[0]["status"] == "needs_reassessment"
assert result[0].get("github_thread_resolved") is not True
assert "reassess" in result[0]["last_reconciliation_note"]
replace.assert_awaited_once()
@ -212,13 +213,60 @@ async def test_reconcile_duplicate_markers_stay_open_when_some_threads_only_outd
],
)
assert result[0]["status"] == "open"
assert "last_reconciliation_note" not in result[0]
assert result[0]["status"] == "needs_reassessment"
assert "reassess" in result[0]["last_reconciliation_note"]
assert result[0]["github_resolved_thread_ids"] == ["THREAD_RESOLVED"]
assert result[0].get("github_thread_resolved") is not True
replace.assert_awaited_once()
@pytest.mark.asyncio
async def test_reconcile_marks_author_outdated_thread_needs_reassessment() -> None:
findings = [{"id": "f1", "status": "open", "github_review_comment_id": 11}]
with (
patch("agent.review.reconcile.list_findings", AsyncMock(return_value=findings)),
patch("agent.review.reconcile.replace_findings", AsyncMock()),
):
result = await reconcile_findings_with_review_threads(
"tid",
[
{
"id": "THREAD_1",
"is_resolved": False,
"is_outdated": True,
"comments": [{"id": 11, "author": "open-swe[bot]", "body": "bug"}],
}
],
)
assert result[0]["status"] == "needs_reassessment"
@pytest.mark.asyncio
async def test_reconcile_preserves_reviewer_resolved_status() -> None:
findings = [{"id": "f1", "status": "resolved", "github_review_comment_id": 11}]
with (
patch("agent.review.reconcile.list_findings", AsyncMock(return_value=findings)),
patch("agent.review.reconcile.replace_findings", AsyncMock()),
):
result = await reconcile_findings_with_review_threads(
"tid",
[
{
"id": "THREAD_1",
"is_resolved": True,
"is_outdated": False,
"comments": [{"id": 11, "author": "open-swe[bot]", "body": "bug"}],
}
],
)
assert result[0]["status"] == "resolved"
assert result[0]["github_thread_resolved"] is True
@pytest.mark.asyncio
async def test_reconcile_ignores_spoofed_non_bot_marker() -> None:
findings = [