open-swe/agent/dashboard/pr_diff.py

125 lines
4.4 KiB
Python
Raw Permalink Normal View History

"""Shared builder for full-content PR diffs.
Fetches a PR's changed files and their full original/modified contents so the
UI can render syntax-highlighted diffs with pierre's ``MultiFileDiff``. Used by
both the thread PR diff endpoint (user token) and the review diff endpoint
(App installation token).
"""
from __future__ import annotations
import asyncio
from typing import Any
from urllib.parse import quote
import httpx
from fastapi import HTTPException
_GITHUB_API = "https://api.github.com"
PR_DIFF_MAX_FILES = 50
PR_DIFF_MAX_FILE_BYTES = 200_000
PR_DIFF_FETCH_CONCURRENCY = 5
async def _fetch_file_at_ref(
client: httpx.AsyncClient,
semaphore: asyncio.Semaphore,
full_name: str,
path: str,
ref: str,
) -> str | None:
async with semaphore:
response = await client.get(
f"{_GITHUB_API}/repos/{full_name}/contents/{quote(path, safe='/')}",
params={"ref": ref},
headers={"Accept": "application/vnd.github.raw+json"},
)
if response.status_code == 404:
return ""
if response.status_code != 200:
return None
if len(response.content) > PR_DIFF_MAX_FILE_BYTES:
return None
try:
return response.content.decode("utf-8")
except UnicodeDecodeError:
return None
async def build_pr_diff_files(
client: httpx.AsyncClient,
full_name: str,
pr_number: int,
) -> dict[str, Any]:
"""Return ``{base_sha, head_sha, truncated, files}`` for a PR.
Each file carries full ``originalContent``/``modifiedContent`` (or ``None``
for binary/oversized blobs, flagged via ``unrenderable``). ``client`` must
already be configured with auth headers.
"""
pull_response = await client.get(f"{_GITHUB_API}/repos/{full_name}/pulls/{pr_number}")
if pull_response.status_code == 404:
raise HTTPException(404, "pull request not found")
if pull_response.status_code != 200:
raise HTTPException(502, f"github API error ({pull_response.status_code})")
pull = pull_response.json()
base_sha = pull.get("base", {}).get("sha")
head_sha = pull.get("head", {}).get("sha")
if not isinstance(base_sha, str) or not isinstance(head_sha, str):
raise HTTPException(502, "github API returned an unexpected pull request payload")
files_response = await client.get(
f"{_GITHUB_API}/repos/{full_name}/pulls/{pr_number}/files",
params={"per_page": 100},
)
if files_response.status_code != 200:
raise HTTPException(502, f"github API error ({files_response.status_code})")
raw_files = files_response.json()
if not isinstance(raw_files, list):
raise HTTPException(502, "github API returned an unexpected files payload")
truncated = len(raw_files) > PR_DIFF_MAX_FILES
raw_files = raw_files[:PR_DIFF_MAX_FILES]
semaphore = asyncio.Semaphore(PR_DIFF_FETCH_CONCURRENCY)
async def build_entry(raw: dict[str, Any]) -> dict[str, Any] | None:
path = raw.get("filename")
if not isinstance(path, str):
return None
status = raw.get("status") if isinstance(raw.get("status"), str) else "modified"
previous = raw.get("previous_filename")
original_path = previous if isinstance(previous, str) else path
original: str | None = ""
modified: str | None = ""
if status != "added":
original = await _fetch_file_at_ref(
client, semaphore, full_name, original_path, base_sha
)
if status != "removed":
modified = await _fetch_file_at_ref(client, semaphore, full_name, path, head_sha)
return {
"path": path,
"previousPath": previous if isinstance(previous, str) else None,
"status": status,
"additions": raw.get("additions") if isinstance(raw.get("additions"), int) else 0,
"deletions": raw.get("deletions") if isinstance(raw.get("deletions"), int) else 0,
"originalContent": original,
"modifiedContent": modified,
# Binary or oversized blobs come back as None — the client renders a
# placeholder instead of file contents.
"unrenderable": original is None or modified is None,
}
entries = await asyncio.gather(*(build_entry(raw) for raw in raw_files))
return {
"base_sha": base_sha,
"head_sha": head_sha,
"truncated": truncated,
"files": [entry for entry in entries if entry is not None],
}