"""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:
body += f"\n\n\nReview details
\n\n{review_text[:3000]}\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:
body += f"\n\nFull review
\n\n{review_text[:3000]}\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)