"""GitHub PR feedback — posts pipeline status to GitHub PRs for external contributors. Three touchpoints: 1. Discovery ack: when pipeline discovers a mirrored PR 2. Eval review: when evaluation completes (approved or rejected with reasoning) 3. Merge/close outcome: when PR is merged or permanently closed Only fires for PRs with a github_pr link (set by sync-mirror.sh). All calls are non-fatal — GitHub feedback never blocks the pipeline. """ import logging import os import aiohttp from . import config logger = logging.getLogger("pipeline.github_feedback") GITHUB_API = "https://api.github.com" GITHUB_REPO = "living-ip/teleo-codex" _BOT_ACCOUNTS = frozenset({"m3taversal", "teleo-bot", "teleo", "github-actions[bot]"}) def _github_pat() -> str | None: pat_file = config.SECRETS_DIR / "github-pat" if pat_file.exists(): return pat_file.read_text().strip() return os.environ.get("GITHUB_PAT") async def _post_comment(github_pr: int, body: str) -> bool: pat = _github_pat() if not pat: logger.warning("No GitHub PAT — skipping feedback for GH PR #%d", github_pr) return False url = f"{GITHUB_API}/repos/{GITHUB_REPO}/issues/{github_pr}/comments" headers = { "Authorization": f"Bearer {pat}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } try: async with aiohttp.ClientSession() as session: async with session.post( url, headers=headers, json={"body": body}, timeout=aiohttp.ClientTimeout(total=30), ) as resp: if resp.status >= 400: text = await resp.text() logger.error("GitHub comment on PR #%d failed: %d %s", github_pr, resp.status, text[:200]) return False logger.info("GitHub comment posted on PR #%d", github_pr) return True except Exception: logger.exception("GitHub comment on PR #%d failed", github_pr) return False async def _close_github_pr(github_pr: int) -> bool: pat = _github_pat() if not pat: return False url = f"{GITHUB_API}/repos/{GITHUB_REPO}/pulls/{github_pr}" headers = { "Authorization": f"Bearer {pat}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } try: async with aiohttp.ClientSession() as session: async with session.patch( url, headers=headers, json={"state": "closed"}, timeout=aiohttp.ClientTimeout(total=30), ) as resp: if resp.status >= 400: text = await resp.text() logger.error("GitHub close PR #%d failed: %d %s", github_pr, resp.status, text[:200]) return False logger.info("GitHub PR #%d closed", github_pr) return True except Exception: logger.exception("GitHub close PR #%d failed", github_pr) return False def _get_github_pr(conn, forgejo_pr: int) -> int | None: row = conn.execute( "SELECT github_pr FROM prs WHERE number = ? AND github_pr IS NOT NULL", (forgejo_pr,), ).fetchone() return row["github_pr"] if row else None async def on_discovery(conn, forgejo_pr: int): """Post discovery acknowledgment to GitHub PR.""" gh_pr = _get_github_pr(conn, forgejo_pr) if not gh_pr: return body = ( "Your contribution has been received by the Teleo evaluation pipeline. " "It's queued for automated review (priority: high).\n\n" "You'll receive updates here as it progresses through evaluation.\n\n" "_Automated message from the [LivingIP](https://livingip.xyz) pipeline._" ) await _post_comment(gh_pr, body) async def on_eval_complete(conn, forgejo_pr: int, *, outcome: str, review_text: str = None, issues: list[str] = None): """Post evaluation result to GitHub PR. outcome: 'approved', 'rejected', 'changes_requested' """ gh_pr = _get_github_pr(conn, forgejo_pr) if not gh_pr: return if outcome == "approved": body = "**Evaluation: Approved**\n\nYour contribution passed automated review and is queued for merge." if review_text: safe_text = review_text[:3000].replace("", "</details>") body += f"\n\n
\nReview details\n\n{safe_text}\n\n
" elif outcome == "rejected": body = "**Evaluation: Changes Requested**\n\n" if issues: body += "Issues found:\n" for issue in issues: body += f"- {issue}\n" if review_text: safe_text = review_text[:3000].replace("", "</details>") body += f"\n
\nFull review\n\n{safe_text}\n\n
" body += ( "\n\nThe pipeline will attempt automated fixes where possible. " "If fixes fail, the PR will be closed — you're welcome to resubmit." ) else: body = f"**Evaluation: {outcome}**\n\n" if review_text: body += review_text[:3000] body += "\n\n_Automated message from the [LivingIP](https://livingip.xyz) pipeline._" await _post_comment(gh_pr, body) async def on_merged(conn, forgejo_pr: int, *, claims_count: int = None): """Post merge confirmation and close GitHub PR.""" gh_pr = _get_github_pr(conn, forgejo_pr) if not gh_pr: return body = "**Merged!** Your contribution has been merged into the knowledge base." if claims_count and claims_count > 0: body += f" ({claims_count} claim{'s' if claims_count != 1 else ''} added)" body += ( "\n\nThank you for contributing to LivingIP. " "Your attribution has been recorded.\n\n" "_Automated message from the [LivingIP](https://livingip.xyz) pipeline._" ) await _post_comment(gh_pr, body) await _close_github_pr(gh_pr) async def on_closed(conn, forgejo_pr: int, *, reason: str = None): """Post closure notification and close GitHub PR.""" gh_pr = _get_github_pr(conn, forgejo_pr) if not gh_pr: return body = "**Closed.** " if reason: body += reason else: body += "This PR was closed after evaluation." body += ( "\n\nYou're welcome to resubmit with changes. " "See the evaluation feedback above for guidance.\n\n" "_Automated message from the [LivingIP](https://livingip.xyz) pipeline._" ) await _post_comment(gh_pr, body) await _close_github_pr(gh_pr)