Forgejo returns 200 with HTML content-type on successful merge instead of JSON. Our API helper threw on resp.json(), causing merge to report failure even though the PR merged. Now treats non-JSON 200 as success. This was causing PRs #732 and #789 to show as conflict in our DB while actually merged on Forgejo, and tripping the merge circuit breaker. Pentagon-Agent: Leo <294C3CA1-0205-4668-82FA-B984D54F48AD>
89 lines
3.5 KiB
Python
89 lines
3.5 KiB
Python
"""Forgejo API client — single shared module for all pipeline stages.
|
|
|
|
Extracted from evaluate.py, merge.py, validate.py (Phase 3 refactor).
|
|
All Forgejo HTTP calls go through this module.
|
|
"""
|
|
|
|
import logging
|
|
|
|
import aiohttp
|
|
|
|
from . import config
|
|
|
|
logger = logging.getLogger("pipeline.forgejo")
|
|
|
|
|
|
async def api(method: str, path: str, body: dict = None, token: str = None):
|
|
"""Call Forgejo API. Returns parsed JSON, {} for 204, or None on error.
|
|
|
|
Args:
|
|
method: HTTP method (GET, POST, DELETE, etc.)
|
|
path: API path after /api/v1 (e.g. "/repos/teleo/teleo-codex/pulls")
|
|
body: JSON body for POST/PUT/PATCH
|
|
token: Override token. If None, reads from FORGEJO_TOKEN_FILE (admin token).
|
|
"""
|
|
url = f"{config.FORGEJO_URL}/api/v1{path}"
|
|
if token is None:
|
|
token = config.FORGEJO_TOKEN_FILE.read_text().strip() if config.FORGEJO_TOKEN_FILE.exists() else ""
|
|
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.request(
|
|
method, url, headers=headers, json=body, timeout=aiohttp.ClientTimeout(total=60)
|
|
) as resp:
|
|
if resp.status >= 400:
|
|
text = await resp.text()
|
|
logger.error("Forgejo API %s %s → %d: %s", method, path, resp.status, text[:200])
|
|
return None
|
|
if resp.status == 204:
|
|
return {}
|
|
# Forgejo sometimes returns 200 with HTML (not JSON) on merge success.
|
|
# Treat 200 with non-JSON content-type as success rather than error.
|
|
content_type = resp.content_type or ""
|
|
if "json" not in content_type:
|
|
logger.debug("Forgejo API %s %s → %d (non-JSON: %s), treating as success", method, path, resp.status, content_type)
|
|
return {}
|
|
return await resp.json()
|
|
except Exception as e:
|
|
logger.error("Forgejo API error: %s %s → %s", method, path, e)
|
|
return None
|
|
|
|
|
|
async def get_pr_diff(pr_number: int) -> str:
|
|
"""Fetch PR diff via Forgejo API. Returns diff text or empty string."""
|
|
url = f"{config.FORGEJO_URL}/api/v1/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/pulls/{pr_number}.diff"
|
|
token = config.FORGEJO_TOKEN_FILE.read_text().strip() if config.FORGEJO_TOKEN_FILE.exists() else ""
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(
|
|
url,
|
|
headers={"Authorization": f"token {token}", "Accept": "text/plain"},
|
|
timeout=aiohttp.ClientTimeout(total=60),
|
|
) as resp:
|
|
if resp.status >= 400:
|
|
return ""
|
|
diff = await resp.text()
|
|
if len(diff) > 2_000_000:
|
|
return ""
|
|
return diff
|
|
except Exception as e:
|
|
logger.error("Failed to fetch diff for PR #%d: %s", pr_number, e)
|
|
return ""
|
|
|
|
|
|
def get_agent_token(agent_name: str) -> str | None:
|
|
"""Read Forgejo token for a named agent. Returns token string or None."""
|
|
token_file = config.SECRETS_DIR / f"forgejo-{agent_name.lower()}-token"
|
|
if token_file.exists():
|
|
return token_file.read_text().strip()
|
|
return None
|
|
|
|
|
|
def repo_path(subpath: str = "") -> str:
|
|
"""Build standard repo API path: /repos/{owner}/{repo}/{subpath}."""
|
|
base = f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}"
|
|
if subpath:
|
|
return f"{base}/{subpath}"
|
|
return base
|