mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-02 23:53:26 +00:00
fix: make review publish idempotent (partial-failure recovery) (#1477)
Publish had partial-failure windows that double-posted summaries or
corrupted findings state. This hardens the recovery paths:
- open_swe_review_exists is now tri-state (True/False/None). On a
pagination/API failure it returns None ("unknown") instead of False,
and the empty-summary dedup keys off the durable last_reviewed_sha
before consulting GitHub, so a transient failure never double-posts a
"no issues found" summary.
- Comment-id backfill matches strictly on the embedded open-swe marker;
the colliding (path, line, body) fallback is gone, so similar findings
no longer share a comment id and break resolve-on-fix.
- Review-id and comment-id stamping collapse into one guarded
read-modify-write (re-reads latest before writing), removing the
half-stamped intermediate states the prior multi-write flow left open.
- New mutate_findings primitive centralizes read-modify-write so finding
updates operate on the freshest persisted list and skip no-op writes.
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
9475afdf2e
commit
7cd882bb67
5 changed files with 522 additions and 179 deletions
|
|
@ -14,6 +14,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import Any, Literal, TypedDict, cast
|
from typing import Any, Literal, TypedDict, cast
|
||||||
|
|
||||||
from langgraph.config import get_config
|
from langgraph.config import get_config
|
||||||
|
|
@ -331,16 +332,41 @@ async def get_finding(thread_id: str, finding_id: str) -> Finding | None:
|
||||||
|
|
||||||
|
|
||||||
async def replace_findings(thread_id: str, findings: list[Finding]) -> None:
|
async def replace_findings(thread_id: str, findings: list[Finding]) -> None:
|
||||||
"""Overwrite the findings list on a thread's metadata."""
|
"""Overwrite the findings list on a thread's metadata.
|
||||||
|
|
||||||
|
Prefer :func:`mutate_findings` for read-modify-write updates: it re-reads the
|
||||||
|
freshest persisted list immediately before mutating, which shrinks the
|
||||||
|
lost-update window a blind overwrite here leaves open.
|
||||||
|
"""
|
||||||
client = get_client()
|
client = get_client()
|
||||||
await client.threads.update(thread_id=thread_id, metadata={"findings": findings})
|
await client.threads.update(thread_id=thread_id, metadata={"findings": findings})
|
||||||
|
|
||||||
|
|
||||||
|
async def mutate_findings(
|
||||||
|
thread_id: str,
|
||||||
|
mutator: Callable[[list[Finding]], bool],
|
||||||
|
) -> list[Finding]:
|
||||||
|
"""Read the latest findings, apply ``mutator`` in place, persist iff changed.
|
||||||
|
|
||||||
|
Centralizes the read-modify-write so every mutation operates on the freshest
|
||||||
|
persisted list rather than a stale in-memory snapshot. ``mutator`` edits the
|
||||||
|
list in place and returns ``True`` when it changed something; we only write
|
||||||
|
on change, so a no-op mutation never clobbers a concurrent update.
|
||||||
|
"""
|
||||||
|
findings = await list_findings(thread_id)
|
||||||
|
if mutator(findings):
|
||||||
|
await replace_findings(thread_id, findings)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
async def append_finding(thread_id: str, finding: Finding) -> Finding:
|
async def append_finding(thread_id: str, finding: Finding) -> Finding:
|
||||||
"""Append a finding and persist the new list."""
|
"""Append a finding and persist the new list."""
|
||||||
findings = await list_findings(thread_id)
|
|
||||||
findings.append(finding)
|
def _append(findings: list[Finding]) -> bool:
|
||||||
await replace_findings(thread_id, findings)
|
findings.append(finding)
|
||||||
|
return True
|
||||||
|
|
||||||
|
await mutate_findings(thread_id, _append)
|
||||||
return finding
|
return finding
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -350,17 +376,18 @@ async def update_finding_fields(
|
||||||
updates: dict[str, Any],
|
updates: dict[str, Any],
|
||||||
) -> Finding | None:
|
) -> Finding | None:
|
||||||
"""Apply field updates to one finding by id and persist."""
|
"""Apply field updates to one finding by id and persist."""
|
||||||
findings = await list_findings(thread_id)
|
captured: dict[str, Finding] = {}
|
||||||
updated: Finding | None = None
|
|
||||||
for finding in findings:
|
def _apply(findings: list[Finding]) -> bool:
|
||||||
if finding.get("id") == finding_id:
|
for finding in findings:
|
||||||
finding.update(updates)
|
if finding.get("id") == finding_id:
|
||||||
updated = finding
|
finding.update(updates)
|
||||||
break
|
captured["finding"] = finding
|
||||||
if updated is None:
|
return True
|
||||||
return None
|
return False
|
||||||
await replace_findings(thread_id, findings)
|
|
||||||
return updated
|
await mutate_findings(thread_id, _apply)
|
||||||
|
return captured.get("finding")
|
||||||
|
|
||||||
|
|
||||||
async def update_finding_surface(
|
async def update_finding_surface(
|
||||||
|
|
@ -369,21 +396,22 @@ async def update_finding_surface(
|
||||||
updates: dict[str, Any],
|
updates: dict[str, Any],
|
||||||
) -> Finding | None:
|
) -> Finding | None:
|
||||||
"""Apply updates to the nested surface record and legacy GitHub fields."""
|
"""Apply updates to the nested surface record and legacy GitHub fields."""
|
||||||
findings = await list_findings(thread_id)
|
captured: dict[str, Finding] = {}
|
||||||
updated: Finding | None = None
|
|
||||||
for finding in findings:
|
def _apply(findings: list[Finding]) -> bool:
|
||||||
if finding.get("id") != finding_id:
|
for finding in findings:
|
||||||
continue
|
if finding.get("id") != finding_id:
|
||||||
surface = _coerce_surface(finding, finding_id)
|
continue
|
||||||
surface.update(updates)
|
surface = _coerce_surface(finding, finding_id)
|
||||||
finding["surface"] = surface
|
surface.update(updates)
|
||||||
_sync_legacy_surface_fields(finding, surface)
|
finding["surface"] = surface
|
||||||
updated = finding
|
_sync_legacy_surface_fields(finding, surface)
|
||||||
break
|
captured["finding"] = finding
|
||||||
if updated is None:
|
return True
|
||||||
return None
|
return False
|
||||||
await replace_findings(thread_id, findings)
|
|
||||||
return updated
|
await mutate_findings(thread_id, _apply)
|
||||||
|
return captured.get("finding")
|
||||||
|
|
||||||
|
|
||||||
async def append_finding_interaction(
|
async def append_finding_interaction(
|
||||||
|
|
@ -392,29 +420,29 @@ async def append_finding_interaction(
|
||||||
interaction: FindingInteraction,
|
interaction: FindingInteraction,
|
||||||
) -> Finding | None:
|
) -> Finding | None:
|
||||||
"""Persist a GitHub review-thread interaction on one finding."""
|
"""Persist a GitHub review-thread interaction on one finding."""
|
||||||
findings = await list_findings(thread_id)
|
captured: dict[str, Finding] = {}
|
||||||
updated: Finding | None = None
|
|
||||||
for finding in findings:
|
def _apply(findings: list[Finding]) -> bool:
|
||||||
if finding.get("id") != finding_id:
|
for finding in findings:
|
||||||
continue
|
if finding.get("id") != finding_id:
|
||||||
interactions = finding.get("interactions")
|
continue
|
||||||
if not isinstance(interactions, list):
|
captured["finding"] = finding
|
||||||
interactions = []
|
interactions = finding.get("interactions")
|
||||||
github_comment_id = interaction.get("github_comment_id")
|
if not isinstance(interactions, list):
|
||||||
if isinstance(github_comment_id, int) and any(
|
interactions = []
|
||||||
isinstance(item, dict) and item.get("github_comment_id") == github_comment_id
|
github_comment_id = interaction.get("github_comment_id")
|
||||||
for item in interactions
|
if isinstance(github_comment_id, int) and any(
|
||||||
):
|
isinstance(item, dict) and item.get("github_comment_id") == github_comment_id
|
||||||
updated = finding
|
for item in interactions
|
||||||
break
|
):
|
||||||
interactions.append(interaction)
|
return False
|
||||||
finding["interactions"] = interactions
|
interactions.append(interaction)
|
||||||
updated = finding
|
finding["interactions"] = interactions
|
||||||
break
|
return True
|
||||||
if updated is None:
|
return False
|
||||||
return None
|
|
||||||
await replace_findings(thread_id, findings)
|
await mutate_findings(thread_id, _apply)
|
||||||
return updated
|
return captured.get("finding")
|
||||||
|
|
||||||
|
|
||||||
def _coerce_surface(finding: Finding, finding_id: str) -> FindingSurface:
|
def _coerce_surface(finding: Finding, finding_id: str) -> FindingSurface:
|
||||||
|
|
|
||||||
|
|
@ -431,8 +431,8 @@ async def open_swe_review_exists(
|
||||||
repo: str,
|
repo: str,
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
token: str,
|
token: str,
|
||||||
) -> bool:
|
) -> bool | None:
|
||||||
"""Return True if Open SWE has already posted a review summary on this PR.
|
"""Return whether Open SWE has already posted a review summary on this PR.
|
||||||
|
|
||||||
Detected via the ``review_summary_marker`` that ``render_review_body``
|
Detected via the ``review_summary_marker`` that ``render_review_body``
|
||||||
embeds in every Open SWE review body. The reviewer uses this to avoid
|
embeds in every Open SWE review body. The reviewer uses this to avoid
|
||||||
|
|
@ -441,8 +441,14 @@ async def open_swe_review_exists(
|
||||||
into the still-running first-review run, whose configurable still says
|
into the still-running first-review run, whose configurable still says
|
||||||
``re_review=False``, so the empty-review guard can't trust that flag alone.
|
``re_review=False``, so the empty-review guard can't trust that flag alone.
|
||||||
|
|
||||||
On any API failure this returns False (fail open): the only consequence is
|
Tri-state on purpose:
|
||||||
a possible duplicate summary, never a suppressed first review.
|
- ``True`` — an Open SWE review summary was found.
|
||||||
|
- ``False`` — the full review list was paginated successfully and carried
|
||||||
|
no Open SWE summary.
|
||||||
|
- ``None`` — the answer is unknown because an API call (or a page partway
|
||||||
|
through pagination) failed. Callers must not treat ``None`` as "no review
|
||||||
|
exists": the old fail-open-as-False behaviour double-posted "no issues"
|
||||||
|
summaries whenever pagination failed mid-walk.
|
||||||
"""
|
"""
|
||||||
marker = review_summary_marker(pr_number)
|
marker = review_summary_marker(pr_number)
|
||||||
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
||||||
|
|
@ -460,9 +466,11 @@ async def open_swe_review_exists(
|
||||||
repo,
|
repo,
|
||||||
pr_number,
|
pr_number,
|
||||||
)
|
)
|
||||||
return False
|
return None
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if not isinstance(data, list) or not data:
|
if not isinstance(data, list):
|
||||||
|
return None
|
||||||
|
if not data:
|
||||||
return False
|
return False
|
||||||
for review in data:
|
for review in data:
|
||||||
if isinstance(review, dict) and marker in (review.get("body") or ""):
|
if isinstance(review, dict) and marker in (review.get("body") or ""):
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from ..reviewer_findings import (
|
||||||
_coerce_surface,
|
_coerce_surface,
|
||||||
filter_findings_for_publish,
|
filter_findings_for_publish,
|
||||||
get_thread_id_from_runtime,
|
get_thread_id_from_runtime,
|
||||||
|
get_thread_last_reviewed_sha,
|
||||||
get_thread_metadata,
|
get_thread_metadata,
|
||||||
get_thread_slack_ref,
|
get_thread_slack_ref,
|
||||||
replace_findings,
|
replace_findings,
|
||||||
|
|
@ -281,11 +282,13 @@ async def _publish_review_async(
|
||||||
if (
|
if (
|
||||||
not inline_comments
|
not inline_comments
|
||||||
and not eligible_out_of_diff
|
and not eligible_out_of_diff
|
||||||
and (
|
and await _open_swe_already_reviewed(
|
||||||
is_re_review
|
thread_id=thread_id,
|
||||||
or await open_swe_review_exists(
|
owner=owner,
|
||||||
owner=owner, repo=repo, pr_number=pr_number, token=token
|
repo=repo,
|
||||||
)
|
pr_number=pr_number,
|
||||||
|
token=token,
|
||||||
|
is_re_review=is_re_review,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
resolved_thread_count = await _resolve_threads_for_resolved_findings(
|
resolved_thread_count = await _resolve_threads_for_resolved_findings(
|
||||||
|
|
@ -405,41 +408,37 @@ async def _publish_review_async(
|
||||||
}
|
}
|
||||||
review_id = review_response.get("id") if isinstance(review_response, dict) else None
|
review_id = review_response.get("id") if isinstance(review_response, dict) else None
|
||||||
|
|
||||||
if review_id is not None and eligible_out_of_diff:
|
if review_id is not None and (eligible_out_of_diff or inline_comments):
|
||||||
# Mark surfaced out-of-diff findings so re-review doesn't repost them.
|
# Record the GitHub review id AND inline comment ids in a single
|
||||||
await _store_review_id_on_findings(
|
# findings write. Previously these were three separate read-replace
|
||||||
|
# cycles (out-of-diff review id, inline review id, comment ids); each
|
||||||
|
# extra write widened the window where a crash could leave findings
|
||||||
|
# half-stamped — surfaced on GitHub but with no recorded comment id, so
|
||||||
|
# a later resolve-on-fix couldn't find the thread.
|
||||||
|
comment_records: list[dict[str, Any]] = []
|
||||||
|
if inline_comments:
|
||||||
|
comment_records = await fetch_review_comments(
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
review_id=review_id,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
if langgraph_run_id is None:
|
||||||
|
metadata = await get_thread_metadata(thread_id)
|
||||||
|
current_run_id = metadata.get("current_reviewer_run_id")
|
||||||
|
if isinstance(current_run_id, str) and current_run_id:
|
||||||
|
langgraph_run_id = current_run_id
|
||||||
|
await _record_review_publication(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
findings=findings,
|
|
||||||
eligible_with_payload=[(dict(f), {}) for f in eligible_out_of_diff],
|
|
||||||
review_id=review_id,
|
review_id=review_id,
|
||||||
)
|
out_of_diff_findings=eligible_out_of_diff,
|
||||||
|
inline_with_payload=eligible_with_payload,
|
||||||
if review_id is not None and inline_comments:
|
|
||||||
await _store_review_id_on_findings(
|
|
||||||
thread_id=thread_id,
|
|
||||||
findings=findings,
|
|
||||||
eligible_with_payload=eligible_with_payload,
|
|
||||||
review_id=review_id,
|
|
||||||
)
|
|
||||||
comment_records = await fetch_review_comments(
|
|
||||||
owner=owner,
|
|
||||||
repo=repo,
|
|
||||||
pr_number=pr_number,
|
|
||||||
review_id=review_id,
|
|
||||||
token=token,
|
|
||||||
)
|
|
||||||
if langgraph_run_id is None:
|
|
||||||
metadata = await get_thread_metadata(thread_id)
|
|
||||||
current_run_id = metadata.get("current_reviewer_run_id")
|
|
||||||
if isinstance(current_run_id, str) and current_run_id:
|
|
||||||
langgraph_run_id = current_run_id
|
|
||||||
await _store_comment_ids_on_findings(
|
|
||||||
thread_id=thread_id,
|
|
||||||
findings=findings,
|
|
||||||
eligible_with_payload=eligible_with_payload,
|
|
||||||
comment_records=comment_records,
|
comment_records=comment_records,
|
||||||
langgraph_run_id=langgraph_run_id,
|
langgraph_run_id=langgraph_run_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if review_id is not None and inline_comments:
|
||||||
current_findings = await list_findings_async(thread_id)
|
current_findings = await list_findings_async(thread_id)
|
||||||
if _missing_comment_ids_for_published_findings(current_findings, eligible_with_payload):
|
if _missing_comment_ids_for_published_findings(current_findings, eligible_with_payload):
|
||||||
await _backfill_findings_from_pr_threads(
|
await _backfill_findings_from_pr_threads(
|
||||||
|
|
@ -497,6 +496,39 @@ async def _publish_review_async(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_swe_already_reviewed(
|
||||||
|
*,
|
||||||
|
thread_id: str,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
token: str,
|
||||||
|
is_re_review: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Decide whether to suppress a duplicate empty "no issues found" summary.
|
||||||
|
|
||||||
|
Suppress only when we are *certain* a prior Open SWE review exists, so a
|
||||||
|
transient GitHub failure never causes a double-post:
|
||||||
|
|
||||||
|
- ``is_re_review`` is a durable signal (the dispatching webhook set it from
|
||||||
|
the persisted ``last_reviewed_sha``), so trust it outright.
|
||||||
|
- Otherwise consult durable reviewer state (``last_reviewed_sha`` on thread
|
||||||
|
metadata): a non-empty value means this thread already published once.
|
||||||
|
- Only as a last resort hit the GitHub reviews API. That call is tri-state:
|
||||||
|
``True``/``False`` are authoritative, but ``None`` means "unknown"
|
||||||
|
(pagination or the request failed). On ``None`` we do NOT suppress — a
|
||||||
|
possible duplicate summary is better than silently swallowing the only
|
||||||
|
review the user will ever see, and re-posting is the safe failure mode.
|
||||||
|
"""
|
||||||
|
if is_re_review:
|
||||||
|
return True
|
||||||
|
metadata = await get_thread_metadata(thread_id)
|
||||||
|
if get_thread_last_reviewed_sha(metadata):
|
||||||
|
return True
|
||||||
|
exists = await open_swe_review_exists(owner=owner, repo=repo, pr_number=pr_number, token=token)
|
||||||
|
return exists is True
|
||||||
|
|
||||||
|
|
||||||
def _has_publication_identity(finding: Finding) -> bool:
|
def _has_publication_identity(finding: Finding) -> bool:
|
||||||
return isinstance(finding.get("github_review_comment_id"), int) or isinstance(
|
return isinstance(finding.get("github_review_comment_id"), int) or isinstance(
|
||||||
finding.get("github_review_id"), int
|
finding.get("github_review_id"), int
|
||||||
|
|
@ -570,18 +602,12 @@ def _missing_comment_ids_for_published_findings(
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _store_review_id_on_findings(
|
def _apply_review_id(
|
||||||
*,
|
|
||||||
thread_id: str,
|
|
||||||
findings: list[Finding],
|
findings: list[Finding],
|
||||||
eligible_with_payload: list[tuple[dict[str, Any], dict[str, Any]]],
|
*,
|
||||||
|
finding_ids: set[str],
|
||||||
review_id: int,
|
review_id: int,
|
||||||
) -> None:
|
) -> bool:
|
||||||
finding_ids = {
|
|
||||||
finding.get("id")
|
|
||||||
for finding, _payload in eligible_with_payload
|
|
||||||
if isinstance(finding.get("id"), str)
|
|
||||||
}
|
|
||||||
updated = False
|
updated = False
|
||||||
for finding in findings:
|
for finding in findings:
|
||||||
if finding.get("id") in finding_ids and finding.get("github_review_id") != review_id:
|
if finding.get("id") in finding_ids and finding.get("github_review_id") != review_id:
|
||||||
|
|
@ -591,8 +617,120 @@ async def _store_review_id_on_findings(
|
||||||
surface["github_review_id"] = review_id
|
surface["github_review_id"] = review_id
|
||||||
finding["surface"] = surface
|
finding["surface"] = surface
|
||||||
updated = True
|
updated = True
|
||||||
if updated:
|
return updated
|
||||||
await replace_findings(thread_id, findings)
|
|
||||||
|
|
||||||
|
def _apply_comment_ids(
|
||||||
|
findings: list[Finding],
|
||||||
|
*,
|
||||||
|
comment_id_by_finding_id: dict[str, int],
|
||||||
|
langgraph_run_id: str | None,
|
||||||
|
) -> bool:
|
||||||
|
updated = False
|
||||||
|
for finding in findings:
|
||||||
|
finding_id = finding.get("id")
|
||||||
|
if not isinstance(finding_id, str):
|
||||||
|
continue
|
||||||
|
comment_id = comment_id_by_finding_id.get(finding_id)
|
||||||
|
if comment_id is None:
|
||||||
|
continue
|
||||||
|
finding["github_review_comment_id"] = comment_id
|
||||||
|
comment_ids = _int_list(finding.get("github_review_comment_ids"))
|
||||||
|
if comment_id not in comment_ids:
|
||||||
|
comment_ids.append(comment_id)
|
||||||
|
finding["github_review_comment_ids"] = comment_ids
|
||||||
|
surface = _coerce_surface(finding, finding_id)
|
||||||
|
surface["state"] = "surfaced"
|
||||||
|
surface["github_review_comment_id"] = comment_id
|
||||||
|
surface["severity_threshold_at_publish"] = finding.get("severity")
|
||||||
|
surface["surfaced_at_sha"] = finding.get("last_confirmed_sha") or finding.get(
|
||||||
|
"first_seen_sha"
|
||||||
|
)
|
||||||
|
finding["surface"] = surface
|
||||||
|
if langgraph_run_id:
|
||||||
|
finding["github_review_run_id"] = langgraph_run_id
|
||||||
|
updated = True
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _comment_id_by_finding_id(
|
||||||
|
eligible_with_payload: list[tuple[dict[str, Any], dict[str, Any]]],
|
||||||
|
comment_records: list[dict[str, Any]],
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Map each surfaced finding id to its GitHub comment id via the marker.
|
||||||
|
|
||||||
|
The embedded Open SWE marker is the *only* source of truth. Every comment
|
||||||
|
this reviewer posts carries a ``<!-- open-swe-review-comment {...} -->``
|
||||||
|
marker keyed by finding id (see ``render_inline_comment_body``), so the
|
||||||
|
match is exact. The old ``(path, line, body)`` fallback collided whenever
|
||||||
|
two findings shared a path/line/body — it cached the same comment id on
|
||||||
|
both, which corrupts resolve-on-fix (resolving one would target the wrong
|
||||||
|
thread). Findings whose comment lacks a parseable marker are left out here;
|
||||||
|
``_backfill_findings_from_pr_threads`` recovers them via the same marker
|
||||||
|
against the PR's review threads.
|
||||||
|
"""
|
||||||
|
by_marker_id: dict[str, int] = {}
|
||||||
|
for record in comment_records:
|
||||||
|
body = record.get("body", "")
|
||||||
|
comment_id = record.get("id")
|
||||||
|
if isinstance(body, str) and isinstance(comment_id, int):
|
||||||
|
marker = parse_review_comment_marker(body)
|
||||||
|
if marker is not None:
|
||||||
|
by_marker_id[marker["id"]] = comment_id
|
||||||
|
|
||||||
|
out: dict[str, int] = {}
|
||||||
|
for finding_snapshot, _payload in eligible_with_payload:
|
||||||
|
finding_id = finding_snapshot.get("id")
|
||||||
|
if isinstance(finding_id, str) and finding_id in by_marker_id:
|
||||||
|
out[finding_id] = by_marker_id[finding_id]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_review_publication(
|
||||||
|
*,
|
||||||
|
thread_id: str,
|
||||||
|
review_id: int,
|
||||||
|
out_of_diff_findings: list[Finding],
|
||||||
|
inline_with_payload: list[tuple[dict[str, Any], dict[str, Any]]],
|
||||||
|
comment_records: list[dict[str, Any]],
|
||||||
|
langgraph_run_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Stamp the review id and inline comment ids onto findings in one write.
|
||||||
|
|
||||||
|
Collapsing the review-id and comment-id updates into a single
|
||||||
|
read-modify-write keeps publication identity atomic: a finding is never
|
||||||
|
persisted carrying a review id without also carrying whatever comment id
|
||||||
|
GitHub returned for it in the same record.
|
||||||
|
"""
|
||||||
|
review_finding_ids = {
|
||||||
|
finding.get("id") for finding in out_of_diff_findings if isinstance(finding.get("id"), str)
|
||||||
|
}
|
||||||
|
review_finding_ids |= {
|
||||||
|
finding.get("id")
|
||||||
|
for finding, _payload in inline_with_payload
|
||||||
|
if isinstance(finding.get("id"), str)
|
||||||
|
}
|
||||||
|
comment_id_by_finding_id = _comment_id_by_finding_id(inline_with_payload, comment_records)
|
||||||
|
|
||||||
|
# Re-read the freshest persisted list right before mutating so this single
|
||||||
|
# write merges onto any update that landed since the snapshot the caller
|
||||||
|
# passed in, instead of blindly overwriting it.
|
||||||
|
latest = await list_findings_async(thread_id)
|
||||||
|
changed = _apply_review_id(
|
||||||
|
latest,
|
||||||
|
finding_ids={fid for fid in review_finding_ids if isinstance(fid, str)},
|
||||||
|
review_id=review_id,
|
||||||
|
)
|
||||||
|
changed = (
|
||||||
|
_apply_comment_ids(
|
||||||
|
latest,
|
||||||
|
comment_id_by_finding_id=comment_id_by_finding_id,
|
||||||
|
langgraph_run_id=langgraph_run_id,
|
||||||
|
)
|
||||||
|
or changed
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
await replace_findings(thread_id, latest)
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_diff_line_set(
|
async def _resolve_diff_line_set(
|
||||||
|
|
@ -703,77 +841,6 @@ async def _maybe_post_slack_completion_reply(
|
||||||
await post_slack_thread_reply(slack_ref["channel_id"], slack_ref["thread_ts"], text)
|
await post_slack_thread_reply(slack_ref["channel_id"], slack_ref["thread_ts"], text)
|
||||||
|
|
||||||
|
|
||||||
async def _store_comment_ids_on_findings(
|
|
||||||
*,
|
|
||||||
thread_id: str,
|
|
||||||
findings: list[Finding],
|
|
||||||
eligible_with_payload: list[tuple[dict[str, Any], dict[str, Any]]],
|
|
||||||
comment_records: list[dict[str, Any]],
|
|
||||||
langgraph_run_id: str | None,
|
|
||||||
) -> None:
|
|
||||||
"""Match returned GitHub comment ids back to the findings that produced them.
|
|
||||||
|
|
||||||
Prefer the Open SWE marker because it survives body formatting changes;
|
|
||||||
fall back to ``(path, line, body)`` for older comments.
|
|
||||||
"""
|
|
||||||
by_marker_id: dict[str, int] = {}
|
|
||||||
by_key: dict[tuple[str, int, str], int] = {}
|
|
||||||
for record in comment_records:
|
|
||||||
path = record.get("path")
|
|
||||||
line = record.get("line") or record.get("original_line")
|
|
||||||
body = record.get("body", "")
|
|
||||||
comment_id = record.get("id")
|
|
||||||
if (
|
|
||||||
isinstance(path, str)
|
|
||||||
and isinstance(line, int)
|
|
||||||
and isinstance(body, str)
|
|
||||||
and isinstance(comment_id, int)
|
|
||||||
):
|
|
||||||
by_key[(path, line, body)] = comment_id
|
|
||||||
marker = parse_review_comment_marker(body)
|
|
||||||
if marker is not None:
|
|
||||||
by_marker_id[marker["id"]] = comment_id
|
|
||||||
|
|
||||||
updated = False
|
|
||||||
findings_by_id = {f.get("id"): f for f in findings}
|
|
||||||
for finding_snapshot, payload in eligible_with_payload:
|
|
||||||
finding_id = finding_snapshot.get("id")
|
|
||||||
comment_id = by_marker_id.get(finding_id) if isinstance(finding_id, str) else None
|
|
||||||
line_value = payload.get("line")
|
|
||||||
if comment_id is None and isinstance(line_value, int):
|
|
||||||
key = (
|
|
||||||
str(payload.get("path", "")),
|
|
||||||
line_value,
|
|
||||||
str(payload.get("body", "")),
|
|
||||||
)
|
|
||||||
comment_id = by_key.get(key)
|
|
||||||
if comment_id is None:
|
|
||||||
continue
|
|
||||||
finding = findings_by_id.get(finding_id)
|
|
||||||
if finding is None:
|
|
||||||
continue
|
|
||||||
finding["github_review_comment_id"] = comment_id
|
|
||||||
comment_ids = _int_list(finding.get("github_review_comment_ids"))
|
|
||||||
if comment_id not in comment_ids:
|
|
||||||
comment_ids.append(comment_id)
|
|
||||||
finding["github_review_comment_ids"] = comment_ids
|
|
||||||
if isinstance(finding_id, str):
|
|
||||||
surface = _coerce_surface(finding, finding_id)
|
|
||||||
surface["state"] = "surfaced"
|
|
||||||
surface["github_review_comment_id"] = comment_id
|
|
||||||
surface["severity_threshold_at_publish"] = finding.get("severity")
|
|
||||||
surface["surfaced_at_sha"] = finding.get("last_confirmed_sha") or finding.get(
|
|
||||||
"first_seen_sha"
|
|
||||||
)
|
|
||||||
finding["surface"] = surface
|
|
||||||
if langgraph_run_id:
|
|
||||||
finding["github_review_run_id"] = langgraph_run_id
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
await replace_findings(thread_id, list(findings_by_id.values()))
|
|
||||||
|
|
||||||
|
|
||||||
async def _store_thread_ids_on_findings(
|
async def _store_thread_ids_on_findings(
|
||||||
*,
|
*,
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from agent.reviewer_findings import (
|
||||||
append_finding,
|
append_finding,
|
||||||
filter_findings_for_publish,
|
filter_findings_for_publish,
|
||||||
list_findings,
|
list_findings,
|
||||||
|
mutate_findings,
|
||||||
new_finding,
|
new_finding,
|
||||||
new_finding_id,
|
new_finding_id,
|
||||||
replace_findings,
|
replace_findings,
|
||||||
|
|
@ -142,6 +143,42 @@ async def test_append_finding_appends_to_existing_list() -> None:
|
||||||
assert [f["id"] for f in persisted] == ["f_a", "f_b"]
|
assert [f["id"] for f in persisted] == ["f_a", "f_b"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mutate_findings_reads_latest_before_mutating() -> None:
|
||||||
|
"""mutate_findings must operate on the freshest persisted list, not a stale
|
||||||
|
snapshot — the mutator receives whatever ``list_findings`` returns now."""
|
||||||
|
latest = [_f(id="f_fresh")]
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
fake_client.threads.get.return_value = {"metadata": {"findings": latest}}
|
||||||
|
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def _mutator(findings: list[Finding]) -> bool:
|
||||||
|
seen.extend(f["id"] for f in findings)
|
||||||
|
findings[0]["status"] = "resolved"
|
||||||
|
return True
|
||||||
|
|
||||||
|
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
|
||||||
|
result = await mutate_findings("tid", _mutator)
|
||||||
|
|
||||||
|
assert seen == ["f_fresh"]
|
||||||
|
assert result[0]["status"] == "resolved"
|
||||||
|
fake_client.threads.update.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mutate_findings_skips_write_when_unchanged() -> None:
|
||||||
|
"""A no-op mutation must NOT persist, so it can never clobber a concurrent
|
||||||
|
update that landed between the read and a would-be write."""
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
fake_client.threads.get.return_value = {"metadata": {"findings": [_f(id="f_a")]}}
|
||||||
|
|
||||||
|
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
|
||||||
|
await mutate_findings("tid", lambda _findings: False)
|
||||||
|
|
||||||
|
fake_client.threads.update.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_finding_fields_mutates_only_target() -> None:
|
async def test_update_finding_fields_mutates_only_target() -> None:
|
||||||
a = _f(id="f_a", description="orig-a")
|
a = _f(id="f_a", description="orig-a")
|
||||||
|
|
|
||||||
|
|
@ -852,6 +852,94 @@ async def test_publish_review_skips_review_existence_check_on_re_review() -> Non
|
||||||
assert result["skipped_empty_re_review"] is True
|
assert result["skipped_empty_re_review"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_review_dedup_keys_off_durable_last_reviewed_sha() -> None:
|
||||||
|
"""A non-empty ``last_reviewed_sha`` on thread metadata means this thread
|
||||||
|
already published once. The empty-summary guard must trust that durable
|
||||||
|
signal and suppress without ever hitting the reviews API."""
|
||||||
|
from agent.tools.publish_review import _publish_review_async
|
||||||
|
|
||||||
|
review_exists = AsyncMock(return_value=False)
|
||||||
|
post_review = 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.get_thread_metadata",
|
||||||
|
AsyncMock(return_value={"last_reviewed_sha": "oldsha"}),
|
||||||
|
),
|
||||||
|
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",
|
||||||
|
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=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
review_exists.assert_not_called()
|
||||||
|
post_review.assert_not_called()
|
||||||
|
assert result["skipped_empty_re_review"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_review_posts_summary_when_review_existence_unknown() -> None:
|
||||||
|
"""When the reviews API can't answer (``open_swe_review_exists`` returns
|
||||||
|
``None``) and there is no durable prior-review signal, the guard must NOT
|
||||||
|
suppress — re-posting the summary is the safe failure mode, never silently
|
||||||
|
swallowing the only review the user sees."""
|
||||||
|
from agent.tools.publish_review import _publish_review_async
|
||||||
|
|
||||||
|
review_exists = AsyncMock(return_value=None)
|
||||||
|
post_review = AsyncMock(return_value={"id": 321})
|
||||||
|
|
||||||
|
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.get_thread_metadata",
|
||||||
|
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.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", 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=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
review_exists.assert_awaited_once()
|
||||||
|
post_review.assert_awaited_once()
|
||||||
|
assert "skipped_empty_re_review" not in result
|
||||||
|
assert result["review_id"] == 321
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_swe_review_exists_detects_summary_marker() -> None:
|
async def test_open_swe_review_exists_detects_summary_marker() -> None:
|
||||||
response = MagicMock()
|
response = MagicMock()
|
||||||
|
|
@ -886,7 +974,10 @@ async def test_open_swe_review_exists_false_without_marker() -> None:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_swe_review_exists_fails_open_on_http_error() -> None:
|
async def test_open_swe_review_exists_returns_none_on_http_error() -> None:
|
||||||
|
"""A failed reviews API call is reported as ``None`` (unknown), never
|
||||||
|
``False`` — the empty-summary dedup must not treat a transient failure as
|
||||||
|
"no prior review exists" and double-post."""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
client_cm = AsyncMock()
|
client_cm = AsyncMock()
|
||||||
|
|
@ -895,7 +986,7 @@ async def test_open_swe_review_exists_fails_open_on_http_error() -> None:
|
||||||
|
|
||||||
with patch("agent.reviewer_publish.httpx.AsyncClient", return_value=client_cm):
|
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")
|
exists = await open_swe_review_exists(owner="o", repo="r", pr_number=7, token="t")
|
||||||
assert exists is False
|
assert exists is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -1152,6 +1243,118 @@ async def test_re_review_only_posts_current_head_unpublished_findings() -> None:
|
||||||
assert new["github_review_comment_id"] == 303
|
assert new["github_review_comment_id"] == 303
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_review_matches_comment_ids_by_marker_not_path_line_body() -> None:
|
||||||
|
"""Two findings on the same path/line with identical rendered bodies must
|
||||||
|
each get their OWN comment id, matched via the embedded marker. The old
|
||||||
|
``(path, line, body)`` fallback collided here and cached one comment id on
|
||||||
|
both findings, breaking resolve-on-fix."""
|
||||||
|
from agent.tools.publish_review import _publish_review_async
|
||||||
|
|
||||||
|
f1 = _f(id="f_one", file="dup.py", start_line=5, end_line=5, description="same text")
|
||||||
|
f2 = _f(id="f_two", file="dup.py", start_line=5, end_line=5, description="same text")
|
||||||
|
findings = [f1, f2]
|
||||||
|
post_review = AsyncMock(return_value={"id": 700})
|
||||||
|
# GitHub returns one comment per finding; the only thing that distinguishes
|
||||||
|
# them is the marker embedded in each body.
|
||||||
|
fetch_comments = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{"id": 901, "path": "dup.py", "line": 5, "body": render_inline_comment_body(f1)},
|
||||||
|
{"id": 902, "path": "dup.py", "line": 5, "body": render_inline_comment_body(f2)},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
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._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", 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
|
||||||
|
by_id = {f["id"]: f for f in findings}
|
||||||
|
assert by_id["f_one"]["github_review_comment_id"] == 901
|
||||||
|
assert by_id["f_two"]["github_review_comment_id"] == 902
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_review_records_review_id_and_comment_id_in_single_write() -> None:
|
||||||
|
"""The post-publish bookkeeping stamps review id + comment id onto findings
|
||||||
|
in one ``replace_findings`` call, so a finding is never persisted with a
|
||||||
|
review id but no comment id."""
|
||||||
|
from agent.tools.publish_review import _publish_review_async
|
||||||
|
|
||||||
|
finding = _f(id="f_new", file="x.py", start_line=3, end_line=3)
|
||||||
|
findings = [finding]
|
||||||
|
post_review = AsyncMock(return_value={"id": 555})
|
||||||
|
fetch_comments = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{"id": 808, "path": "x.py", "line": 3, "body": render_inline_comment_body(finding)},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
replace = 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=findings)),
|
||||||
|
patch("agent.tools.publish_review.replace_findings", replace),
|
||||||
|
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._store_thread_ids_on_findings", new_callable=AsyncMock),
|
||||||
|
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", 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
|
||||||
|
# Exactly one persisted snapshot carries both ids together — never a
|
||||||
|
# half-stamped intermediate state.
|
||||||
|
persisted_snapshots = [call.args[1] for call in replace.await_args_list]
|
||||||
|
assert any(
|
||||||
|
snap[0].get("github_review_id") == 555 and snap[0].get("github_review_comment_id") == 808
|
||||||
|
for snap in persisted_snapshots
|
||||||
|
)
|
||||||
|
assert all(
|
||||||
|
not (
|
||||||
|
snap[0].get("github_review_id") == 555
|
||||||
|
and snap[0].get("github_review_comment_id") is None
|
||||||
|
)
|
||||||
|
for snap in persisted_snapshots
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_publish_review_posts_summary_when_no_findings() -> None:
|
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."""
|
"""An empty findings list must still post a review so the user sees feedback."""
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue