ganymede: extract lib/forgejo.py — single Forgejo API client
Some checks failed
CI / lint-and-test (pull_request) Has been cancelled

- 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 <F99EBFA6-547B-4096-BEEA-1D59C3E4028A>
This commit is contained in:
m3taversal 2026-03-13 15:29:34 +00:00
parent 927b5011b4
commit 9d69629893
4 changed files with 119 additions and 166 deletions

View file

@ -22,6 +22,8 @@ import re
from datetime import datetime, timezone from datetime import datetime, timezone
from . import config, db 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") logger = logging.getLogger("pipeline.evaluate")
@ -61,14 +63,6 @@ async def kill_active_subprocesses():
_active_subprocesses.clear() _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 = ( REVIEW_STYLE_GUIDE = (
"Be concise. Only mention what fails or is interesting. " "Be concise. Only mention what fails or is interesting. "
"Do not summarize what the PR does — the diff speaks for itself. " "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 ─────────────────────────────────────────────────────────── # ─── 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: async def _openrouter_call(model: str, prompt: str, timeout_sec: int = 120) -> str | None:
"""Call OpenRouter API. Returns response text or None on failure.""" """Call OpenRouter API. Returns response text or None on failure."""
import aiohttp import aiohttp
@ -301,31 +269,6 @@ async def _claude_cli_call(model: str, prompt: str, timeout_sec: int = 600, cwd:
# ─── Diff helpers ────────────────────────────────────────────────────────── # ─── 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]: def _filter_diff(diff: str) -> tuple[str, str]:
"""Filter diff to only review-relevant files. """Filter diff to only review-relevant files.
@ -499,12 +442,11 @@ async def _post_formal_approvals(pr_number: int, pr_author: str):
continue continue
if approvals >= 2: if approvals >= 2:
break break
token_file = config.SECRETS_DIR / f"forgejo-{agent_name}-token" token = get_agent_token(agent_name)
if token_file.exists(): if token:
token = token_file.read_text().strip() result = await forgejo_api(
result = await _forgejo_api(
"POST", "POST",
f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/pulls/{pr_number}/reviews", repo_path(f"pulls/{pr_number}/reviews"),
{"body": "Approved.", "event": "APPROVED"}, {"body": "Approved.", "event": "APPROVED"},
token=token, 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"} return {"pr": pr_number, "skipped": True, "reason": "already_claimed"}
# Fetch diff # Fetch diff
diff = await _get_pr_diff(pr_number) diff = await get_pr_diff(pr_number)
if not diff: if not diff:
return {"pr": pr_number, "skipped": True, "reason": "no_diff"} return {"pr": pr_number, "skipped": True, "reason": "no_diff"}
# Musings bypass # Musings bypass
if _is_musings_only(diff): if _is_musings_only(diff):
logger.info("PR #%d is musings-only — auto-approving", pr_number) logger.info("PR #%d is musings-only — auto-approving", pr_number)
await _forgejo_api( await forgejo_api(
"POST", "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."}, {"body": "Auto-approved: musings bypass eval per collective policy."},
) )
conn.execute( 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) # Post domain review as comment (from agent's Forgejo account)
agent_tok = _agent_token(agent) agent_tok = get_agent_token(agent)
await _forgejo_api( await forgejo_api(
"POST", "POST",
f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/issues/{pr_number}/comments", repo_path(f"issues/{pr_number}/comments"),
{"body": domain_review}, {"body": domain_review},
token=agent_tok, 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)) conn.execute("UPDATE prs SET leo_verdict = ? WHERE number = ?", (leo_verdict, pr_number))
# Post Leo review as comment (from Leo's Forgejo account) # Post Leo review as comment (from Leo's Forgejo account)
leo_tok = _agent_token("Leo") leo_tok = get_agent_token("Leo")
await _forgejo_api( await forgejo_api(
"POST", "POST",
f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/issues/{pr_number}/comments", repo_path(f"issues/{pr_number}/comments"),
{"body": leo_review}, {"body": leo_review},
token=leo_tok, token=leo_tok,
) )
@ -656,9 +598,9 @@ async def evaluate_pr(conn, pr_number: int, tier: str = None) -> dict:
if both_approve: if both_approve:
# Get PR author for formal approvals # Get PR author for formal approvals
pr_info = await _forgejo_api( pr_info = await forgejo_api(
"GET", "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 "" pr_author = pr_info.get("user", {}).get("login", "") if pr_info else ""

83
lib/forgejo.py Normal file
View file

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

View file

@ -16,6 +16,8 @@ import logging
from collections import defaultdict from collections import defaultdict
from . import config, db from . import config, db
from .forgejo import api as forgejo_api
from .forgejo import repo_path
logger = logging.getLogger("pipeline.merge") 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 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) --- # --- PR Discovery (Multiplayer v1) ---
@ -92,9 +68,9 @@ async def discover_external_prs(conn) -> int:
page = 1 page = 1
while True: while True:
prs = await _forgejo_api( prs = await forgejo_api(
"GET", "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: if not prs:
break break
@ -185,9 +161,9 @@ async def _post_ack_comment(pr_number: int):
"(priority: high). Expected review time: ~5 minutes.\n\n" "(priority: high). Expected review time: ~5 minutes.\n\n"
"_This is an automated message from the Teleo pipeline._" "_This is an automated message from the Teleo pipeline._"
) )
await _forgejo_api( await forgejo_api(
"POST", "POST",
f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/issues/{pr_number}/comments", repo_path(f"issues/{pr_number}/comments"),
{"body": body}, {"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]: async def _merge_pr(pr_number: int) -> tuple[bool, str]:
"""Merge PR via Forgejo API. Preserves PR metadata and reviewer attribution.""" """Merge PR via Forgejo API. Preserves PR metadata and reviewer attribution."""
result = await _forgejo_api( result = await forgejo_api(
"POST", "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": ""}, {"Do": "merge", "merge_message_field": ""},
) )
if result is None: 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, If DELETE fails, log and move on stale branch is cosmetic,
stale merge is operational. stale merge is operational.
""" """
result = await _forgejo_api( result = await forgejo_api(
"DELETE", "DELETE",
f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/branches/{branch}", repo_path(f"branches/{branch}"),
) )
if result is None: if result is None:
logger.warning("Failed to delete remote branch %s — cosmetic, continuing", branch) logger.warning("Failed to delete remote branch %s — cosmetic, continuing", branch)

View file

@ -16,6 +16,8 @@ from difflib import SequenceMatcher
from pathlib import Path from pathlib import Path
from . import config, db from . import config, db
from .forgejo import api as forgejo_api
from .forgejo import get_pr_diff, repo_path
logger = logging.getLogger("pipeline.validate") logger = logging.getLogger("pipeline.validate")
@ -356,61 +358,11 @@ def extract_claim_files_from_diff(diff: str) -> dict[str, str]:
return files 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: async def _get_pr_head_sha(pr_number: int) -> str:
"""Get HEAD SHA of PR's branch.""" """Get HEAD SHA of PR's branch."""
pr_info = await _forgejo_api( pr_info = await forgejo_api(
"GET", "GET",
f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/pulls/{pr_number}", repo_path(f"pulls/{pr_number}"),
) )
if pr_info: if pr_info:
return pr_info.get("head", {}).get("sha", "") 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) # Paginate comments (Ganymede standing rule)
page = 1 page = 1
while True: while True:
comments = await _forgejo_api( comments = await forgejo_api(
"GET", "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: if not comments:
break 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')}*") 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", "POST",
f"/repos/{config.FORGEJO_OWNER}/{config.FORGEJO_REPO}/issues/{pr_number}/comments", repo_path(f"issues/{pr_number}/comments"),
{"body": "\n".join(lines)}, {"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"} return {"pr": pr_number, "skipped": True, "reason": "already_validated"}
# Fetch diff # Fetch diff
diff = await _get_pr_diff(pr_number) diff = await get_pr_diff(pr_number)
if not diff: if not diff:
logger.debug("PR #%d: empty or oversized diff", pr_number) logger.debug("PR #%d: empty or oversized diff", pr_number)
return {"pr": pr_number, "skipped": True, "reason": "no_diff"} return {"pr": pr_number, "skipped": True, "reason": "no_diff"}