"""Already-landed PR reconciliation workflow helpers (#301). Read-only assessment and capability planning for reconciling open PRs whose heads are already ancestors of the target branch. Does not invoke review or merge paths. """ from __future__ import annotations import subprocess from typing import Any import gitea_config from merged_cleanup_reconcile import extract_linked_issue, is_head_ancestor_of_ref ELIGIBILITY_ALREADY_LANDED = "ALREADY_LANDED_RECONCILE_REQUIRED" ELIGIBILITY_NOT_LANDED = "NOT_ALREADY_LANDED" ELIGIBILITY_STALE_TARGET = "TARGET_BRANCH_UNVERIFIED" ELIGIBILITY_PR_NOT_OPEN = "PR_NOT_OPEN" OUTCOME_FULL_RECONCILE = "FULL_RECONCILE_CLOSE_ALLOWED" OUTCOME_PARTIAL_COMMENT = "PARTIAL_RECONCILE_COMMENT_THEN_STOP" OUTCOME_RECOVERY_HANDOFF = "RECOVERY_HANDOFF_ONLY" OUTCOME_NOT_LANDED = "NOT_LANDED_NO_ACTION" OUTCOME_GATE_NOT_PROVEN = "GATE_NOT_PROVEN" RECONCILE_WORKFLOW_MARKERS = ( "workflows/reconcile-landed-pr.md", "reconcile-landed-pr.md", ) RECONCILE_TASK_MARKERS = ( "reconcile-landed-pr", "reconcile already-landed", "reconcile_already_landed", ) def fetch_target_branch(project_root: str, remote: str, branch: str) -> dict[str, Any]: """Fetch *branch* from *remote* and return the resolved SHA.""" ref = f"{remote}/{branch}" fetch = subprocess.run( ["git", "-C", project_root, "fetch", remote, branch], capture_output=True, text=True, check=False, ) if fetch.returncode != 0: return { "success": False, "target_branch": branch, "target_ref": ref, "target_branch_sha": None, "reasons": [ f"git fetch {remote} {branch} failed: " f"{(fetch.stderr or fetch.stdout or '').strip()}" ], } rev = subprocess.run( ["git", "-C", project_root, "rev-parse", ref], capture_output=True, text=True, check=False, ) if rev.returncode != 0: return { "success": False, "target_branch": branch, "target_ref": ref, "target_branch_sha": None, "reasons": [ f"git rev-parse {ref} failed: " f"{(rev.stderr or rev.stdout or '').strip()}" ], } return { "success": True, "target_branch": branch, "target_ref": ref, "target_branch_sha": (rev.stdout or "").strip(), "reasons": [], "git_fetch_command": f"git fetch {remote} {branch}", } def profile_reconciliation_capabilities( allowed: list[str] | None, forbidden: list[str] | None, ) -> dict[str, bool]: """Map active profile operations to reconciliation capabilities.""" allowed = allowed or [] forbidden = forbidden or [] def can(op: str) -> bool: return gitea_config.check_operation(op, allowed, forbidden)[0] return { "read": can("gitea.read"), "comment_pr": can("gitea.pr.comment"), "comment_issue": can("gitea.issue.comment"), "close_pr": can("gitea.pr.close"), "close_issue": can("gitea.issue.close"), "review_pr": can("gitea.pr.review") or can("gitea.pr.approve"), "merge_pr": can("gitea.pr.merge"), } def assess_open_pr_reconciliation( *, pr: dict[str, Any], project_root: str, remote: str, target_branch: str, target_fetch: dict[str, Any] | None = None, ) -> dict[str, Any]: """Assess whether an open PR is eligible for already-landed reconciliation.""" pr_number = int(pr.get("number") or 0) pr_state = (pr.get("state") or "").strip().lower() head = pr.get("head") or {} head_sha = head.get("sha") if isinstance(head, dict) else None head_ref = head.get("ref") if isinstance(head, dict) else None base = pr.get("base") or {} base_ref = base.get("ref") if isinstance(base, dict) else None title = pr.get("title") or "" body = pr.get("body") or "" fetch_result = target_fetch or fetch_target_branch( project_root, remote, target_branch ) linked_issue = extract_linked_issue(title, body) result: dict[str, Any] = { "pr_number": pr_number, "pr_state": pr_state, "candidate_head_sha": head_sha, "head_ref": head_ref, "base_ref": base_ref or target_branch, "target_branch": target_branch, "target_branch_sha": fetch_result.get("target_branch_sha"), "linked_issue": linked_issue, "git_ref_mutations": [], "reasons": [], "review_merge_allowed": False, } if fetch_result.get("git_fetch_command"): result["git_ref_mutations"].append(fetch_result["git_fetch_command"]) if pr_state != "open": result["eligibility_class"] = ELIGIBILITY_PR_NOT_OPEN result["ancestor_proof"] = None result["reconciliation_allowed"] = False result["reasons"].append(f"PR #{pr_number} state is {pr_state!r}, not open") return result if not fetch_result.get("success"): result["eligibility_class"] = ELIGIBILITY_STALE_TARGET result["ancestor_proof"] = None result["reconciliation_allowed"] = False result["reasons"].extend(fetch_result.get("reasons") or []) return result target_ref = fetch_result.get("target_ref") or f"{remote}/{target_branch}" ancestor = is_head_ancestor_of_ref(project_root, head_sha, target_ref) result["ancestor_proof"] = ancestor if ancestor is None: result["eligibility_class"] = ELIGIBILITY_STALE_TARGET result["reconciliation_allowed"] = False result["reasons"].append( f"ancestor check failed for head {head_sha!r} against {target_ref}" ) return result if ancestor: result["eligibility_class"] = ELIGIBILITY_ALREADY_LANDED result["reconciliation_allowed"] = True return result result["eligibility_class"] = ELIGIBILITY_NOT_LANDED result["reconciliation_allowed"] = False result["reasons"].append( f"PR head {head_sha} is not an ancestor of {target_ref}" ) return result def resolve_reconciliation_plan( *, assessment: dict[str, Any], capabilities: dict[str, bool] | None, ) -> dict[str, Any]: """Map eligibility + profile capabilities to a reconciliation outcome.""" caps = capabilities or {} reasons: list[str] = [] if not assessment.get("reconciliation_allowed"): outcome = OUTCOME_NOT_LANDED if assessment.get("eligibility_class") in { ELIGIBILITY_STALE_TARGET, ELIGIBILITY_PR_NOT_OPEN, }: outcome = OUTCOME_GATE_NOT_PROVEN reasons.extend(assessment.get("reasons") or []) return { "outcome": outcome, "close_pr_allowed": False, "comment_pr_allowed": False, "close_issue_allowed": False, "comment_issue_allowed": False, "review_merge_allowed": False, "missing_capabilities": _missing_reconciliation_capabilities(caps), "safe_next_action": ( "Do not reconcile via review/merge; repair missing proof first." ), "reasons": reasons, } close_pr = bool(caps.get("close_pr")) comment_pr = bool(caps.get("comment_pr")) close_issue = bool(caps.get("close_issue")) comment_issue = bool(caps.get("comment_issue")) if caps.get("review_pr") or caps.get("merge_pr"): reasons.append( "reconciliation path must not use review/merge capabilities" ) if close_pr: outcome = OUTCOME_FULL_RECONCILE safe_next_action = ( "Run reconciliation close via exact gitea.pr.close after proof; " "do not approve or merge." ) elif comment_pr: outcome = OUTCOME_PARTIAL_COMMENT safe_next_action = ( "Post one reconciliation comment with ancestor proof, then stop " "for an authorized close profile." ) else: outcome = OUTCOME_RECOVERY_HANDOFF safe_next_action = ( "Produce a recovery handoff naming missing gitea.pr.close and/or " "gitea.pr.comment; do not loop through review/merge." ) reasons.append("gitea.pr.close is not available in the active profile") return { "outcome": outcome, "close_pr_allowed": close_pr, "comment_pr_allowed": comment_pr, "close_issue_allowed": close_issue, "comment_issue_allowed": comment_issue, "review_merge_allowed": False, "missing_capabilities": _missing_reconciliation_capabilities(caps), "safe_next_action": safe_next_action, "reasons": reasons, } def _missing_reconciliation_capabilities(caps: dict[str, bool]) -> list[str]: missing = [] if not caps.get("read"): missing.append("gitea.read") if not caps.get("close_pr"): missing.append("gitea.pr.close") if not caps.get("comment_pr"): missing.append("gitea.pr.comment") return missing def assess_reconcile_workflow_source(report_text: str) -> dict[str, Any]: """Reconciliation reports must cite the canonical workflow file.""" lower = (report_text or "").lower() reasons = [] if not any(marker in lower for marker in RECONCILE_WORKFLOW_MARKERS): reasons.append( "reconciliation report missing workflow source " "(workflows/reconcile-landed-pr.md)" ) if not any(marker in lower for marker in RECONCILE_TASK_MARKERS): reasons.append( "reconciliation report missing task mode declaration " "(reconcile-landed-pr)" ) return { "complete": not reasons, "downgraded": bool(reasons), "reasons": reasons, }