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

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 . 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)

View file

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