From 9d69629893784715bae2fac92b72ebeb2e53f4c0 Mon Sep 17 00:00:00 2001 From: m3taversal Date: Fri, 13 Mar 2026 15:29:34 +0000 Subject: [PATCH] =?UTF-8?q?ganymede:=20extract=20lib/forgejo.py=20?= =?UTF-8?q?=E2=80=94=20single=20Forgejo=20API=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - What: Unified forgejo_api(), get_pr_diff(), get_agent_token(), repo_path() into lib/forgejo.py. Removed 3 duplicate _forgejo_api functions (evaluate.py, merge.py, validate.py), 2 duplicate _get_pr_diff functions (evaluate.py, validate.py), and 1 _agent_token function (evaluate.py). - Why: Phase 3 structural refactor. Single source of truth for all Forgejo HTTP calls. Eliminates ~90 lines of duplicated code across 3 modules. - Connections: All hardcoded repo paths now use repo_path() helper. Consumer modules no longer reference config.FORGEJO_URL/OWNER/REPO/TOKEN_FILE directly. Pentagon-Agent: Ganymede --- lib/evaluate.py | 92 +++++++++---------------------------------------- lib/forgejo.py | 83 ++++++++++++++++++++++++++++++++++++++++++++ lib/merge.py | 44 ++++++----------------- lib/validate.py | 66 +++++------------------------------ 4 files changed, 119 insertions(+), 166 deletions(-) create mode 100644 lib/forgejo.py diff --git a/lib/evaluate.py b/lib/evaluate.py index 197befd..7420d72 100644 --- a/lib/evaluate.py +++ b/lib/evaluate.py @@ -22,6 +22,8 @@ import re from datetime import datetime, timezone from . import config, db +from .forgejo import api as forgejo_api +from .forgejo import get_agent_token, get_pr_diff, repo_path logger = logging.getLogger("pipeline.evaluate") @@ -61,14 +63,6 @@ async def kill_active_subprocesses(): _active_subprocesses.clear() -def _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 - - REVIEW_STYLE_GUIDE = ( "Be concise. Only mention what fails or is interesting. " "Do not summarize what the PR does — the diff speaks for itself. " @@ -192,32 +186,6 @@ End your review with exactly one of: # ─── API helpers ─────────────────────────────────────────────────────────── -async def _forgejo_api(method: str, path: str, body: dict = None, token: str = None): - """Call Forgejo API.""" - import aiohttp - - 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 {} - return await resp.json() - except Exception as e: - logger.error("Forgejo API error: %s %s → %s", method, path, e) - return None - - async def _openrouter_call(model: str, prompt: str, timeout_sec: int = 120) -> str | None: """Call OpenRouter API. Returns response text or None on failure.""" import aiohttp @@ -301,31 +269,6 @@ async def _claude_cli_call(model: str, prompt: str, timeout_sec: int = 600, cwd: # ─── Diff helpers ────────────────────────────────────────────────────────── -async def _get_pr_diff(pr_number: int) -> str: - """Fetch PR diff via Forgejo API.""" - import aiohttp - - 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 _filter_diff(diff: str) -> tuple[str, str]: """Filter diff to only review-relevant files. @@ -499,12 +442,11 @@ async def _post_formal_approvals(pr_number: int, pr_author: str): continue if approvals >= 2: break - token_file = config.SECRETS_DIR / f"forgejo-{agent_name}-token" - if token_file.exists(): - token = token_file.read_text().strip() - result = await _forgejo_api( + token = get_agent_token(agent_name) + if token: + result = await forgejo_api( "POST", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/pulls/{pr_number}/reviews", + repo_path(f"pulls/{pr_number}/reviews"), {"body": "Approved.", "event": "APPROVED"}, token=token, ) @@ -528,16 +470,16 @@ async def evaluate_pr(conn, pr_number: int, tier: str = None) -> dict: return {"pr": pr_number, "skipped": True, "reason": "already_claimed"} # Fetch diff - diff = await _get_pr_diff(pr_number) + diff = await get_pr_diff(pr_number) if not diff: return {"pr": pr_number, "skipped": True, "reason": "no_diff"} # Musings bypass if _is_musings_only(diff): logger.info("PR #%d is musings-only — auto-approving", pr_number) - await _forgejo_api( + await forgejo_api( "POST", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/issues/{pr_number}/comments", + repo_path(f"issues/{pr_number}/comments"), {"body": "Auto-approved: musings bypass eval per collective policy."}, ) conn.execute( @@ -605,10 +547,10 @@ async def evaluate_pr(conn, pr_number: int, tier: str = None) -> dict: ) # Post domain review as comment (from agent's Forgejo account) - agent_tok = _agent_token(agent) - await _forgejo_api( + agent_tok = get_agent_token(agent) + await forgejo_api( "POST", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/issues/{pr_number}/comments", + repo_path(f"issues/{pr_number}/comments"), {"body": domain_review}, token=agent_tok, ) @@ -640,10 +582,10 @@ async def evaluate_pr(conn, pr_number: int, tier: str = None) -> dict: conn.execute("UPDATE prs SET leo_verdict = ? WHERE number = ?", (leo_verdict, pr_number)) # Post Leo review as comment (from Leo's Forgejo account) - leo_tok = _agent_token("Leo") - await _forgejo_api( + leo_tok = get_agent_token("Leo") + await forgejo_api( "POST", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/issues/{pr_number}/comments", + repo_path(f"issues/{pr_number}/comments"), {"body": leo_review}, token=leo_tok, ) @@ -656,9 +598,9 @@ async def evaluate_pr(conn, pr_number: int, tier: str = None) -> dict: if both_approve: # Get PR author for formal approvals - pr_info = await _forgejo_api( + pr_info = await forgejo_api( "GET", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/pulls/{pr_number}", + repo_path(f"pulls/{pr_number}"), ) pr_author = pr_info.get("user", {}).get("login", "") if pr_info else "" diff --git a/lib/forgejo.py b/lib/forgejo.py new file mode 100644 index 0000000..7bd024f --- /dev/null +++ b/lib/forgejo.py @@ -0,0 +1,83 @@ +"""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 {} + 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 diff --git a/lib/merge.py b/lib/merge.py index a134e05..49e6180 100644 --- a/lib/merge.py +++ b/lib/merge.py @@ -16,6 +16,8 @@ import logging from collections import defaultdict from . import config, db +from .forgejo import api as forgejo_api +from .forgejo import repo_path logger = logging.getLogger("pipeline.merge") @@ -50,32 +52,6 @@ async def _git(*args, cwd: str = None, timeout: int = 60) -> tuple[int, str]: return proc.returncode, output -async def _forgejo_api(method: str, path: str, body: dict = None) -> dict | list | None: - """Call Forgejo API. Returns parsed JSON or None on error.""" - import aiohttp - - url = f"{config.FORGEJO_URL}/api/v1{path}" - token_file = config.FORGEJO_TOKEN_FILE - token = token_file.read_text().strip() if 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=30) - ) 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: # No content (DELETE) - return {} - return await resp.json() - except Exception as e: - logger.error("Forgejo API error: %s %s → %s", method, path, e) - return None - - # --- PR Discovery (Multiplayer v1) --- @@ -92,9 +68,9 @@ async def discover_external_prs(conn) -> int: page = 1 while True: - prs = await _forgejo_api( + prs = await forgejo_api( "GET", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/pulls?state=open&limit=50&page={page}", + repo_path(f"pulls?state=open&limit=50&page={page}"), ) if not prs: break @@ -185,9 +161,9 @@ async def _post_ack_comment(pr_number: int): "(priority: high). Expected review time: ~5 minutes.\n\n" "_This is an automated message from the Teleo pipeline._" ) - await _forgejo_api( + await forgejo_api( "POST", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/issues/{pr_number}/comments", + repo_path(f"issues/{pr_number}/comments"), {"body": body}, ) @@ -296,9 +272,9 @@ async def _rebase_and_push(branch: str) -> tuple[bool, str]: async def _merge_pr(pr_number: int) -> tuple[bool, str]: """Merge PR via Forgejo API. Preserves PR metadata and reviewer attribution.""" - result = await _forgejo_api( + result = await forgejo_api( "POST", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/pulls/{pr_number}/merge", + repo_path(f"pulls/{pr_number}/merge"), {"Do": "merge", "merge_message_field": ""}, ) if result is None: @@ -312,9 +288,9 @@ async def _delete_remote_branch(branch: str): If DELETE fails, log and move on — stale branch is cosmetic, stale merge is operational. """ - result = await _forgejo_api( + result = await forgejo_api( "DELETE", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/branches/{branch}", + repo_path(f"branches/{branch}"), ) if result is None: logger.warning("Failed to delete remote branch %s — cosmetic, continuing", branch) diff --git a/lib/validate.py b/lib/validate.py index 4a3bbec..988a813 100644 --- a/lib/validate.py +++ b/lib/validate.py @@ -16,6 +16,8 @@ from difflib import SequenceMatcher from pathlib import Path from . import config, db +from .forgejo import api as forgejo_api +from .forgejo import get_pr_diff, repo_path logger = logging.getLogger("pipeline.validate") @@ -356,61 +358,11 @@ def extract_claim_files_from_diff(diff: str) -> dict[str, str]: return files -# ─── Forgejo API (using merge module's helper) ───────────────────────────── - - -async def _forgejo_api(method: str, path: str, body: dict = None): - """Call Forgejo API. Reuses merge module pattern.""" - import aiohttp - - url = f"{config.FORGEJO_URL}/api/v1{path}" - 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=30) - ) 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 {} - 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.""" - import aiohttp - - 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 "" - headers = {"Authorization": f"token {token}", "Accept": "text/plain"} - - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as resp: - if resp.status >= 400: - return "" - diff = await resp.text() - if len(diff) > 2_000_000: - return "" # Too large - return diff - except Exception as e: - logger.error("Failed to fetch diff for PR #%d: %s", pr_number, e) - return "" - - async def _get_pr_head_sha(pr_number: int) -> str: """Get HEAD SHA of PR's branch.""" - pr_info = await _forgejo_api( + pr_info = await forgejo_api( "GET", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/pulls/{pr_number}", + repo_path(f"pulls/{pr_number}"), ) if pr_info: return pr_info.get("head", {}).get("sha", "") @@ -424,9 +376,9 @@ async def _has_tier0_comment(pr_number: int, head_sha: str) -> bool: # Paginate comments (Ganymede standing rule) page = 1 while True: - comments = await _forgejo_api( + comments = await forgejo_api( "GET", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/issues/{pr_number}/comments?limit=50&page={page}", + repo_path(f"issues/{pr_number}/comments?limit=50&page={page}"), ) if not comments: break @@ -469,9 +421,9 @@ async def _post_validation_comment(pr_number: int, results: list[dict], head_sha lines.append(f"\n*tier0-gate v2 | {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}*") - await _forgejo_api( + await forgejo_api( "POST", - f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/issues/{pr_number}/comments", + repo_path(f"issues/{pr_number}/comments"), {"body": "\n".join(lines)}, ) @@ -509,7 +461,7 @@ async def validate_pr(conn, pr_number: int) -> dict: return {"pr": pr_number, "skipped": True, "reason": "already_validated"} # Fetch diff - diff = await _get_pr_diff(pr_number) + diff = await get_pr_diff(pr_number) if not diff: logger.debug("PR #%d: empty or oversized diff", pr_number) return {"pr": pr_number, "skipped": True, "reason": "no_diff"}