"""GitHub Reviews API + GraphQL resolveReviewThread for the reviewer agent. The reviewer agent calls ``publish_review`` at the end of a run. That tool batches eligible findings (severity ≥ threshold, status=open, capped) into a single GitHub PR Review: - Review body: a fixed, host-formatted summary line. The agent never writes prose here — it's either "no issues found" or "found N potential issue(s)". - Inline comments: one per surfaced finding, anchored to ``path`` + ``line`` (+ ``start_line`` for ranges) + ``side``. - Suggestion: when ``finding.suggestion`` is set, appended to the comment body as a fenced ```suggestion``` block — gives the user the "Commit suggestion" button on GitHub. After publish, the returned per-comment IDs get stored back on each Finding as ``github_review_comment_id``. On a re-review run, when a finding moves ``open`` → ``resolved``, ``resolve_review_thread`` is called for that ID via the GraphQL ``resolveReviewThread`` mutation (REST doesn't expose this). """ import json import logging import re from typing import Any, TypedDict import httpx from .reviewer_findings import ( DiffSide, Finding, get_thread_metadata, normalize_finding_title, set_reviewer_thread_metadata, ) from .utils.dashboard_links import dashboard_thread_url from .utils.github_checks import CheckConclusion, complete_review_check_run from .utils.github_http import ( GITHUB_API_BASE, GITHUB_GRAPHQL, github_client, github_request, ) from .utils.github_token import GitHubAuthError logger = logging.getLogger(__name__) _GITHUB_API_BASE = GITHUB_API_BASE _GITHUB_GRAPHQL = GITHUB_GRAPHQL _OPEN_SWE_REVIEW_COMMENT_MARKER_RE = re.compile( r"", 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: """Render the body of one inline review comment. Format: 🟡 **Generated finding title** *(Refers to lines X-Y)* --- *Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.* ```suggestion ``` The suggestion block is only included when ``finding.suggestion`` is set. Multi-line suggestions just become multi-line ```suggestion``` blocks. """ description = (finding.get("description") or "").strip() severity = finding.get("severity") or "medium" marker_payload = { "id": finding.get("id", ""), "file_path": finding.get("file", ""), "start_line": finding.get("start_line"), "end_line": finding.get("end_line"), "side": finding.get("side", "RIGHT"), } marker = f"" title, detail = _split_title_and_detail(description, finding.get("title")) line_ref = _format_line_reference(finding.get("start_line"), finding.get("end_line")) body_parts = [marker, "", f"{_severity_emoji(severity)} **{title}**"] if detail: body_parts.extend(["", detail]) if line_ref: body_parts.extend(["", line_ref]) body_parts.extend( [ "", "---", "*Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.*", ] ) body = "\n".join(body_parts) suggestion = finding.get("suggestion") if suggestion: body = f"{body}\n\n```suggestion\n{suggestion}\n```" return body def _severity_emoji(severity: str) -> str: return { "critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", }.get(severity, "🟡") def _split_title_and_detail(description: str, title: object = None) -> tuple[str, str]: """Split a generated finding title from the review comment detail.""" if isinstance(title, str) and title.strip(): normalized_title = normalize_finding_title(title) detail = description.strip() if detail: lines = description.split("\n") if normalize_finding_title(lines[0]) == normalized_title: detail = "\n".join(lines[1:]).strip() return normalized_title, detail if not description: return normalize_finding_title(None), "" lines = description.split("\n") first_line = lines[0].strip() detail = "\n".join(lines[1:]).strip() normalized_title = normalize_finding_title(first_line) if not detail and normalized_title != " ".join(first_line.split()): return normalized_title, description return normalized_title, detail def _format_line_reference(start_line: int | None, end_line: int | None) -> str: """Format the line reference footer.""" if end_line is None: return "" if start_line is None or start_line == end_line: return f"*(Refers to line {end_line})*" return f"*(Refers to lines {start_line}-{end_line})*" def render_resolution_comment( finding: Finding, status: str, note: str | None = None, ) -> str | None: """Render the agent-provided reply for a review thread.""" return _resolution_body(finding, note) def _resolution_body(finding: Finding, note: str | None) -> str | None: candidates = [note, finding.get("resolution_note"), finding.get("last_update_note")] for candidate in candidates: if isinstance(candidate, str) and candidate.strip(): return candidate.strip() return None def render_inline_comment_payload(finding: Finding) -> dict[str, Any] | None: """Render one finding into the payload shape GitHub's Reviews API expects. Returns ``None`` for file-level findings (no line range), since the Reviews API requires inline comments to be anchored to a line. """ file = finding.get("file") start_line = finding.get("start_line") end_line = finding.get("end_line") side = finding.get("side", "RIGHT") if not file or end_line is None: return None payload: dict[str, Any] = { "path": file, "line": end_line, "side": side, "body": render_inline_comment_body(finding), } if start_line is not None and start_line != end_line: payload["start_line"] = start_line payload["start_side"] = side return payload def review_summary_marker(pr_number: int) -> str: """The hidden marker embedded in every Open SWE review summary body. Used both to stamp the summary (``render_review_body``) and to detect (``open_swe_review_exists``) whether Open SWE has already reviewed a PR. """ return f"" def render_out_of_diff_section(findings: list[Finding]) -> str: """Render findings anchored outside the PR diff as a collapsed dropdown. These can't be posted as inline comments (GitHub rejects off-diff lines), so they live in the review summary body inside a ``
`` block — visible on demand without adding noise to the changed-line review. """ count = len(findings) noun = "finding" if count == 1 else "findings" items: list[str] = [] for f in findings: title, detail = _split_title_and_detail( (f.get("description") or "").strip(), f.get("title") ) location = f.get("file") or "?" line_ref = _format_line_reference(f.get("start_line"), f.get("end_line")) if line_ref: location += f" {line_ref.strip('*()')}".replace("Refers to ", "") item = f"- {_severity_emoji(f.get('severity') or 'medium')} **{title}** — `{location}`" if detail: item += f"\n {detail}" items.append(item) return ( f"
\n🔍 {count} out-of-diff {noun}\n\n" "These relate to code outside this PR's changed lines.\n\n" + "\n".join(items) + "\n
" ) def render_review_body( *, pr_number: int, surfaced_count: int, trace_url: str | None = None, ui_url: str | None = None, out_of_diff_findings: list[Finding] | None = None, additional_findings_count: int = 0, ) -> str: """Compose the top-level review body. When ``surfaced_count`` is 0 but ``additional_findings_count`` is > 0, the headline says "No issues found" and a second line directs the reader to the web app for the remaining sub-threshold findings. """ out_of_diff_findings = out_of_diff_findings or [] has_additional = additional_findings_count > 0 if surfaced_count == 0 and not out_of_diff_findings: headline = ( "## ✅ Open SWE Review: No issues found\n\n" "Open SWE reviewed this PR and found no potential bugs to report." ) elif surfaced_count == 0: headline = "**Open SWE Review** found no issues in the changed lines." else: issue_word = "issue" if surfaced_count == 1 else "issues" headline = f"**Open SWE Review** found {surfaced_count} potential {issue_word}." parts = [headline] if has_additional: noun = "finding" if additional_findings_count == 1 else "findings" parts.append(f"{additional_findings_count} additional {noun} can be viewed in the web app.") if out_of_diff_findings: parts.append(render_out_of_diff_section(out_of_diff_findings)) links = [] if ui_url: links.append(f"[Open in Web]({ui_url})") if trace_url: links.append(f"[View Open SWE trace]({trace_url})") if links: parts.append(" • ".join(links)) parts.append(review_summary_marker(pr_number)) return "\n\n".join(parts) def status_comment_marker(pr_number: int) -> str: """Hidden marker stamped on the live status comment for a PR.""" return f"" def render_status_comment( *, pr_number: int, thread_id: str | None = None, trace_url: str | None = None, ) -> str: """Compose the transient "review in progress" comment. Posted when a review starts so the PR shows activity and a clickable "Open in Web" link while the run is live; deleted once ``publish_review`` posts the review (which carries the same link). """ parts = ["## 🔍 Open SWE Review: in progress\n\nOpen SWE is reviewing this PR…"] links = [] ui_url = dashboard_thread_url(thread_id) if thread_id else None if ui_url: links.append(f"[Open in Web]({ui_url})") if trace_url: links.append(f"[View Open SWE trace]({trace_url})") if links: parts.append(" • ".join(links)) parts.append(status_comment_marker(pr_number)) return "\n\n".join(parts) async def post_status_comment( *, owner: str, repo: str, pr_number: int, body: str, token: str, ) -> int | None: """POST the live status comment to a PR. Returns its comment id or None.""" url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{pr_number}/comments" async with github_client(token=token) as client: try: response = await github_request(client, "POST", url, json={"body": body}) response.raise_for_status() except httpx.HTTPError: logger.exception("Failed to post status comment for %s/%s#%s", owner, repo, pr_number) return None data = response.json() comment_id = data.get("id") if isinstance(data, dict) else None return comment_id if isinstance(comment_id, int) else None async def delete_status_comment( *, owner: str, repo: str, comment_id: int, token: str, ) -> bool: """DELETE a status comment by id. Returns True on success (or if gone).""" url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/issues/comments/{comment_id}" async with github_client(token=token) as client: try: response = await github_request(client, "DELETE", url) if response.status_code == 404: # noqa: PLR2004 return True response.raise_for_status() except httpx.HTTPError: logger.exception("Failed to delete status comment %s on %s/%s", comment_id, owner, repo) return False return True async def post_review_started_comment( *, thread_id: str, owner: str, repo: str, pr_number: int, token: str, trace_url: str | None = None, ) -> int | None: """Post (or refresh) the transient "review in progress" comment. Reuses the ``status_comment_id`` persisted in reviewer thread metadata when one already lingers (a prior run that never settled), otherwise posts a fresh comment and stores its id so ``clear_review_started_comment`` can delete it once the review lands. """ metadata = await get_thread_metadata(thread_id) existing_id = metadata.get("status_comment_id") if isinstance(existing_id, int): await delete_status_comment(owner=owner, repo=repo, comment_id=existing_id, token=token) body = render_status_comment(pr_number=pr_number, thread_id=thread_id, trace_url=trace_url) new_id = await post_status_comment( owner=owner, repo=repo, pr_number=pr_number, body=body, token=token ) await set_reviewer_thread_metadata(thread_id, extra={"status_comment_id": new_id}) return new_id async def clear_review_started_comment( *, thread_id: str, owner: str, repo: str, token: str, ) -> None: """Delete the transient "review in progress" comment, if one is tracked.""" metadata = await get_thread_metadata(thread_id) comment_id = metadata.get("status_comment_id") if not isinstance(comment_id, int): return await delete_status_comment(owner=owner, repo=repo, comment_id=comment_id, token=token) await set_reviewer_thread_metadata(thread_id, extra={"status_comment_id": None}) async def settle_review_check_run( *, thread_id: str, owner: str, repo: str, token: str, conclusion: CheckConclusion, title: str, summary: str, ) -> None: """Complete the tracked ``Open SWE Review`` check run, if one is open. The dispatching webhook stores ``review_check_run_id`` in reviewer thread metadata when it creates the check. No-op when none is tracked. The id is only cleared after a successful PATCH so a transient failure (timeout, 5xx, rate limit) leaves it in place for the after-agent hook or a later publish to retry — otherwise the check would hang in-progress forever. On failure the intended result is persisted as ``review_check_pending_result`` so the retry reports this conclusion, not a generic failure that would misreport a published review. """ metadata = await get_thread_metadata(thread_id) check_run_id = metadata.get("review_check_run_id") if not isinstance(check_run_id, int): return ok = await complete_review_check_run( owner=owner, repo=repo, check_run_id=check_run_id, token=token, conclusion=conclusion, title=title, summary=summary, ) if ok: await set_reviewer_thread_metadata( thread_id, extra={"review_check_run_id": None, "review_check_pending_result": None}, ) else: await set_reviewer_thread_metadata( thread_id, extra={ "review_check_pending_result": { "conclusion": conclusion, "title": title, "summary": summary, } }, ) async def open_swe_review_exists( *, owner: str, repo: str, pr_number: int, token: str, ) -> bool | None: """Return whether Open SWE has already posted a review summary on this PR. Detected via the ``review_summary_marker`` that ``render_review_body`` embeds in every Open SWE review body. The reviewer uses this to avoid posting a duplicate "No issues found" summary when the ``re_review`` config flag is stale — a push that lands mid-run is delivered as a queued message 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. Tri-state on purpose: - ``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) url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews" params: dict[str, Any] = {"per_page": 100, "page": 1} async with github_client(token=token) as client: while True: try: response = await github_request(client, "GET", url, params=params) response.raise_for_status() except httpx.HTTPError: logger.exception( "Failed to list PR reviews for %s/%s#%s", owner, repo, pr_number, ) return None data = response.json() if not isinstance(data, list): return None if not data: return False for review in data: if isinstance(review, dict) and marker in (review.get("body") or ""): return True if len(data) < 100: # noqa: PLR2004 return False params["page"] += 1 async def post_pull_request_review( *, owner: str, repo: str, pr_number: int, head_sha: str, body: str, inline_comments: list[dict[str, Any]], token: str, ) -> dict[str, Any] | None: """POST one GitHub PR Review with inline comments. Returns the API response or None.""" url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews" payload: dict[str, Any] = { "commit_id": head_sha, "event": "COMMENT", "body": body, "comments": inline_comments, } async with github_client(token=token) as client: try: response = await github_request(client, "POST", url, json=payload) if response.status_code == 401: raise GitHubAuthError( f"GitHub returned 401 posting PR review for {owner}/{repo}#{pr_number}" ) response.raise_for_status() except GitHubAuthError: raise except httpx.HTTPStatusError as e: body = (e.response.text or "")[:500] logger.exception( "Failed to POST PR review for %s/%s#%s: %s %s", owner, repo, pr_number, e.response.status_code, body, ) # GitHub returns 422 with errors like "Path could not be resolved" # or "Line could not be resolved" when an inline comment's anchor # is not part of the PR diff. Surface that as a structured signal # so the tool layer can prune the offending findings and retry # once, instead of the agent retrying with byte-identical args. error_kind: str | None = None raw_errors: list[Any] = [] if e.response.status_code == 422: try: parsed = e.response.json() if isinstance(parsed, dict): candidate = parsed.get("errors", []) if isinstance(candidate, list): raw_errors = candidate except Exception: # noqa: BLE001 — body may not be JSON raw_errors = [] if any( isinstance(err, str) and ("Path could not be resolved" in err or "Line could not be resolved" in err) for err in raw_errors ): error_kind = "unresolved_anchor" return { "_error": f"HTTP {e.response.status_code}: {body}", "_error_kind": error_kind, "_raw_errors": raw_errors, "_status": e.response.status_code, } except httpx.HTTPError as e: logger.exception("Failed to POST PR review for %s/%s#%s", owner, repo, pr_number) return {"_error": f"{type(e).__name__}: {e}"} data = response.json() if isinstance(data, dict): return data body_excerpt = (response.text or "")[:500] logger.error( "POST PR review for %s/%s#%s returned non-dict body: %s", owner, repo, pr_number, body_excerpt, ) return {"_error": (f"HTTP {response.status_code}: non-dict response body: {body_excerpt}")} async def fetch_review_comments( *, owner: str, repo: str, pr_number: int, review_id: int, token: str, ) -> list[dict[str, Any]]: """List the inline comments for a posted review. GitHub's review-creation response includes a ``comments`` count but not the per-comment IDs in all paths; this paginates the canonical list endpoint. """ url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/comments" out: list[dict[str, Any]] = [] params: dict[str, Any] = {"per_page": 100, "page": 1} async with github_client(token=token) as client: while True: try: response = await github_request(client, "GET", url, params=params) response.raise_for_status() except httpx.HTTPError: logger.exception( "Failed to list review comments for review %s on %s/%s", review_id, owner, repo, ) break data = response.json() if not isinstance(data, list) or not data: break out.extend(item for item in data if isinstance(item, dict)) if len(data) < 100: # noqa: PLR2004 break params["page"] += 1 return out async def fetch_pr_review_threads( *, owner: str, repo: str, pr_number: int, token: str, max_threads: int = 100, max_comments_per_thread: int = 20, ) -> list[dict[str, Any]]: """Fetch all inline review threads on a PR (across reviewers, with replies). Returned shape per thread: { "path": str, "line": int | None, "original_line": int | None, "is_resolved": bool, "is_outdated": bool, "comments": [{"author": str, "body": str, "created_at": str}, ...], } Used to give the reviewer agent comment-awareness: it should not re-file a finding that already appears as an open thread (its own or another reviewer's), and should treat a thread as addressed when a human reply explains the code or the thread is resolved. """ query = """ query Threads($owner: String!, $repo: String!, $pr: Int!, $cursor: String, $perThread: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 50, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { id isResolved isOutdated path line originalLine comments(first: $perThread) { nodes { databaseId author { login } authorAssociation body createdAt } } } } } } } """ out: list[dict[str, Any]] = [] cursor: str | None = None async with github_client(token=token) as client: while len(out) < max_threads: try: response = await github_request( client, "POST", _GITHUB_GRAPHQL, json={ "query": query, "variables": { "owner": owner, "repo": repo, "pr": pr_number, "cursor": cursor, "perThread": max_comments_per_thread, }, }, ) response.raise_for_status() except httpx.HTTPError: logger.exception( "Failed to fetch PR review threads for %s/%s#%s", owner, repo, pr_number, ) return out data = response.json() data_root = data.get("data") if isinstance(data, dict) else None repository = data_root.get("repository") if isinstance(data_root, dict) else None if not isinstance(repository, dict): logger.warning( "Null repository in review-threads response for %s/%s#%s " "(token likely lacks access: SAML, expired token, or private/deleted repo)", owner, repo, pr_number, ) return out pull_request = repository.get("pullRequest") threads = pull_request.get("reviewThreads") if isinstance(pull_request, dict) else None if not isinstance(threads, dict): return out for thread in threads.get("nodes", []) or []: if not isinstance(thread, dict): continue comments_block = thread.get("comments") or {} comments_nodes = comments_block.get("nodes") or [] comments: list[dict[str, Any]] = [] for c in comments_nodes: if not isinstance(c, dict): continue author_block = c.get("author") or {} login = author_block.get("login") if isinstance(author_block, dict) else None comments.append( { "id": c.get("databaseId") if isinstance(c.get("databaseId"), int) else None, "author": login if isinstance(login, str) else "unknown", "author_association": c.get("authorAssociation", "") if isinstance(c.get("authorAssociation"), str) else "", "body": c.get("body", "") if isinstance(c.get("body"), str) else "", "created_at": c.get("createdAt", "") if isinstance(c.get("createdAt"), str) else "", } ) out.append( { "id": thread.get("id") if isinstance(thread.get("id"), str) else "", "path": thread.get("path", "") if isinstance(thread.get("path"), str) else "", "line": thread.get("line") if isinstance(thread.get("line"), int) else None, "original_line": thread.get("originalLine") if isinstance(thread.get("originalLine"), int) else None, "is_resolved": bool(thread.get("isResolved")), "is_outdated": bool(thread.get("isOutdated")), "comments": comments, } ) if len(out) >= max_threads: break page_info = threads.get("pageInfo") or {} if not page_info.get("hasNextPage"): break cursor = page_info.get("endCursor") return out async def fetch_review_thread_id_for_comment( *, owner: str, repo: str, pr_number: int, review_comment_id: int, token: str, ) -> str | None: """Resolve the GraphQL review-thread node id for a REST review-comment id. GitHub's GraphQL API resolves "threads" rather than individual comments; to resolve a thread we need its node id. The REST review-comment id is mapped to the thread by walking the PR's review threads. """ query = """ query Threads($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 50, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { id comments(first: 50) { nodes { databaseId } } } } } } } """ cursor: str | None = None async with github_client(token=token) as client: while True: try: response = await github_request( client, "POST", _GITHUB_GRAPHQL, json={ "query": query, "variables": { "owner": owner, "repo": repo, "pr": pr_number, "cursor": cursor, }, }, ) response.raise_for_status() except httpx.HTTPError: logger.exception( "Failed to fetch review threads for %s/%s#%s", owner, repo, pr_number, ) return None data = response.json() if not isinstance(data, dict): return None data_root = data.get("data") if not isinstance(data_root, dict): return None repository = data_root.get("repository") if not isinstance(repository, dict): return None pull_request = repository.get("pullRequest") if not isinstance(pull_request, dict): return None threads = pull_request.get("reviewThreads") if not isinstance(threads, dict): return None for thread in threads.get("nodes", []) or []: comment_ids = { c.get("databaseId") for c in (thread.get("comments", {}).get("nodes") or []) } if review_comment_id in comment_ids: node_id = thread.get("id") return node_id if isinstance(node_id, str) else None page_info = threads.get("pageInfo") or {} if not page_info.get("hasNextPage"): return None cursor = page_info.get("endCursor") async def resolve_review_thread(*, thread_node_id: str, token: str) -> bool: """Mark a review thread as resolved via the GraphQL ``resolveReviewThread`` mutation.""" mutation = """ mutation Resolve($threadId: ID!) { resolveReviewThread(input: {threadId: $threadId}) { thread { id isResolved } } } """ async with github_client(token=token) as client: try: response = await github_request( client, "POST", _GITHUB_GRAPHQL, json={"query": mutation, "variables": {"threadId": thread_node_id}}, ) response.raise_for_status() except httpx.HTTPError: logger.exception("Failed to resolve review thread %s", thread_node_id) return False data = response.json() if data.get("errors"): logger.warning("resolveReviewThread errors: %s", data["errors"]) return False data_root = data.get("data") if isinstance(data, dict) else None resolved = data_root.get("resolveReviewThread") if isinstance(data_root, dict) else None thread = resolved.get("thread") if isinstance(resolved, dict) else None return bool(thread.get("isResolved")) if isinstance(thread, dict) else False async def reply_to_review_comment( *, owner: str, repo: str, pr_number: int, review_comment_id: int, body: str, token: str, ) -> dict[str, Any] | None: """Reply to an existing pull request review comment thread.""" url = ( f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/" f"{pr_number}/comments/{review_comment_id}/replies" ) async with github_client(token=token) as client: try: response = await github_request(client, "POST", url, json={"body": body}) if response.status_code == 401: raise GitHubAuthError( f"GitHub returned 401 replying to review comment {review_comment_id}" ) response.raise_for_status() except GitHubAuthError: raise except httpx.HTTPError: logger.exception( "Failed to reply to review comment %s on %s/%s#%s", review_comment_id, owner, repo, pr_number, ) return None data = response.json() return data if isinstance(data, dict) else None