teleo-codex/ops/pipeline-v2/lib/forgejo.py
m3taversal 05d74d5e32 sync: import all VPS pipeline + diagnostics code as baseline
Imports 67 files from VPS (/opt/teleo-eval/) into repo as the single source
of truth. Previously only 8 of 67 files existed in repo — the rest were
deployed directly to VPS via SCP, causing massive drift.

Includes:
- pipeline/lib/: 33 Python modules (daemon core, extraction, evaluation, merge, cascade, cross-domain, costs, attribution, etc.)
- pipeline/: main daemon (teleo-pipeline.py), reweave.py, batch-extract-50.sh
- diagnostics/: 19 files (4-page dashboard, alerting, daily digest, review queue, tier1 metrics)
- agent-state/: bootstrap, lib-state, cascade inbox processor, schema
- systemd/: service unit files for reference
- deploy.sh: rsync-based deploy with --dry-run, syntax checks, dirty-tree gate
- research-session.sh: updated with Step 8.5 digest + cascade inbox processing

No new code written — all files are exact copies from VPS as of 2026-04-06.
From this point forward: edit in repo, commit, then deploy.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:00:00 +01:00

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