Some checks are pending
CI / lint-and-test (push) Waiting to run
A transient DB lock in breaker.record_failure() inside an except handler killed the asyncio coroutine permanently — snapshot_cycle died Apr 18 and never recovered. All three breaker call sites now have their own try/except. Also includes HTML injection fix for github_feedback review_text. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
187 lines
6.5 KiB
Python
187 lines
6.5 KiB
Python
"""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>", "</details>")
|
|
body += f"\n\n<details>\n<summary>Review details</summary>\n\n{safe_text}\n\n</details>"
|
|
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>", "</details>")
|
|
body += f"\n<details>\n<summary>Full review</summary>\n\n{safe_text}\n\n</details>"
|
|
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)
|