mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 13:53:27 +00:00
fix: dedupe reviewer comments from PR state (#1341)
* fix: dedupe reviewer comments from PR state Use GitHub review-thread markers to repair reviewer publication state before posting or resolving findings, so re-reviews do not duplicate comments and resolved findings close all matching PR threads. * fix: require all duplicate reviewer threads resolved Avoid treating a marker-backed finding as resolved when only one duplicate thread is outdated while another matching thread remains open.
This commit is contained in:
parent
dd4ca9181e
commit
0d4d1c5a3b
9 changed files with 959 additions and 91 deletions
|
|
@ -76,10 +76,14 @@ class Finding(TypedDict, total=False):
|
||||||
status: FindingStatus
|
status: FindingStatus
|
||||||
first_seen_sha: str
|
first_seen_sha: str
|
||||||
last_confirmed_sha: str
|
last_confirmed_sha: str
|
||||||
|
github_review_id: int | None
|
||||||
github_review_comment_id: int | None
|
github_review_comment_id: int | None
|
||||||
|
github_review_comment_ids: list[int]
|
||||||
github_review_thread_id: str | None
|
github_review_thread_id: str | None
|
||||||
|
github_review_thread_ids: list[str]
|
||||||
github_review_run_id: str | None
|
github_review_run_id: str | None
|
||||||
github_thread_resolved: bool
|
github_thread_resolved: bool
|
||||||
|
github_resolved_thread_ids: list[str]
|
||||||
last_human_reply_at: str | None
|
last_human_reply_at: str | None
|
||||||
last_human_reply_author: str | None
|
last_human_reply_author: str | None
|
||||||
last_human_reply_body: str | None
|
last_human_reply_body: str | None
|
||||||
|
|
@ -141,10 +145,14 @@ def new_finding(
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"first_seen_sha": sha,
|
"first_seen_sha": sha,
|
||||||
"last_confirmed_sha": sha,
|
"last_confirmed_sha": sha,
|
||||||
|
"github_review_id": None,
|
||||||
"github_review_comment_id": None,
|
"github_review_comment_id": None,
|
||||||
|
"github_review_comment_ids": [],
|
||||||
"github_review_thread_id": None,
|
"github_review_thread_id": None,
|
||||||
|
"github_review_thread_ids": [],
|
||||||
"github_review_run_id": None,
|
"github_review_run_id": None,
|
||||||
"github_thread_resolved": False,
|
"github_thread_resolved": False,
|
||||||
|
"github_resolved_thread_ids": [],
|
||||||
"last_human_reply_at": None,
|
"last_human_reply_at": None,
|
||||||
"last_human_reply_author": None,
|
"last_human_reply_author": None,
|
||||||
"last_human_reply_body": None,
|
"last_human_reply_body": None,
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,12 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
import re
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .reviewer_findings import Finding
|
from .reviewer_findings import DiffSide, Finding
|
||||||
from .utils.github_token import GitHubAuthError
|
from .utils.github_token import GitHubAuthError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -35,6 +36,52 @@ logger = logging.getLogger(__name__)
|
||||||
_GITHUB_API_BASE = "https://api.github.com"
|
_GITHUB_API_BASE = "https://api.github.com"
|
||||||
_GITHUB_GRAPHQL = "https://api.github.com/graphql"
|
_GITHUB_GRAPHQL = "https://api.github.com/graphql"
|
||||||
_GITHUB_HEADERS_VERSION = "2022-11-28"
|
_GITHUB_HEADERS_VERSION = "2022-11-28"
|
||||||
|
_OPEN_SWE_REVIEW_COMMENT_MARKER_RE = re.compile(
|
||||||
|
r"<!--\s*open-swe-review-comment\s+(\{.*?\})\s*-->",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewCommentMarker(TypedDict):
|
||||||
|
id: str
|
||||||
|
file_path: str
|
||||||
|
start_line: int | None
|
||||||
|
end_line: int | None
|
||||||
|
side: DiffSide
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_int(value: Any) -> int | None:
|
||||||
|
return value if isinstance(value, int) else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_review_comment_marker(body: str) -> ReviewCommentMarker | None:
|
||||||
|
match = _OPEN_SWE_REVIEW_COMMENT_MARKER_RE.search(body)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
finding_id = payload.get("id")
|
||||||
|
file_path = payload.get("file_path")
|
||||||
|
side_raw = payload.get("side", "RIGHT")
|
||||||
|
if not isinstance(finding_id, str) or not finding_id:
|
||||||
|
return None
|
||||||
|
if not isinstance(file_path, str) or not file_path:
|
||||||
|
return None
|
||||||
|
if side_raw not in {"LEFT", "RIGHT"}:
|
||||||
|
return None
|
||||||
|
side: DiffSide = "LEFT" if side_raw == "LEFT" else "RIGHT"
|
||||||
|
return {
|
||||||
|
"id": finding_id,
|
||||||
|
"file_path": file_path,
|
||||||
|
"start_line": _optional_int(payload.get("start_line")),
|
||||||
|
"end_line": _optional_int(payload.get("end_line")),
|
||||||
|
"side": side,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def render_inline_comment_body(finding: Finding) -> str:
|
def render_inline_comment_body(finding: Finding) -> str:
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,26 @@ from __future__ import annotations
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .reviewer_findings import Finding, list_findings, replace_findings
|
from .reviewer_findings import Finding, list_findings, replace_findings
|
||||||
|
from .reviewer_publish import parse_review_comment_marker
|
||||||
|
|
||||||
ReviewThread = dict[str, Any]
|
ReviewThread = dict[str, Any]
|
||||||
|
ReviewThreadMatch = tuple[ReviewThread, int | None]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_open_swe_bot_comment(comment: ReviewThread) -> bool:
|
||||||
|
return comment.get("author") == "open-swe[bot]"
|
||||||
|
|
||||||
|
|
||||||
|
def _int_list(value: Any) -> list[int]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [item for item in value if isinstance(item, int)]
|
||||||
|
|
||||||
|
|
||||||
|
def _str_list(value: Any) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [item for item in value if isinstance(item, str) and item]
|
||||||
|
|
||||||
|
|
||||||
def _human_replies_after_bot_comment(
|
def _human_replies_after_bot_comment(
|
||||||
|
|
@ -36,13 +54,14 @@ def _human_replies_after_bot_comment(
|
||||||
|
|
||||||
def _index_review_threads(
|
def _index_review_threads(
|
||||||
review_threads: list[ReviewThread],
|
review_threads: list[ReviewThread],
|
||||||
) -> tuple[dict[str, ReviewThread], dict[int, ReviewThread]]:
|
) -> tuple[dict[str, ReviewThread], dict[int, ReviewThread], dict[str, list[ReviewThreadMatch]]]:
|
||||||
by_thread_id = {
|
by_thread_id = {
|
||||||
thread_id: review_thread
|
thread_id: review_thread
|
||||||
for review_thread in review_threads
|
for review_thread in review_threads
|
||||||
if isinstance(thread_id := review_thread.get("id"), str) and thread_id
|
if isinstance(thread_id := review_thread.get("id"), str) and thread_id
|
||||||
}
|
}
|
||||||
by_comment_id: dict[int, ReviewThread] = {}
|
by_comment_id: dict[int, ReviewThread] = {}
|
||||||
|
by_marker_id: dict[str, list[ReviewThreadMatch]] = {}
|
||||||
for review_thread in review_threads:
|
for review_thread in review_threads:
|
||||||
comments = review_thread.get("comments")
|
comments = review_thread.get("comments")
|
||||||
if not isinstance(comments, list):
|
if not isinstance(comments, list):
|
||||||
|
|
@ -53,49 +72,114 @@ def _index_review_threads(
|
||||||
comment_id = comment.get("id")
|
comment_id = comment.get("id")
|
||||||
if isinstance(comment_id, int):
|
if isinstance(comment_id, int):
|
||||||
by_comment_id[comment_id] = review_thread
|
by_comment_id[comment_id] = review_thread
|
||||||
return by_thread_id, by_comment_id
|
body = comment.get("body")
|
||||||
|
if not isinstance(body, str) or not _is_open_swe_bot_comment(comment):
|
||||||
|
continue
|
||||||
|
marker = parse_review_comment_marker(body)
|
||||||
|
if marker is not None and isinstance(comment_id, int):
|
||||||
|
by_marker_id.setdefault(marker["id"], []).append((review_thread, comment_id))
|
||||||
|
return by_thread_id, by_comment_id, by_marker_id
|
||||||
|
|
||||||
|
|
||||||
def _find_review_thread_for_finding(
|
def _find_review_threads_for_finding(
|
||||||
finding: Finding,
|
finding: Finding,
|
||||||
*,
|
*,
|
||||||
by_thread_id: dict[str, ReviewThread],
|
by_thread_id: dict[str, ReviewThread],
|
||||||
by_comment_id: dict[int, ReviewThread],
|
by_comment_id: dict[int, ReviewThread],
|
||||||
) -> ReviewThread | None:
|
by_marker_id: dict[str, list[ReviewThreadMatch]],
|
||||||
|
) -> list[ReviewThreadMatch]:
|
||||||
|
finding_id = finding.get("id")
|
||||||
|
if isinstance(finding_id, str):
|
||||||
|
marker_match = by_marker_id.get(finding_id)
|
||||||
|
if marker_match:
|
||||||
|
return marker_match
|
||||||
|
|
||||||
github_thread_id = finding.get("github_review_thread_id")
|
github_thread_id = finding.get("github_review_thread_id")
|
||||||
if isinstance(github_thread_id, str) and github_thread_id:
|
if isinstance(github_thread_id, str) and github_thread_id:
|
||||||
review_thread = by_thread_id.get(github_thread_id)
|
review_thread = by_thread_id.get(github_thread_id)
|
||||||
if review_thread is not None:
|
if review_thread is not None:
|
||||||
return review_thread
|
comment_id = finding.get("github_review_comment_id")
|
||||||
|
return [(review_thread, comment_id if isinstance(comment_id, int) else None)]
|
||||||
|
|
||||||
github_comment_id = finding.get("github_review_comment_id")
|
github_comment_id = finding.get("github_review_comment_id")
|
||||||
if isinstance(github_comment_id, int):
|
if isinstance(github_comment_id, int):
|
||||||
return by_comment_id.get(github_comment_id)
|
review_thread = by_comment_id.get(github_comment_id)
|
||||||
return None
|
if review_thread is not None:
|
||||||
|
return [(review_thread, github_comment_id)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _sync_thread_status(finding: Finding, review_thread: ReviewThread) -> bool:
|
def _sync_publication_identity(
|
||||||
|
finding: Finding,
|
||||||
|
review_thread: ReviewThread,
|
||||||
|
comment_id: int | None,
|
||||||
|
) -> bool:
|
||||||
updated = False
|
updated = False
|
||||||
|
if isinstance(comment_id, int) and not isinstance(finding.get("github_review_comment_id"), int):
|
||||||
|
finding["github_review_comment_id"] = comment_id
|
||||||
|
updated = True
|
||||||
|
comment_ids = _int_list(finding.get("github_review_comment_ids"))
|
||||||
|
if isinstance(comment_id, int) and comment_id not in comment_ids:
|
||||||
|
comment_ids.append(comment_id)
|
||||||
|
finding["github_review_comment_ids"] = comment_ids
|
||||||
|
updated = True
|
||||||
|
|
||||||
github_thread_id = finding.get("github_review_thread_id")
|
github_thread_id = finding.get("github_review_thread_id")
|
||||||
|
new_thread_id = review_thread.get("id")
|
||||||
if not isinstance(github_thread_id, str) or not github_thread_id:
|
if not isinstance(github_thread_id, str) or not github_thread_id:
|
||||||
new_thread_id = review_thread.get("id")
|
|
||||||
if isinstance(new_thread_id, str) and new_thread_id:
|
if isinstance(new_thread_id, str) and new_thread_id:
|
||||||
finding["github_review_thread_id"] = new_thread_id
|
finding["github_review_thread_id"] = new_thread_id
|
||||||
updated = True
|
updated = True
|
||||||
|
thread_ids = _str_list(finding.get("github_review_thread_ids"))
|
||||||
if review_thread.get("is_resolved") or review_thread.get("is_outdated"):
|
if isinstance(new_thread_id, str) and new_thread_id and new_thread_id not in thread_ids:
|
||||||
if finding.get("status") == "open":
|
thread_ids.append(new_thread_id)
|
||||||
finding["status"] = "resolved"
|
finding["github_review_thread_ids"] = thread_ids
|
||||||
finding["last_reconciliation_note"] = "GitHub thread is resolved or outdated."
|
updated = True
|
||||||
updated = True
|
|
||||||
if review_thread.get("is_resolved") and not finding.get("github_thread_resolved"):
|
|
||||||
finding["github_thread_resolved"] = True
|
|
||||||
updated = True
|
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def _sync_latest_human_reply(finding: Finding, review_thread: ReviewThread) -> bool:
|
def _is_terminal_thread(review_thread: ReviewThread) -> bool:
|
||||||
github_comment_id = finding.get("github_review_comment_id")
|
return bool(review_thread.get("is_resolved") or review_thread.get("is_outdated"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_thread_status(finding: Finding, matches: list[ReviewThreadMatch]) -> bool:
|
||||||
|
if not matches or not all(_is_terminal_thread(review_thread) for review_thread, _ in matches):
|
||||||
|
return False
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
if finding.get("status") == "open":
|
||||||
|
finding["status"] = "resolved"
|
||||||
|
finding["last_reconciliation_note"] = "All GitHub threads are resolved or outdated."
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
resolved_thread_ids = _str_list(finding.get("github_resolved_thread_ids"))
|
||||||
|
all_resolved = True
|
||||||
|
for review_thread, _comment_id in matches:
|
||||||
|
thread_id = review_thread.get("id")
|
||||||
|
if review_thread.get("is_resolved") and isinstance(thread_id, str) and thread_id:
|
||||||
|
if thread_id not in resolved_thread_ids:
|
||||||
|
resolved_thread_ids.append(thread_id)
|
||||||
|
updated = True
|
||||||
|
else:
|
||||||
|
all_resolved = False
|
||||||
|
|
||||||
|
if resolved_thread_ids != _str_list(finding.get("github_resolved_thread_ids")):
|
||||||
|
finding["github_resolved_thread_ids"] = resolved_thread_ids
|
||||||
|
if all_resolved and not finding.get("github_thread_resolved"):
|
||||||
|
finding["github_thread_resolved"] = True
|
||||||
|
updated = True
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_latest_human_reply(
|
||||||
|
finding: Finding,
|
||||||
|
review_thread: ReviewThread,
|
||||||
|
*,
|
||||||
|
comment_id: int | None,
|
||||||
|
) -> bool:
|
||||||
|
github_comment_id = (
|
||||||
|
comment_id if isinstance(comment_id, int) else finding.get("github_review_comment_id")
|
||||||
|
)
|
||||||
if not isinstance(github_comment_id, int):
|
if not isinstance(github_comment_id, int):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -130,20 +214,22 @@ async def reconcile_findings_with_review_threads(
|
||||||
if not findings:
|
if not findings:
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
by_thread_id, by_comment_id = _index_review_threads(review_threads)
|
by_thread_id, by_comment_id, by_marker_id = _index_review_threads(review_threads)
|
||||||
|
|
||||||
updated = False
|
updated = False
|
||||||
for finding in findings:
|
for finding in findings:
|
||||||
review_thread = _find_review_thread_for_finding(
|
matches = _find_review_threads_for_finding(
|
||||||
finding,
|
finding,
|
||||||
by_thread_id=by_thread_id,
|
by_thread_id=by_thread_id,
|
||||||
by_comment_id=by_comment_id,
|
by_comment_id=by_comment_id,
|
||||||
|
by_marker_id=by_marker_id,
|
||||||
)
|
)
|
||||||
if review_thread is None:
|
for review_thread, comment_id in matches:
|
||||||
continue
|
updated = _sync_publication_identity(finding, review_thread, comment_id) or updated
|
||||||
|
updated = (
|
||||||
updated = _sync_thread_status(finding, review_thread) or updated
|
_sync_latest_human_reply(finding, review_thread, comment_id=comment_id) or updated
|
||||||
updated = _sync_latest_human_reply(finding, review_thread) or updated
|
)
|
||||||
|
updated = _sync_thread_status(finding, matches) or updated
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
await replace_findings(reviewer_thread_id, findings)
|
await replace_findings(reviewer_thread_id, findings)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from langgraph.config import get_config
|
||||||
|
|
||||||
from ..reviewer_diff import compute_diff_line_set, fetch_pr_diff, is_range_in_diff
|
from ..reviewer_diff import compute_diff_line_set, fetch_pr_diff, is_range_in_diff
|
||||||
from ..reviewer_findings import (
|
from ..reviewer_findings import (
|
||||||
|
Finding,
|
||||||
Severity,
|
Severity,
|
||||||
filter_findings_for_publish,
|
filter_findings_for_publish,
|
||||||
get_thread_id_from_runtime,
|
get_thread_id_from_runtime,
|
||||||
|
|
@ -24,11 +25,13 @@ from ..reviewer_publish import (
|
||||||
fetch_pr_review_threads,
|
fetch_pr_review_threads,
|
||||||
fetch_review_comments,
|
fetch_review_comments,
|
||||||
fetch_review_thread_id_for_comment,
|
fetch_review_thread_id_for_comment,
|
||||||
|
parse_review_comment_marker,
|
||||||
post_pull_request_review,
|
post_pull_request_review,
|
||||||
render_inline_comment_payload,
|
render_inline_comment_payload,
|
||||||
render_review_body,
|
render_review_body,
|
||||||
resolve_review_thread,
|
resolve_review_thread,
|
||||||
)
|
)
|
||||||
|
from ..reviewer_reconcile import reconcile_findings_with_review_threads
|
||||||
from ..utils.github_token import (
|
from ..utils.github_token import (
|
||||||
GitHubAuthError,
|
GitHubAuthError,
|
||||||
get_github_token,
|
get_github_token,
|
||||||
|
|
@ -142,9 +145,7 @@ async def _publish_review_eval_dry_run_async(
|
||||||
"""Simulate publish_review for benchmark runs without posting to GitHub."""
|
"""Simulate publish_review for benchmark runs without posting to GitHub."""
|
||||||
thread_id = get_thread_id_from_runtime()
|
thread_id = get_thread_id_from_runtime()
|
||||||
findings = await list_findings_async(thread_id)
|
findings = await list_findings_async(thread_id)
|
||||||
unpublished_findings = [
|
unpublished_findings = [f for f in findings if not _has_publication_identity(f)]
|
||||||
f for f in findings if not isinstance(f.get("github_review_comment_id"), int)
|
|
||||||
]
|
|
||||||
open_unpublished = [f for f in unpublished_findings if f.get("status", "open") == "open"]
|
open_unpublished = [f for f in unpublished_findings if f.get("status", "open") == "open"]
|
||||||
eligible = filter_findings_for_publish(
|
eligible = filter_findings_for_publish(
|
||||||
unpublished_findings,
|
unpublished_findings,
|
||||||
|
|
@ -182,7 +183,13 @@ async def _publish_review_async(
|
||||||
langgraph_run_id: str | None = None,
|
langgraph_run_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
thread_id = get_thread_id_from_runtime()
|
thread_id = get_thread_id_from_runtime()
|
||||||
findings = await list_findings_async(thread_id)
|
findings = await _backfill_findings_from_pr_threads(
|
||||||
|
thread_id=thread_id,
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
# Re-reviews only post NEW findings. Anything with a github_review_comment_id
|
# Re-reviews only post NEW findings. Anything with a github_review_comment_id
|
||||||
# already lives on GitHub from a prior publish — reposting would create
|
# already lives on GitHub from a prior publish — reposting would create
|
||||||
|
|
@ -191,6 +198,10 @@ async def _publish_review_async(
|
||||||
unpublished_findings = [
|
unpublished_findings = [
|
||||||
f for f in findings if not isinstance(f.get("github_review_comment_id"), int)
|
f for f in findings if not isinstance(f.get("github_review_comment_id"), int)
|
||||||
]
|
]
|
||||||
|
if is_re_review:
|
||||||
|
unpublished_findings = [
|
||||||
|
f for f in unpublished_findings if f.get("first_seen_sha") == head_sha
|
||||||
|
]
|
||||||
open_unpublished = [f for f in unpublished_findings if f.get("status", "open") == "open"]
|
open_unpublished = [f for f in unpublished_findings if f.get("status", "open") == "open"]
|
||||||
eligible = filter_findings_for_publish(
|
eligible = filter_findings_for_publish(
|
||||||
unpublished_findings, severity_threshold=severity_threshold, cap=cap
|
unpublished_findings, severity_threshold=severity_threshold, cap=cap
|
||||||
|
|
@ -319,6 +330,12 @@ 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 inline_comments:
|
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(
|
comment_records = await fetch_review_comments(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
repo=repo,
|
repo=repo,
|
||||||
|
|
@ -338,6 +355,15 @@ async def _publish_review_async(
|
||||||
comment_records=comment_records,
|
comment_records=comment_records,
|
||||||
langgraph_run_id=langgraph_run_id,
|
langgraph_run_id=langgraph_run_id,
|
||||||
)
|
)
|
||||||
|
current_findings = await list_findings_async(thread_id)
|
||||||
|
if _missing_comment_ids_for_published_findings(current_findings, eligible_with_payload):
|
||||||
|
await _backfill_findings_from_pr_threads(
|
||||||
|
thread_id=thread_id,
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
await _store_thread_ids_on_findings(
|
await _store_thread_ids_on_findings(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
|
@ -382,6 +408,99 @@ async def _publish_review_async(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _has_publication_identity(finding: Finding) -> bool:
|
||||||
|
return isinstance(finding.get("github_review_comment_id"), int) or isinstance(
|
||||||
|
finding.get("github_review_id"), int
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _int_list(value: Any) -> list[int]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [item for item in value if isinstance(item, int)]
|
||||||
|
|
||||||
|
|
||||||
|
def _str_list(value: Any) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [item for item in value if isinstance(item, str) and item]
|
||||||
|
|
||||||
|
|
||||||
|
def _comment_ids_for_finding(finding: dict[str, Any]) -> list[int]:
|
||||||
|
comment_ids = _int_list(finding.get("github_review_comment_ids"))
|
||||||
|
comment_id = finding.get("github_review_comment_id")
|
||||||
|
if isinstance(comment_id, int) and comment_id not in comment_ids:
|
||||||
|
comment_ids.insert(0, comment_id)
|
||||||
|
return comment_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_ids_for_finding(finding: dict[str, Any]) -> list[str]:
|
||||||
|
thread_ids = _str_list(finding.get("github_review_thread_ids"))
|
||||||
|
thread_id = finding.get("github_review_thread_id")
|
||||||
|
if isinstance(thread_id, str) and thread_id and thread_id not in thread_ids:
|
||||||
|
thread_ids.insert(0, thread_id)
|
||||||
|
return thread_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def _backfill_findings_from_pr_threads(
|
||||||
|
*,
|
||||||
|
thread_id: str,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
token: str,
|
||||||
|
) -> list[Finding]:
|
||||||
|
findings = await list_findings_async(thread_id)
|
||||||
|
if not findings:
|
||||||
|
return findings
|
||||||
|
review_threads = await fetch_pr_review_threads(
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
if not review_threads:
|
||||||
|
return findings
|
||||||
|
return await reconcile_findings_with_review_threads(thread_id, review_threads)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_comment_ids_for_published_findings(
|
||||||
|
findings: list[Finding],
|
||||||
|
eligible_with_payload: list[tuple[dict[str, Any], dict[str, Any]]],
|
||||||
|
) -> bool:
|
||||||
|
finding_ids = {
|
||||||
|
finding.get("id")
|
||||||
|
for finding, _payload in eligible_with_payload
|
||||||
|
if isinstance(finding.get("id"), str)
|
||||||
|
}
|
||||||
|
for finding in findings:
|
||||||
|
if finding.get("id") in finding_ids and not isinstance(
|
||||||
|
finding.get("github_review_comment_id"), int
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_review_id_on_findings(
|
||||||
|
*,
|
||||||
|
thread_id: str,
|
||||||
|
findings: list[Finding],
|
||||||
|
eligible_with_payload: list[tuple[dict[str, Any], dict[str, Any]]],
|
||||||
|
review_id: int,
|
||||||
|
) -> None:
|
||||||
|
finding_ids = {
|
||||||
|
finding.get("id")
|
||||||
|
for finding, _payload in eligible_with_payload
|
||||||
|
if isinstance(finding.get("id"), str)
|
||||||
|
}
|
||||||
|
updated = False
|
||||||
|
for finding in findings:
|
||||||
|
if finding.get("id") in finding_ids and finding.get("github_review_id") != review_id:
|
||||||
|
finding["github_review_id"] = review_id
|
||||||
|
if updated:
|
||||||
|
await replace_findings(thread_id, findings)
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_diff_line_set(
|
async def _resolve_diff_line_set(
|
||||||
*,
|
*,
|
||||||
owner: str,
|
owner: str,
|
||||||
|
|
@ -493,16 +612,17 @@ async def _maybe_post_slack_completion_reply(
|
||||||
async def _store_comment_ids_on_findings(
|
async def _store_comment_ids_on_findings(
|
||||||
*,
|
*,
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
findings: list[dict[str, Any]],
|
findings: list[Finding],
|
||||||
eligible_with_payload: list[tuple[dict[str, Any], dict[str, Any]]],
|
eligible_with_payload: list[tuple[dict[str, Any], dict[str, Any]]],
|
||||||
comment_records: list[dict[str, Any]],
|
comment_records: list[dict[str, Any]],
|
||||||
langgraph_run_id: str | None,
|
langgraph_run_id: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Match returned GitHub comment ids back to the findings that produced them.
|
"""Match returned GitHub comment ids back to the findings that produced them.
|
||||||
|
|
||||||
Match key is ``(path, line, body)`` since we don't have a server-side hint
|
Prefer the Open SWE marker because it survives body formatting changes;
|
||||||
pointing each REST comment to its source finding.
|
fall back to ``(path, line, body)`` for older comments.
|
||||||
"""
|
"""
|
||||||
|
by_marker_id: dict[str, int] = {}
|
||||||
by_key: dict[tuple[str, int, str], int] = {}
|
by_key: dict[tuple[str, int, str], int] = {}
|
||||||
for record in comment_records:
|
for record in comment_records:
|
||||||
path = record.get("path")
|
path = record.get("path")
|
||||||
|
|
@ -516,25 +636,33 @@ async def _store_comment_ids_on_findings(
|
||||||
and isinstance(comment_id, int)
|
and isinstance(comment_id, int)
|
||||||
):
|
):
|
||||||
by_key[(path, line, body)] = comment_id
|
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
|
updated = False
|
||||||
findings_by_id = {f.get("id"): f for f in findings}
|
findings_by_id = {f.get("id"): f for f in findings}
|
||||||
for finding_snapshot, payload in eligible_with_payload:
|
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")
|
line_value = payload.get("line")
|
||||||
if not isinstance(line_value, int):
|
if comment_id is None and isinstance(line_value, int):
|
||||||
continue
|
key = (
|
||||||
key = (
|
str(payload.get("path", "")),
|
||||||
str(payload.get("path", "")),
|
line_value,
|
||||||
line_value,
|
str(payload.get("body", "")),
|
||||||
str(payload.get("body", "")),
|
)
|
||||||
)
|
comment_id = by_key.get(key)
|
||||||
comment_id = by_key.get(key)
|
|
||||||
if comment_id is None:
|
if comment_id is None:
|
||||||
continue
|
continue
|
||||||
finding = findings_by_id.get(finding_snapshot.get("id"))
|
finding = findings_by_id.get(finding_id)
|
||||||
if finding is None:
|
if finding is None:
|
||||||
continue
|
continue
|
||||||
finding["github_review_comment_id"] = comment_id
|
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 langgraph_run_id:
|
if langgraph_run_id:
|
||||||
finding["github_review_run_id"] = langgraph_run_id
|
finding["github_review_run_id"] = langgraph_run_id
|
||||||
updated = True
|
updated = True
|
||||||
|
|
@ -552,16 +680,12 @@ async def _store_thread_ids_on_findings(
|
||||||
token: str,
|
token: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
findings = await list_findings_async(thread_id)
|
findings = await list_findings_async(thread_id)
|
||||||
comment_ids_by_finding_id: dict[str, int] = {}
|
comment_ids_by_finding_id: dict[str, list[int]] = {}
|
||||||
for finding in findings:
|
for finding in findings:
|
||||||
finding_id = finding.get("id")
|
finding_id = finding.get("id")
|
||||||
comment_id = finding.get("github_review_comment_id")
|
comment_ids = _comment_ids_for_finding(finding)
|
||||||
if (
|
if isinstance(finding_id, str) and comment_ids and not _thread_ids_for_finding(finding):
|
||||||
isinstance(finding_id, str)
|
comment_ids_by_finding_id[finding_id] = comment_ids
|
||||||
and isinstance(comment_id, int)
|
|
||||||
and not isinstance(finding.get("github_review_thread_id"), str)
|
|
||||||
):
|
|
||||||
comment_ids_by_finding_id[finding_id] = comment_id
|
|
||||||
if not comment_ids_by_finding_id:
|
if not comment_ids_by_finding_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -588,12 +712,18 @@ async def _store_thread_ids_on_findings(
|
||||||
finding_id = finding.get("id")
|
finding_id = finding.get("id")
|
||||||
if not isinstance(finding_id, str):
|
if not isinstance(finding_id, str):
|
||||||
continue
|
continue
|
||||||
comment_id = comment_ids_by_finding_id.get(finding_id)
|
thread_ids = _thread_ids_for_finding(finding)
|
||||||
if not isinstance(comment_id, int):
|
for comment_id in comment_ids_by_finding_id.get(finding_id, []):
|
||||||
continue
|
github_thread_id = thread_id_by_comment_id.get(comment_id)
|
||||||
github_thread_id = thread_id_by_comment_id.get(comment_id)
|
if not github_thread_id:
|
||||||
if github_thread_id:
|
continue
|
||||||
finding["github_review_thread_id"] = github_thread_id
|
if not isinstance(finding.get("github_review_thread_id"), str):
|
||||||
|
finding["github_review_thread_id"] = github_thread_id
|
||||||
|
updated = True
|
||||||
|
if github_thread_id not in thread_ids:
|
||||||
|
thread_ids.append(github_thread_id)
|
||||||
|
finding["github_review_thread_ids"] = thread_ids
|
||||||
|
updated = True
|
||||||
updated = True
|
updated = True
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
|
|
@ -610,24 +740,17 @@ async def _resolve_threads_for_resolved_findings(
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Resolve GitHub review threads for findings that just transitioned to resolved.
|
"""Resolve GitHub review threads for findings that just transitioned to resolved.
|
||||||
|
|
||||||
A finding qualifies if:
|
Resolves every known GitHub thread for a finding. Multiple threads can
|
||||||
- status == ``resolved``
|
exist when an earlier run duplicated a comment before publication identity
|
||||||
- has a ``github_review_comment_id`` from a prior publish
|
was backfilled.
|
||||||
- has not already been GitHub-resolved (tracked via
|
|
||||||
``github_thread_resolved`` flag we write back here)
|
|
||||||
"""
|
"""
|
||||||
resolved_count = 0
|
resolved_count = 0
|
||||||
mutated = False
|
mutated = False
|
||||||
for finding in findings:
|
for finding in findings:
|
||||||
if finding.get("status") != "resolved":
|
if finding.get("status") != "resolved":
|
||||||
continue
|
continue
|
||||||
comment_id = finding.get("github_review_comment_id")
|
thread_node_ids = _thread_ids_for_finding(finding)
|
||||||
if not isinstance(comment_id, int):
|
for comment_id in _comment_ids_for_finding(finding):
|
||||||
continue
|
|
||||||
if finding.get("github_thread_resolved"):
|
|
||||||
continue
|
|
||||||
thread_node_id = finding.get("github_review_thread_id")
|
|
||||||
if not isinstance(thread_node_id, str) or not thread_node_id:
|
|
||||||
thread_node_id = await fetch_review_thread_id_for_comment(
|
thread_node_id = await fetch_review_thread_id_for_comment(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
repo=repo,
|
repo=repo,
|
||||||
|
|
@ -635,13 +758,32 @@ async def _resolve_threads_for_resolved_findings(
|
||||||
review_comment_id=comment_id,
|
review_comment_id=comment_id,
|
||||||
token=token,
|
token=token,
|
||||||
)
|
)
|
||||||
if not thread_node_id:
|
if thread_node_id and thread_node_id not in thread_node_ids:
|
||||||
|
thread_node_ids.append(thread_node_id)
|
||||||
|
|
||||||
|
if not thread_node_ids:
|
||||||
continue
|
continue
|
||||||
ok = await resolve_review_thread(thread_node_id=thread_node_id, token=token)
|
|
||||||
if ok:
|
resolved_thread_ids = _str_list(finding.get("github_resolved_thread_ids"))
|
||||||
|
for thread_node_id in thread_node_ids:
|
||||||
|
if thread_node_id in resolved_thread_ids:
|
||||||
|
continue
|
||||||
|
ok = await resolve_review_thread(thread_node_id=thread_node_id, token=token)
|
||||||
|
if ok:
|
||||||
|
resolved_thread_ids.append(thread_node_id)
|
||||||
|
resolved_count += 1
|
||||||
|
mutated = True
|
||||||
|
|
||||||
|
if resolved_thread_ids:
|
||||||
|
finding["github_resolved_thread_ids"] = resolved_thread_ids
|
||||||
|
if thread_node_ids:
|
||||||
|
finding["github_review_thread_ids"] = thread_node_ids
|
||||||
|
if not isinstance(finding.get("github_review_thread_id"), str):
|
||||||
|
finding["github_review_thread_id"] = thread_node_ids[0]
|
||||||
|
if thread_node_ids and all(
|
||||||
|
thread_id in resolved_thread_ids for thread_id in thread_node_ids
|
||||||
|
):
|
||||||
finding["github_thread_resolved"] = True
|
finding["github_thread_resolved"] = True
|
||||||
mutated = True
|
|
||||||
resolved_count += 1
|
|
||||||
|
|
||||||
if mutated:
|
if mutated:
|
||||||
thread_id = get_thread_id_from_runtime()
|
thread_id = get_thread_id_from_runtime()
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,18 @@ from typing import Any
|
||||||
|
|
||||||
from langgraph.config import get_config
|
from langgraph.config import get_config
|
||||||
|
|
||||||
from ..reviewer_findings import get_finding, get_thread_id_from_runtime, update_finding_fields
|
from ..reviewer_findings import (
|
||||||
from ..reviewer_publish import fetch_review_thread_id_for_comment, resolve_review_thread
|
Finding,
|
||||||
|
get_finding,
|
||||||
|
get_thread_id_from_runtime,
|
||||||
|
update_finding_fields,
|
||||||
|
)
|
||||||
|
from ..reviewer_publish import (
|
||||||
|
fetch_pr_review_threads,
|
||||||
|
fetch_review_thread_id_for_comment,
|
||||||
|
resolve_review_thread,
|
||||||
|
)
|
||||||
|
from ..reviewer_reconcile import reconcile_findings_with_review_threads
|
||||||
from ..utils.github_token import get_github_token
|
from ..utils.github_token import get_github_token
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,35 +74,110 @@ async def _resolve_finding_thread_async(
|
||||||
token: str,
|
token: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
thread_id = get_thread_id_from_runtime()
|
thread_id = get_thread_id_from_runtime()
|
||||||
finding = await get_finding(thread_id, finding_id)
|
finding = await _get_finding_with_pr_backfill(
|
||||||
|
thread_id=thread_id,
|
||||||
|
finding_id=finding_id,
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
if finding is None:
|
if finding is None:
|
||||||
return {"success": False, "error": f"No finding found with id {finding_id}"}
|
return {"success": False, "error": f"No finding found with id {finding_id}"}
|
||||||
|
|
||||||
github_thread_id = finding.get("github_review_thread_id")
|
github_thread_ids = _thread_ids_for_finding(finding)
|
||||||
if not isinstance(github_thread_id, str) or not github_thread_id:
|
for comment_id in _comment_ids_for_finding(finding):
|
||||||
comment_id = finding.get("github_review_comment_id")
|
thread_node_id = await fetch_review_thread_id_for_comment(
|
||||||
if not isinstance(comment_id, int):
|
|
||||||
return {"success": False, "error": "Finding has no GitHub review thread mapping"}
|
|
||||||
github_thread_id = await fetch_review_thread_id_for_comment(
|
|
||||||
owner=owner,
|
owner=owner,
|
||||||
repo=repo,
|
repo=repo,
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
review_comment_id=comment_id,
|
review_comment_id=comment_id,
|
||||||
token=token,
|
token=token,
|
||||||
)
|
)
|
||||||
if not github_thread_id:
|
if thread_node_id and thread_node_id not in github_thread_ids:
|
||||||
|
github_thread_ids.append(thread_node_id)
|
||||||
|
if not github_thread_ids:
|
||||||
return {"success": False, "error": "Could not resolve GitHub review thread id"}
|
return {"success": False, "error": "Could not resolve GitHub review thread id"}
|
||||||
|
|
||||||
ok = await resolve_review_thread(thread_node_id=github_thread_id, token=token)
|
resolved_thread_ids = _str_list(finding.get("github_resolved_thread_ids"))
|
||||||
if not ok:
|
resolved_count = 0
|
||||||
|
for github_thread_id in github_thread_ids:
|
||||||
|
if github_thread_id in resolved_thread_ids:
|
||||||
|
continue
|
||||||
|
ok = await resolve_review_thread(thread_node_id=github_thread_id, token=token)
|
||||||
|
if ok:
|
||||||
|
resolved_thread_ids.append(github_thread_id)
|
||||||
|
resolved_count += 1
|
||||||
|
if resolved_count == 0 and not all(
|
||||||
|
github_thread_id in resolved_thread_ids for github_thread_id in github_thread_ids
|
||||||
|
):
|
||||||
return {"success": False, "error": "GitHub did not resolve the review thread"}
|
return {"success": False, "error": "GitHub did not resolve the review thread"}
|
||||||
|
|
||||||
updates: dict[str, Any] = {
|
updates: dict[str, Any] = {
|
||||||
"status": status,
|
"status": status,
|
||||||
"github_review_thread_id": github_thread_id,
|
"github_review_thread_id": github_thread_ids[0],
|
||||||
"github_thread_resolved": True,
|
"github_review_thread_ids": github_thread_ids,
|
||||||
|
"github_resolved_thread_ids": resolved_thread_ids,
|
||||||
|
"github_thread_resolved": all(
|
||||||
|
github_thread_id in resolved_thread_ids for github_thread_id in github_thread_ids
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if note:
|
if note:
|
||||||
updates["last_reconciliation_note"] = note
|
updates["last_reconciliation_note"] = note
|
||||||
updated = await update_finding_fields(thread_id, finding_id, updates)
|
updated = await update_finding_fields(thread_id, finding_id, updates)
|
||||||
return {"success": True, "finding": updated}
|
return {"success": True, "finding": updated, "resolved_thread_count": resolved_count}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_finding_with_pr_backfill(
|
||||||
|
*,
|
||||||
|
thread_id: str,
|
||||||
|
finding_id: str,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
token: str,
|
||||||
|
) -> Finding | None:
|
||||||
|
finding = await get_finding(thread_id, finding_id)
|
||||||
|
if finding is None:
|
||||||
|
return None
|
||||||
|
if _thread_ids_for_finding(finding) or _comment_ids_for_finding(finding):
|
||||||
|
return finding
|
||||||
|
|
||||||
|
review_threads = await fetch_pr_review_threads(
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
if review_threads:
|
||||||
|
await reconcile_findings_with_review_threads(thread_id, review_threads)
|
||||||
|
finding = await get_finding(thread_id, finding_id)
|
||||||
|
return finding
|
||||||
|
|
||||||
|
|
||||||
|
def _int_list(value: Any) -> list[int]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [item for item in value if isinstance(item, int)]
|
||||||
|
|
||||||
|
|
||||||
|
def _str_list(value: Any) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [item for item in value if isinstance(item, str) and item]
|
||||||
|
|
||||||
|
|
||||||
|
def _comment_ids_for_finding(finding: dict[str, Any]) -> list[int]:
|
||||||
|
comment_ids = _int_list(finding.get("github_review_comment_ids"))
|
||||||
|
comment_id = finding.get("github_review_comment_id")
|
||||||
|
if isinstance(comment_id, int) and comment_id not in comment_ids:
|
||||||
|
comment_ids.insert(0, comment_id)
|
||||||
|
return comment_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_ids_for_finding(finding: dict[str, Any]) -> list[str]:
|
||||||
|
thread_ids = _str_list(finding.get("github_review_thread_ids"))
|
||||||
|
thread_id = finding.get("github_review_thread_id")
|
||||||
|
if isinstance(thread_id, str) and thread_id and thread_id not in thread_ids:
|
||||||
|
thread_ids.insert(0, thread_id)
|
||||||
|
return thread_ids
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,14 @@ def test_new_finding_defaults() -> None:
|
||||||
assert finding["side"] == "RIGHT"
|
assert finding["side"] == "RIGHT"
|
||||||
assert finding["first_seen_sha"] == "abc123"
|
assert finding["first_seen_sha"] == "abc123"
|
||||||
assert finding["last_confirmed_sha"] == "abc123"
|
assert finding["last_confirmed_sha"] == "abc123"
|
||||||
|
assert finding["github_review_id"] is None
|
||||||
assert finding["github_review_comment_id"] is None
|
assert finding["github_review_comment_id"] is None
|
||||||
|
assert finding["github_review_comment_ids"] == []
|
||||||
assert finding["github_review_thread_id"] is None
|
assert finding["github_review_thread_id"] is None
|
||||||
|
assert finding["github_review_thread_ids"] == []
|
||||||
assert finding["github_review_run_id"] is None
|
assert finding["github_review_run_id"] is None
|
||||||
assert finding["github_thread_resolved"] is False
|
assert finding["github_thread_resolved"] is False
|
||||||
|
assert finding["github_resolved_thread_ids"] == []
|
||||||
assert finding["last_human_reply_at"] is None
|
assert finding["last_human_reply_at"] is None
|
||||||
assert finding["suggestion"] is None
|
assert finding["suggestion"] is None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
|
@ -10,6 +11,7 @@ import pytest
|
||||||
from agent.reviewer_findings import Finding, new_finding
|
from agent.reviewer_findings import Finding, new_finding
|
||||||
from agent.reviewer_publish import (
|
from agent.reviewer_publish import (
|
||||||
fetch_pr_review_threads,
|
fetch_pr_review_threads,
|
||||||
|
parse_review_comment_marker,
|
||||||
post_pull_request_review,
|
post_pull_request_review,
|
||||||
render_inline_comment_body,
|
render_inline_comment_body,
|
||||||
render_inline_comment_payload,
|
render_inline_comment_payload,
|
||||||
|
|
@ -34,6 +36,15 @@ def _f(**overrides: Any) -> Finding:
|
||||||
return base
|
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()),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
def test_render_inline_comment_body_without_suggestion() -> None:
|
def test_render_inline_comment_body_without_suggestion() -> None:
|
||||||
body = render_inline_comment_body(_f(description="just text"))
|
body = render_inline_comment_body(_f(description="just text"))
|
||||||
assert "<!-- open-swe-review-comment" in body
|
assert "<!-- open-swe-review-comment" in body
|
||||||
|
|
@ -51,6 +62,36 @@ def test_render_inline_comment_body_with_suggestion_appends_block() -> None:
|
||||||
assert "x = 1\nx += 1" in body
|
assert "x = 1\nx += 1" in body
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def test_render_inline_comment_payload_single_line() -> None:
|
||||||
payload = render_inline_comment_payload(_f(start_line=10, end_line=10))
|
payload = render_inline_comment_payload(_f(start_line=10, end_line=10))
|
||||||
assert payload is not None
|
assert payload is not None
|
||||||
|
|
@ -313,6 +354,247 @@ async def test_publish_review_skips_post_on_re_review_with_no_new_findings() ->
|
||||||
assert result["skipped_empty_re_review"] is True
|
assert result["skipped_empty_re_review"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@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",
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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.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 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_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
|
@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."""
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,185 @@ async def test_reconcile_marks_resolved_github_thread_resolved() -> None:
|
||||||
replace.assert_awaited_once()
|
replace.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconcile_backfills_comment_and_thread_ids_from_bot_marker() -> None:
|
||||||
|
findings = [
|
||||||
|
{
|
||||||
|
"id": "f1",
|
||||||
|
"status": "open",
|
||||||
|
"github_review_comment_id": None,
|
||||||
|
"github_review_thread_id": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
replace = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.reviewer_reconcile.list_findings", AsyncMock(return_value=findings)),
|
||||||
|
patch("agent.reviewer_reconcile.replace_findings", replace),
|
||||||
|
):
|
||||||
|
result = await reconcile_findings_with_review_threads(
|
||||||
|
"tid",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "THREAD_1",
|
||||||
|
"is_resolved": False,
|
||||||
|
"is_outdated": False,
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"author": "open-swe[bot]",
|
||||||
|
"body": (
|
||||||
|
'<!-- open-swe-review-comment {"id":"f1",'
|
||||||
|
'"file_path":"a.py","start_line":1,'
|
||||||
|
'"end_line":1,"side":"RIGHT"} -->\n\nbug'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result[0]["github_review_comment_id"] == 11
|
||||||
|
assert result[0]["github_review_comment_ids"] == [11]
|
||||||
|
assert result[0]["github_review_thread_id"] == "THREAD_1"
|
||||||
|
assert result[0]["github_review_thread_ids"] == ["THREAD_1"]
|
||||||
|
replace.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconcile_duplicate_markers_require_all_threads_terminal() -> None:
|
||||||
|
findings = [
|
||||||
|
{
|
||||||
|
"id": "f1",
|
||||||
|
"status": "open",
|
||||||
|
"github_review_comment_id": None,
|
||||||
|
"github_review_thread_id": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
replace = AsyncMock()
|
||||||
|
marker = (
|
||||||
|
'<!-- open-swe-review-comment {"id":"f1",'
|
||||||
|
'"file_path":"a.py","start_line":1,'
|
||||||
|
'"end_line":1,"side":"RIGHT"} -->\n\nbug'
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.reviewer_reconcile.list_findings", AsyncMock(return_value=findings)),
|
||||||
|
patch("agent.reviewer_reconcile.replace_findings", replace),
|
||||||
|
):
|
||||||
|
result = await reconcile_findings_with_review_threads(
|
||||||
|
"tid",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "THREAD_OLD",
|
||||||
|
"is_resolved": False,
|
||||||
|
"is_outdated": True,
|
||||||
|
"comments": [{"id": 11, "author": "open-swe[bot]", "body": marker}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "THREAD_OPEN",
|
||||||
|
"is_resolved": False,
|
||||||
|
"is_outdated": False,
|
||||||
|
"comments": [{"id": 12, "author": "open-swe[bot]", "body": marker}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result[0]["status"] == "open"
|
||||||
|
assert result[0]["github_review_comment_ids"] == [11, 12]
|
||||||
|
assert result[0]["github_review_thread_ids"] == ["THREAD_OLD", "THREAD_OPEN"]
|
||||||
|
replace.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconcile_duplicate_markers_resolve_when_all_threads_terminal() -> None:
|
||||||
|
findings = [
|
||||||
|
{
|
||||||
|
"id": "f1",
|
||||||
|
"status": "open",
|
||||||
|
"github_review_comment_id": None,
|
||||||
|
"github_review_thread_id": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
replace = AsyncMock()
|
||||||
|
marker = (
|
||||||
|
'<!-- open-swe-review-comment {"id":"f1",'
|
||||||
|
'"file_path":"a.py","start_line":1,'
|
||||||
|
'"end_line":1,"side":"RIGHT"} -->\n\nbug'
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.reviewer_reconcile.list_findings", AsyncMock(return_value=findings)),
|
||||||
|
patch("agent.reviewer_reconcile.replace_findings", replace),
|
||||||
|
):
|
||||||
|
result = await reconcile_findings_with_review_threads(
|
||||||
|
"tid",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "THREAD_OLD",
|
||||||
|
"is_resolved": False,
|
||||||
|
"is_outdated": True,
|
||||||
|
"comments": [{"id": 11, "author": "open-swe[bot]", "body": marker}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "THREAD_RESOLVED",
|
||||||
|
"is_resolved": True,
|
||||||
|
"is_outdated": False,
|
||||||
|
"comments": [{"id": 12, "author": "open-swe[bot]", "body": marker}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result[0]["status"] == "resolved"
|
||||||
|
assert result[0]["last_reconciliation_note"] == "All GitHub threads are resolved or outdated."
|
||||||
|
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_ignores_spoofed_non_bot_marker() -> None:
|
||||||
|
findings = [
|
||||||
|
{
|
||||||
|
"id": "f1",
|
||||||
|
"status": "open",
|
||||||
|
"github_review_comment_id": None,
|
||||||
|
"github_review_thread_id": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
replace = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.reviewer_reconcile.list_findings", AsyncMock(return_value=findings)),
|
||||||
|
patch("agent.reviewer_reconcile.replace_findings", replace),
|
||||||
|
):
|
||||||
|
result = await reconcile_findings_with_review_threads(
|
||||||
|
"tid",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "THREAD_1",
|
||||||
|
"is_resolved": False,
|
||||||
|
"is_outdated": False,
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"author": "human",
|
||||||
|
"body": (
|
||||||
|
'<!-- open-swe-review-comment {"id":"f1",'
|
||||||
|
'"file_path":"a.py","start_line":1,'
|
||||||
|
'"end_line":1,"side":"RIGHT"} -->\n\nspoof'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result[0]["github_review_comment_id"] is None
|
||||||
|
assert result[0]["github_review_thread_id"] is None
|
||||||
|
replace.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reconcile_records_latest_human_reply_after_bot_comment() -> None:
|
async def test_reconcile_records_latest_human_reply_after_bot_comment() -> None:
|
||||||
findings = [{"id": "f1", "status": "open", "github_review_comment_id": 11}]
|
findings = [{"id": "f1", "status": "open", "github_review_comment_id": 11}]
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from agent.tools.add_finding import add_finding
|
from agent.tools.add_finding import add_finding
|
||||||
from agent.tools.list_findings import list_findings
|
from agent.tools.list_findings import list_findings
|
||||||
|
from agent.tools.resolve_finding_thread import resolve_finding_thread
|
||||||
from agent.tools.update_finding import update_finding
|
from agent.tools.update_finding import update_finding
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -196,6 +197,40 @@ def test_update_finding_rejects_invalid_status() -> None:
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_finding_thread_resolves_all_known_threads() -> None:
|
||||||
|
finding = {
|
||||||
|
"id": "f1",
|
||||||
|
"status": "open",
|
||||||
|
"github_review_thread_ids": ["THREAD_1", "THREAD_2"],
|
||||||
|
"github_review_comment_ids": [11, 12],
|
||||||
|
}
|
||||||
|
update = AsyncMock(return_value={**finding, "status": "resolved"})
|
||||||
|
resolve = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.tools.resolve_finding_thread.get_config",
|
||||||
|
return_value=_config(repo={"owner": "o", "name": "r"}, pr_number=7),
|
||||||
|
),
|
||||||
|
patch("agent.tools.resolve_finding_thread.get_github_token", return_value="token"),
|
||||||
|
patch("agent.tools.resolve_finding_thread.get_thread_id_from_runtime", return_value="tid"),
|
||||||
|
patch("agent.tools.resolve_finding_thread.get_finding", AsyncMock(return_value=finding)),
|
||||||
|
patch("agent.tools.resolve_finding_thread.resolve_review_thread", resolve),
|
||||||
|
patch("agent.tools.resolve_finding_thread.update_finding_fields", update),
|
||||||
|
):
|
||||||
|
result = resolve_finding_thread("f1", status="resolved")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["resolved_thread_count"] == 2
|
||||||
|
assert [call.kwargs["thread_node_id"] for call in resolve.await_args_list] == [
|
||||||
|
"THREAD_1",
|
||||||
|
"THREAD_2",
|
||||||
|
]
|
||||||
|
updates = update.await_args.args[2]
|
||||||
|
assert updates["github_thread_resolved"] is True
|
||||||
|
assert updates["github_resolved_thread_ids"] == ["THREAD_1", "THREAD_2"]
|
||||||
|
|
||||||
|
|
||||||
def test_update_finding_rejects_empty_update() -> None:
|
def test_update_finding_rejects_empty_update() -> None:
|
||||||
with patch("agent.tools.update_finding.get_config", return_value=_config()):
|
with patch("agent.tools.update_finding.get_config", return_value=_config()):
|
||||||
result = update_finding(finding_id="f_x")
|
result = update_finding(finding_id="f_x")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue