open-swe/agent/review/reconcile.py

296 lines
11 KiB
Python

from __future__ import annotations
from typing import Any
from .findings import (
Finding,
FindingInteraction,
_coerce_surface,
list_findings,
replace_findings,
)
from .publish import parse_review_comment_marker
ReviewThread = dict[str, Any]
ReviewThreadMatch = tuple[ReviewThread, int | None]
def _is_open_swe_bot_comment(comment: ReviewThread) -> bool:
return comment.get("author") in {"open-swe", "open-swe[bot]", "seahaven-openswe[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(
review_thread: ReviewThread,
*,
bot_comment_id: int,
) -> list[ReviewThread]:
comments = review_thread.get("comments")
if not isinstance(comments, list):
return []
seen_bot_comment = False
replies: list[ReviewThread] = []
for comment in comments:
if not isinstance(comment, dict):
continue
comment_id = comment.get("id")
if comment_id == bot_comment_id:
seen_bot_comment = True
continue
if not seen_bot_comment:
continue
author = comment.get("author")
if author in {"open-swe", "open-swe[bot]", "seahaven-openswe[bot]"}:
continue
replies.append(comment)
return replies
def _index_review_threads(
review_threads: list[ReviewThread],
) -> tuple[dict[str, ReviewThread], dict[int, ReviewThread], dict[str, list[ReviewThreadMatch]]]:
by_thread_id = {
thread_id: review_thread
for review_thread in review_threads
if isinstance(thread_id := review_thread.get("id"), str) and thread_id
}
by_comment_id: dict[int, ReviewThread] = {}
by_marker_id: dict[str, list[ReviewThreadMatch]] = {}
for review_thread in review_threads:
comments = review_thread.get("comments")
if not isinstance(comments, list):
continue
for comment in comments:
if not isinstance(comment, dict):
continue
comment_id = comment.get("id")
if isinstance(comment_id, int):
by_comment_id[comment_id] = review_thread
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_threads_for_finding(
finding: Finding,
*,
by_thread_id: dict[str, ReviewThread],
by_comment_id: dict[int, ReviewThread],
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")
if isinstance(github_thread_id, str) and github_thread_id:
review_thread = by_thread_id.get(github_thread_id)
if review_thread is not None:
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")
if isinstance(github_comment_id, int):
review_thread = by_comment_id.get(github_comment_id)
if review_thread is not None:
return [(review_thread, github_comment_id)]
return []
def _sync_publication_identity(
finding: Finding,
review_thread: ReviewThread,
comment_id: int | None,
) -> bool:
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")
new_thread_id = review_thread.get("id")
if not isinstance(github_thread_id, str) or not github_thread_id:
if isinstance(new_thread_id, str) and new_thread_id:
finding["github_review_thread_id"] = new_thread_id
updated = True
thread_ids = _str_list(finding.get("github_review_thread_ids"))
if isinstance(new_thread_id, str) and new_thread_id and new_thread_id not in thread_ids:
thread_ids.append(new_thread_id)
finding["github_review_thread_ids"] = thread_ids
updated = True
if isinstance(finding.get("id"), str):
surface = _coerce_surface(finding, str(finding["id"]))
if isinstance(comment_id, int):
surface["github_review_comment_id"] = comment_id
if isinstance(new_thread_id, str) and new_thread_id:
surface["github_review_thread_id"] = new_thread_id
if surface.get("state") in {None, "not_surfaced"}:
surface["state"] = "surfaced"
finding["surface"] = surface
updated = True
return updated
def _is_terminal_thread(review_thread: ReviewThread) -> bool:
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
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
status = finding.get("status", "open")
if status in {"open", "needs_reassessment"}:
if status != "needs_reassessment":
finding["status"] = "needs_reassessment"
updated = True
note = (
"GitHub review thread was resolved or outdated by the pull request author; "
"reassess the finding before changing its status."
)
if finding.get("last_reconciliation_note") != note:
finding["last_reconciliation_note"] = note
updated = True
if isinstance(finding.get("id"), str):
surface = _coerce_surface(finding, str(finding["id"]))
if surface.get("state") != "surfaced":
surface["state"] = "surfaced"
updated = True
finding["surface"] = surface
return updated
if not all_resolved:
return updated
if not finding.get("github_thread_resolved"):
finding["github_thread_resolved"] = True
updated = True
if isinstance(finding.get("id"), str):
surface = _coerce_surface(finding, str(finding["id"]))
surface["state"] = "resolved"
finding["surface"] = surface
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):
return False
replies = _human_replies_after_bot_comment(review_thread, bot_comment_id=github_comment_id)
if not replies:
return False
latest = replies[-1]
body = latest.get("body") if isinstance(latest.get("body"), str) else ""
if len(body) > 1000:
body = body[:1000] + "\n...[truncated]"
created_at = latest.get("created_at") if isinstance(latest.get("created_at"), str) else ""
if finding.get("last_human_reply_at") == created_at:
return False
author = latest.get("author") if isinstance(latest.get("author"), str) else ""
finding["last_human_reply_at"] = created_at
finding["last_human_reply_author"] = author
finding["last_human_reply_body"] = body
finding["last_reconciliation_note"] = (
"Human replied to this review thread; reassess before taking action."
)
interactions = finding.get("interactions")
if not isinstance(interactions, list):
interactions = []
reply_id = latest.get("id")
interaction: FindingInteraction = {
"kind": "human_reply",
"github_comment_id": reply_id if isinstance(reply_id, int) else None,
"github_parent_comment_id": github_comment_id,
"author": author,
"body": body,
"created_at": created_at,
"needs_reassessment": True,
}
if not (
isinstance(reply_id, int)
and any(
isinstance(item, dict) and item.get("github_comment_id") == reply_id
for item in interactions
)
):
interactions.append(interaction)
finding["interactions"] = interactions
return True
async def reconcile_findings_with_review_threads(
reviewer_thread_id: str,
review_threads: list[ReviewThread],
) -> list[Finding]:
"""Sync tracked Open SWE findings with the current GitHub review-thread state."""
findings = await list_findings(reviewer_thread_id)
if not findings:
return findings
by_thread_id, by_comment_id, by_marker_id = _index_review_threads(review_threads)
updated = False
for finding in findings:
matches = _find_review_threads_for_finding(
finding,
by_thread_id=by_thread_id,
by_comment_id=by_comment_id,
by_marker_id=by_marker_id,
)
for review_thread, comment_id in matches:
updated = _sync_publication_identity(finding, review_thread, comment_id) or updated
updated = (
_sync_latest_human_reply(finding, review_thread, comment_id=comment_id) or updated
)
updated = _sync_thread_status(finding, matches) or updated
if updated:
await replace_findings(reviewer_thread_id, findings)
return findings