"""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