Spaces:
Sleeping
Sleeping
Deploy OpenClaw PR API
Browse files
src/slop_farmer/app/pr_search_api.py
CHANGED
|
@@ -10,7 +10,7 @@ from fastapi import FastAPI, HTTPException, Request
|
|
| 10 |
from fastapi.responses import JSONResponse
|
| 11 |
|
| 12 |
from slop_farmer.config import PrSearchRefreshOptions
|
| 13 |
-
from slop_farmer.data.ghreplica_api import GhrProbeClient
|
| 14 |
from slop_farmer.reports.pr_search_service import (
|
| 15 |
get_pr_search_candidate_clusters,
|
| 16 |
get_pr_search_cluster,
|
|
@@ -98,6 +98,12 @@ def create_app(settings: PrSearchApiSettings | None = None) -> FastAPI:
|
|
| 98 |
status_code = 404 if _looks_not_found(exc) else 400
|
| 99 |
return JSONResponse({"detail": str(exc)}, status_code=status_code)
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
@app.get("/healthz")
|
| 102 |
async def healthz() -> dict[str, bool]:
|
| 103 |
return {"ok": True}
|
|
|
|
| 10 |
from fastapi.responses import JSONResponse
|
| 11 |
|
| 12 |
from slop_farmer.config import PrSearchRefreshOptions
|
| 13 |
+
from slop_farmer.data.ghreplica_api import GhReplicaProbeUnavailableError, GhrProbeClient
|
| 14 |
from slop_farmer.reports.pr_search_service import (
|
| 15 |
get_pr_search_candidate_clusters,
|
| 16 |
get_pr_search_cluster,
|
|
|
|
| 98 |
status_code = 404 if _looks_not_found(exc) else 400
|
| 99 |
return JSONResponse({"detail": str(exc)}, status_code=status_code)
|
| 100 |
|
| 101 |
+
@app.exception_handler(GhReplicaProbeUnavailableError)
|
| 102 |
+
async def handle_probe_unavailable(
|
| 103 |
+
_request: Request, exc: GhReplicaProbeUnavailableError
|
| 104 |
+
) -> JSONResponse:
|
| 105 |
+
return JSONResponse({"detail": str(exc)}, status_code=exc.status_code)
|
| 106 |
+
|
| 107 |
@app.get("/healthz")
|
| 108 |
async def healthz() -> dict[str, bool]:
|
| 109 |
return {"ok": True}
|
src/slop_farmer/data/ghreplica_api.py
CHANGED
|
@@ -19,6 +19,14 @@ class GhReplicaApiRequestError(RuntimeError):
|
|
| 19 |
super().__init__(f"ghreplica API request failed: {status_code} {path} {detail}")
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
class GhrProbeClient:
|
| 23 |
provider = "ghreplica"
|
| 24 |
|
|
@@ -52,14 +60,54 @@ class GhrProbeClient:
|
|
| 52 |
raise GhReplicaApiRequestError(exc.code, path, detail) from exc
|
| 53 |
return json.loads(payload)
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def get_pull_request(self, owner: str, repo: str, number: int) -> dict[str, Any]:
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
if not isinstance(payload, dict):
|
| 58 |
raise RuntimeError(f"Expected dict payload for pull request, got {type(payload)!r}")
|
| 59 |
return payload
|
| 60 |
|
| 61 |
def iter_pull_files(self, owner: str, repo: str, number: int) -> Iterable[dict[str, Any]]:
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
rows = payload if isinstance(payload, list) else payload.get("files")
|
| 64 |
if not isinstance(rows, list):
|
| 65 |
raise RuntimeError(
|
|
@@ -85,7 +133,9 @@ class GhrProbeClient:
|
|
| 85 |
}
|
| 86 |
|
| 87 |
def get_pull_request_status(self, owner: str, repo: str, number: int) -> dict[str, Any] | None:
|
| 88 |
-
payload = self.
|
|
|
|
|
|
|
| 89 |
if payload is None:
|
| 90 |
return None
|
| 91 |
if not isinstance(payload, dict):
|
|
|
|
| 19 |
super().__init__(f"ghreplica API request failed: {status_code} {path} {detail}")
|
| 20 |
|
| 21 |
|
| 22 |
+
class GhReplicaProbeUnavailableError(RuntimeError):
|
| 23 |
+
"""Raised when ghreplica cannot yet serve a live probe payload."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, detail: str, *, status_code: int = 503):
|
| 26 |
+
self.status_code = status_code
|
| 27 |
+
super().__init__(detail)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
class GhrProbeClient:
|
| 31 |
provider = "ghreplica"
|
| 32 |
|
|
|
|
| 60 |
raise GhReplicaApiRequestError(exc.code, path, detail) from exc
|
| 61 |
return json.loads(payload)
|
| 62 |
|
| 63 |
+
def _request_json_or_none(self, path: str) -> Any | None:
|
| 64 |
+
try:
|
| 65 |
+
return self._request_json(path)
|
| 66 |
+
except GhReplicaApiRequestError as exc:
|
| 67 |
+
if exc.status_code == 404:
|
| 68 |
+
return None
|
| 69 |
+
raise
|
| 70 |
+
|
| 71 |
def get_pull_request(self, owner: str, repo: str, number: int) -> dict[str, Any]:
|
| 72 |
+
try:
|
| 73 |
+
payload = self._request_json(f"/v1/github/repos/{owner}/{repo}/pulls/{number}")
|
| 74 |
+
except GhReplicaApiRequestError as exc:
|
| 75 |
+
if exc.status_code == 404:
|
| 76 |
+
raise GhReplicaProbeUnavailableError(
|
| 77 |
+
f"PR #{number} was not found in ghreplica.",
|
| 78 |
+
status_code=404,
|
| 79 |
+
) from exc
|
| 80 |
+
raise
|
| 81 |
if not isinstance(payload, dict):
|
| 82 |
raise RuntimeError(f"Expected dict payload for pull request, got {type(payload)!r}")
|
| 83 |
return payload
|
| 84 |
|
| 85 |
def iter_pull_files(self, owner: str, repo: str, number: int) -> Iterable[dict[str, Any]]:
|
| 86 |
+
try:
|
| 87 |
+
payload = self._request_json(f"/v1/changes/repos/{owner}/{repo}/pulls/{number}/files")
|
| 88 |
+
except GhReplicaApiRequestError as exc:
|
| 89 |
+
if exc.status_code != 404:
|
| 90 |
+
raise
|
| 91 |
+
status = self.get_pull_request_status(owner, repo, number)
|
| 92 |
+
if isinstance(status, dict):
|
| 93 |
+
detail_bits = []
|
| 94 |
+
for key in (
|
| 95 |
+
"indexed",
|
| 96 |
+
"backfill_in_progress",
|
| 97 |
+
"changed_files",
|
| 98 |
+
"indexed_file_count",
|
| 99 |
+
):
|
| 100 |
+
if key in status:
|
| 101 |
+
detail_bits.append(f"{key}={status[key]}")
|
| 102 |
+
suffix = f" ({', '.join(detail_bits)})" if detail_bits else ""
|
| 103 |
+
raise GhReplicaProbeUnavailableError(
|
| 104 |
+
f"PR #{number} is not available in ghreplica yet{suffix}.",
|
| 105 |
+
status_code=503,
|
| 106 |
+
) from exc
|
| 107 |
+
raise GhReplicaProbeUnavailableError(
|
| 108 |
+
f"PR #{number} was not found in ghreplica changed-file replica.",
|
| 109 |
+
status_code=404,
|
| 110 |
+
) from exc
|
| 111 |
rows = payload if isinstance(payload, list) else payload.get("files")
|
| 112 |
if not isinstance(rows, list):
|
| 113 |
raise RuntimeError(
|
|
|
|
| 133 |
}
|
| 134 |
|
| 135 |
def get_pull_request_status(self, owner: str, repo: str, number: int) -> dict[str, Any] | None:
|
| 136 |
+
payload = self._request_json_or_none(
|
| 137 |
+
f"/v1/changes/repos/{owner}/{repo}/pulls/{number}/status"
|
| 138 |
+
)
|
| 139 |
if payload is None:
|
| 140 |
return None
|
| 141 |
if not isinstance(payload, dict):
|