Some checks are pending
CI / lint-and-test (push) Waiting to run
Ganymede review nit: if get_pr_diff returns an empty string (edge case — Forgejo quirk, empty PR), the old `if diff is None` branch would miss it, the `elif diff and ...` would evaluate False (empty string is falsy), and control would fall to `else` — triggering auto-close on zero diff content. Change `if diff is None` → `if not diff` so empty string ALSO falls through to the conservative path. Matches the stated posture: skip auto-close when in doubt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 lines
10 KiB
Python
260 lines
10 KiB
Python
"""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 asyncio
|
|
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, get_pr_diff, repo_path
|
|
from .github_feedback import on_closed, on_eval_complete
|
|
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
|
|
|
|
try:
|
|
await on_closed(conn, pr_number, reason=reason)
|
|
except Exception:
|
|
logger.exception("PR #%d: GitHub close feedback failed (non-fatal)", pr_number)
|
|
|
|
# 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+.
|
|
|
|
Auto-close gate (all attempts): near-duplicate of an already-merged PR for
|
|
the same source — close immediately. Avoids the Apr 22 runaway-damage
|
|
pattern where a source extracted 20+ times in a short window produced
|
|
dozens of open PRs that all had to be closed manually.
|
|
|
|
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.
|
|
"""
|
|
# Auto-close near-duplicate when a merged sibling for the same source exists.
|
|
# Runs before the attempt-count branches so it catches the common runaway
|
|
# case on attempt 1 instead of waiting for attempt 2's terminate path.
|
|
#
|
|
# Exact-match requirement (Ganymede review): compound rejections like
|
|
# ["near_duplicate", "factual_discrepancy"] carry signal about the merged
|
|
# sibling being wrong or limited — we want humans to see those. Only the
|
|
# pure single-issue case is safe to auto-close.
|
|
if all_issues == ["near_duplicate"]:
|
|
existing_merged = conn.execute(
|
|
"""SELECT p2.number, p1.source_path FROM prs p1
|
|
JOIN prs p2 ON p2.source_path = p1.source_path
|
|
WHERE p1.number = ?
|
|
AND p1.source_path IS NOT NULL
|
|
AND p2.number != p1.number
|
|
AND p2.status = 'merged'
|
|
LIMIT 1""",
|
|
(pr_number,),
|
|
).fetchone()
|
|
if existing_merged:
|
|
sibling = existing_merged[0]
|
|
source_path = existing_merged[1]
|
|
|
|
# Enrichment guard: LLM reviewers can flag enrichment prose as
|
|
# "redundant" via eval_parse regex, tagging near_duplicate even
|
|
# though validate.py's structural check only fires on NEW files.
|
|
# If the PR only MODIFIES existing files (no "new file mode" in
|
|
# diff), it's an enrichment — skip auto-close so a human reviews.
|
|
#
|
|
# 10s timeout bounds damage when Forgejo is wedged (Apr 22 incident:
|
|
# hung for 2.5h). Conservative fallback: skip auto-close on any
|
|
# failure — fall through to normal rejection path.
|
|
try:
|
|
diff = await asyncio.wait_for(get_pr_diff(pr_number), timeout=10)
|
|
except (asyncio.TimeoutError, Exception):
|
|
logger.warning(
|
|
"PR #%d: diff fetch failed/timed out for near-dup guard — skipping auto-close",
|
|
pr_number, exc_info=True,
|
|
)
|
|
diff = None
|
|
|
|
if not diff:
|
|
# None or empty — conservative fallback, fall through to attempt-count branches
|
|
pass
|
|
elif "new file mode" not in diff:
|
|
logger.info(
|
|
"PR #%d: near_duplicate but modifies-only (enrichment) — skipping auto-close",
|
|
pr_number,
|
|
)
|
|
else:
|
|
logger.info(
|
|
"PR #%d: auto-closing near-duplicate of merged PR #%d (same source)",
|
|
pr_number, sibling,
|
|
)
|
|
# Post a brief explanation before closing (best-effort — non-fatal)
|
|
try:
|
|
await forgejo_api(
|
|
"POST",
|
|
repo_path(f"issues/{pr_number}/comments"),
|
|
{"body": (
|
|
f"Auto-closed: near-duplicate of already-merged PR "
|
|
f"#{sibling} (same source: `{source_path}`)."
|
|
)},
|
|
)
|
|
except Exception:
|
|
logger.debug("PR #%d: auto-close comment failed (non-fatal)", pr_number, exc_info=True)
|
|
await close_pr(
|
|
conn, pr_number,
|
|
last_error=f"auto_closed_near_duplicate: merged sibling #{sibling}",
|
|
)
|
|
db.audit(
|
|
conn, "evaluate", "auto_closed_near_duplicate",
|
|
json.dumps({
|
|
"pr": pr_number,
|
|
"merged_sibling": sibling,
|
|
"source_path": source_path,
|
|
"eval_attempts": eval_attempts,
|
|
}),
|
|
)
|
|
return
|
|
|
|
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)}"
|
|
)
|