"""PR disposition actions — async Forgejo + DB operations for end-of-eval decisions. Extracted from evaluate.py to isolate the "do something to this PR" functions from orchestration logic. Contains: - post_formal_approvals: submit Forgejo reviews from 2 agents (not PR author) - terminate_pr: close PR, post rejection comment, requeue source - dispose_rejected_pr: disposition logic for rejected PRs on attempt 2+ All functions are async (Forgejo API calls). Dependencies: forgejo, db, config, pr_state, feedback, eval_parse. """ import json import logging from . import config, db from .eval_parse import classify_issues from .feedback import format_rejection_comment from .forgejo import api as forgejo_api, get_agent_token, repo_path from .pr_state import close_pr logger = logging.getLogger("pipeline.eval_actions") async def post_formal_approvals(pr_number: int, pr_author: str): """Submit formal Forgejo reviews from 2 agents (not the PR author).""" approvals = 0 for agent_name in ["leo", "vida", "theseus", "clay", "astra", "rio"]: if agent_name == pr_author: continue if approvals >= 2: break token = get_agent_token(agent_name) if token: result = await forgejo_api( "POST", repo_path(f"pulls/{pr_number}/reviews"), {"body": "Approved.", "event": "APPROVED"}, token=token, ) if result is not None: approvals += 1 logger.debug("Formal approval for PR #%d by %s (%d/2)", pr_number, agent_name, approvals) async def terminate_pr(conn, pr_number: int, reason: str): """Terminal state: close PR on Forgejo, mark source needs_human.""" # Get issue tags for structured feedback row = conn.execute("SELECT eval_issues, agent FROM prs WHERE number = ?", (pr_number,)).fetchone() issues = [] if row and row["eval_issues"]: try: issues = json.loads(row["eval_issues"]) except (json.JSONDecodeError, TypeError): pass # Post structured rejection comment with quality gate guidance if issues: feedback_body = format_rejection_comment(issues, source="eval_terminal") comment_body = ( f"**Closed by eval pipeline** — {reason}.\n\n" f"Evaluated {config.MAX_EVAL_ATTEMPTS} times without passing. " f"Source will be re-queued with feedback.\n\n" f"{feedback_body}" ) else: comment_body = ( f"**Closed by eval pipeline** — {reason}.\n\n" f"Evaluated {config.MAX_EVAL_ATTEMPTS} times without passing. " f"Source will be re-queued with feedback." ) await forgejo_api( "POST", repo_path(f"issues/{pr_number}/comments"), {"body": comment_body}, ) closed = await close_pr(conn, pr_number, last_error=reason) if not closed: logger.warning("PR #%d: Forgejo close failed — skipping source requeue, will retry next cycle", pr_number) return # Tag source for re-extraction with feedback cursor = conn.execute( """UPDATE sources SET status = 'needs_reextraction', updated_at = datetime('now') WHERE path = (SELECT source_path FROM prs WHERE number = ?)""", (pr_number,), ) if cursor.rowcount == 0: logger.warning("PR #%d: no source_path linked — source not requeued for re-extraction", pr_number) db.audit( conn, "evaluate", "pr_terminated", json.dumps( { "pr": pr_number, "reason": reason, } ), ) logger.info("PR #%d: TERMINATED — %s", pr_number, reason) async def dispose_rejected_pr(conn, pr_number: int, eval_attempts: int, all_issues: list[str]): """Disposition logic for rejected PRs on attempt 2+. Attempt 1: normal — back to open, wait for fix. Attempt 2: check issue classification. - Mechanical only: keep open for one more attempt (auto-fix future). - Substantive or mixed: close PR, requeue source. Attempt 3+: terminal. """ if eval_attempts < 2: # Attempt 1: post structured feedback so agent learns, but don't close if all_issues: feedback_body = format_rejection_comment(all_issues, source="eval_attempt_1") await forgejo_api( "POST", repo_path(f"issues/{pr_number}/comments"), {"body": feedback_body}, ) return classification = classify_issues(all_issues) if eval_attempts >= config.MAX_EVAL_ATTEMPTS: # Terminal await terminate_pr(conn, pr_number, f"eval budget exhausted after {eval_attempts} attempts") return if classification == "mechanical": # Mechanical issues only — keep open for one more attempt. # Future: auto-fix module will push fixes here. logger.info( "PR #%d: attempt %d, mechanical issues only (%s) — keeping open for fix attempt", pr_number, eval_attempts, all_issues, ) db.audit( conn, "evaluate", "mechanical_retry", json.dumps( { "pr": pr_number, "attempt": eval_attempts, "issues": all_issues, } ), ) else: # Substantive, mixed, or unknown — close and requeue logger.info( "PR #%d: attempt %d, %s issues (%s) — closing and requeuing source", pr_number, eval_attempts, classification, all_issues, ) await terminate_pr( conn, pr_number, f"substantive issues after {eval_attempts} attempts: {', '.join(all_issues)}" )