ganymede: extract lib/forgejo.py — single Forgejo API client
Some checks failed
CI / lint-and-test (pull_request) Has been cancelled
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:
parent
927b5011b4
commit
9d69629893
4 changed files with 119 additions and 166 deletions
|
|
@ -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
83
lib/forgejo.py
Normal 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
|
||||
44
lib/merge.py
44
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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Reference in a new issue