feat(pr-sync): native assess and author update-by-merge lifecycle

Prevent approved PRs from stalling when master advances. Add
gitea_assess_pr_sync_status and gitea_update_pr_branch_by_merge with
head/base pinning, author-only updates, conflict handoff, and approval
invalidation after head changes. Wire task capability map, sequential
controller routing in review-merge workflow, and hermetic AC tests.
This commit is contained in:
2026-07-17 10:42:53 -04:00
parent f65069d22d
commit 98fbfb3de7
6 changed files with 1679 additions and 6 deletions
+613
View File
@@ -1134,6 +1134,7 @@ import review_merge_state_machine # noqa: E402
import pr_work_lease # noqa: E402
import workflow_skill # noqa: E402
import conflict_fix_classification # noqa: E402
import pr_sync_status # noqa: E402 # PR sync / update-by-merge lifecycle
import native_mcp_preference # noqa: E402
import branch_cleanup_guard # noqa: E402
import thread_state_ledger_validator # noqa: E402
@@ -13195,6 +13196,616 @@ def gitea_assess_conflict_fix_classification(
return result
def _count_commits_behind(
base_url: str,
auth: dict,
*,
pr_head_sha: str,
base_head_sha: str,
) -> int | None:
"""Return how many base commits are missing from the PR head, if knowable."""
# compare old...new returns commits reachable from new not from old.
# pr_head...base_head ≈ commits on base not in PR (behind count).
try:
cmp_url = (
f"{base_url}/compare/{pr_head_sha}...{base_head_sha}"
)
data = api_request("GET", cmp_url, auth) or {}
total = data.get("total_commits")
if isinstance(total, int) and total >= 0:
return total
commits = data.get("commits")
if isinstance(commits, list):
return len(commits)
except Exception:
pass
return None
def _branch_protection_requires_current_base(
base_url: str,
auth: dict,
*,
base_branch: str,
) -> bool | None:
"""Read Gitea branch protection ``block_on_outdated_branch`` when present."""
branch = (base_branch or "").strip()
if not branch:
return None
try:
# Prefer the named protection rule matching the base branch.
protections = api_request(
"GET", f"{base_url}/branch_protections", auth
) or []
if isinstance(protections, list):
for rule in protections:
if not isinstance(rule, dict):
continue
name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
# Exact match or glob-ish contains for common patterns.
if name == branch or name in ("*", f"{branch}"):
if "block_on_outdated_branch" in rule:
return bool(rule.get("block_on_outdated_branch"))
# Fallback: any protection that mentions the base branch.
for rule in protections:
if not isinstance(rule, dict):
continue
name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
if branch in name or name.endswith(branch):
if "block_on_outdated_branch" in rule:
return bool(rule.get("block_on_outdated_branch"))
# Branch payload may embed effective protection.
br = api_request("GET", f"{base_url}/branches/{branch}", auth) or {}
prot = br.get("protection") or br.get("effective_branch_protection") or {}
if isinstance(prot, dict) and "block_on_outdated_branch" in prot:
return bool(prot.get("block_on_outdated_branch"))
except Exception:
return None
return None
@mcp.tool()
def gitea_assess_pr_sync_status(
pr_number: int,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
prepared_verdict_head_sha: str | None = None,
branch_protection_requires_current_base: bool | None = None,
checks_status: str | None = None,
) -> dict:
"""Read-only: assess whether an approved/open PR is merge-ready or needs sync.
Distinguishes ``merge_now``, ``update_branch_by_merge``,
``author_conflict_remediation``, ``fresh_review_required``, and ``blocked``.
Pins exact PR head and live base head. Never mutates. Never returns tokens.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"performed": False,
"recommended_next_action": pr_sync_status.ACTION_BLOCKED,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
try:
h, o, r = _resolve(remote, host, org, repo)
except Exception as exc:
return {
"success": False,
"performed": False,
"recommended_next_action": pr_sync_status.ACTION_BLOCKED,
"reasons": [f"repository identity resolve failed: {_redact(str(exc))}"],
}
auth = _auth(h)
if not auth:
return {
"success": False,
"performed": False,
"recommended_next_action": pr_sync_status.ACTION_BLOCKED,
"reasons": ["credentials unavailable (fail closed)"],
"host": h,
"org": o,
"repo": r,
}
base = repo_api_url(h, o, r)
try:
pr = api_request("GET", f"{base}/pulls/{pr_number}", auth) or {}
except Exception as exc:
return {
"success": False,
"performed": False,
"recommended_next_action": pr_sync_status.ACTION_BLOCKED,
"reasons": [f"PR details could not be retrieved: {_redact(str(exc))}"],
"host": h,
"org": o,
"repo": r,
"pr_number": pr_number,
}
pr_state = (pr.get("state") or "").strip().lower() or None
head_obj = pr.get("head") or {}
base_obj = pr.get("base") or {}
pr_head_sha = (head_obj.get("sha") or "").strip() or None
source_branch = (head_obj.get("ref") or "").strip() or None
base_branch = (base_obj.get("ref") or "").strip() or "master"
base_head_sha = (base_obj.get("sha") or "").strip() or None
mergeable = pr.get("mergeable")
# Gitea sometimes exposes conflict via mergeable=false only.
has_conflicts = False if mergeable is True else (True if mergeable is False else None)
# Prefer live base branch tip over the PR's stored base SHA (may lag).
try:
br = api_request("GET", f"{base}/branches/{base_branch}", auth) or {}
live_base = ((br.get("commit") or {}).get("id") or "").strip()
if live_base:
base_head_sha = live_base
except Exception:
pass
commits_behind = None
if pr_head_sha and base_head_sha:
commits_behind = _count_commits_behind(
base, auth, pr_head_sha=pr_head_sha, base_head_sha=base_head_sha
)
if commits_behind is None:
# Fail closed on unknown behind-count only when SHAs differ.
commits_behind = 0 if pr_head_sha == base_head_sha else None
if branch_protection_requires_current_base is None:
branch_protection_requires_current_base = (
_branch_protection_requires_current_base(
base, auth, base_branch=base_branch
)
)
# When protection cannot be read, fail closed by requiring current base
# whenever the PR is behind (safer for protected repos). Callers may pass
# an explicit False when policy is known not to require it.
if branch_protection_requires_current_base is None:
if commits_behind is not None and commits_behind > 0:
branch_protection_requires_current_base = True
else:
branch_protection_requires_current_base = False
approval_at_current_head = None
try:
feedback = gitea_get_pr_review_feedback(
pr_number=pr_number, remote=remote, host=host, org=org, repo=repo,
)
if feedback.get("success"):
approval_at_current_head = bool(feedback.get("approval_at_current_head"))
else:
approval_at_current_head = False
except Exception:
approval_at_current_head = None
# Locks / leases (best-effort; absence is reported as None/False).
active_author_lock = None
try:
lock = issue_lock_store.load_issue_lock(
remote=remote if remote in REMOTES else (h or "unknown"),
org=o,
repo=r,
issue_number=pr_number,
)
if lock:
active_author_lock = issue_lock_store.is_lease_live(lock)
else:
active_author_lock = False
except Exception:
active_author_lock = None
active_reviewer_lease = None
active_merger_lease = None
try:
comments = api_request(
"GET", f"{base}/issues/{pr_number}/comments", auth
) or []
if isinstance(comments, list):
rev = pr_work_lease.find_active_reviewer_lease(
comments, pr_number=pr_number
)
active_reviewer_lease = bool(rev)
# Merger lease comments share reviewer_pr_lease session store when present.
try:
sess = reviewer_pr_lease.get_session_lease() or {}
active_merger_lease = bool(
sess.get("active")
and int(sess.get("pr_number") or 0) == int(pr_number)
and (sess.get("phase") or "").startswith("merger")
)
except Exception:
active_merger_lease = False
except Exception:
pass
if checks_status is None:
checks_status = "unknown"
# Combined status on PR head when Actions/status API is available.
if pr_head_sha:
try:
st = api_request(
"GET",
f"{base}/commits/{pr_head_sha}/status",
auth,
) or {}
state = (st.get("state") or "").strip().lower()
if state:
checks_status = state
elif not st:
checks_status = "none"
except Exception:
checks_status = "unknown"
assessment = pr_sync_status.assess_pr_sync_status(
host=h,
org=o,
repo=r,
pr_number=pr_number,
pr_state=pr_state,
source_branch=source_branch,
pr_head_sha=pr_head_sha,
base_head_sha=base_head_sha,
commits_behind=commits_behind,
mergeable=mergeable,
has_conflicts=has_conflicts,
branch_protection_requires_current_base=branch_protection_requires_current_base,
approval_at_current_head=approval_at_current_head,
checks_status=checks_status,
active_author_lock=active_author_lock,
active_reviewer_lease=active_reviewer_lease,
active_merger_lease=active_merger_lease,
prepared_verdict_head_sha=prepared_verdict_head_sha,
)
assessment["remote"] = remote if remote in REMOTES else None
assessment["base_branch"] = base_branch
assessment["success"] = True
assessment["performed"] = False
return assessment
@mcp.tool()
def gitea_update_pr_branch_by_merge(
pr_number: int,
expected_pr_head_sha: str,
expected_base_head_sha: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Author-only: merge live base into the PR branch (Gitea Update-by-merge).
Pins both expected PR head and expected base head; fails closed on either
race. Never rebases or force-pushes. On conflicts, returns a structured
author-remediation handoff without partial remote mutation.
Requires author profile, existing issue/PR lock, PR worktree under
branches/, clean control checkout, and master parity.
"""
profile = get_profile()
allowed = profile.get("allowed_operations") or []
forbidden = profile.get("forbidden_operations") or []
role = _role_kind(allowed, forbidden)
# Permission: author branch push / PR mutation surface.
push_block = _profile_operation_gate("gitea.branch.push")
if push_block:
return {
"success": False,
"performed": False,
"mutation_allowed": False,
"reasons": push_block,
"permission_report": _permission_block_report("gitea.branch.push"),
"role_kind": role,
}
if role != "author":
pre = pr_sync_status.assess_update_pr_branch_preflight(
role_kind=role,
expected_pr_head_sha=expected_pr_head_sha,
live_pr_head_sha=expected_pr_head_sha,
expected_base_head_sha=expected_base_head_sha,
live_base_head_sha=expected_base_head_sha,
style="merge",
)
pre["success"] = False
return pre
try:
h, o, r = _resolve(remote, host, org, repo)
except Exception as exc:
return {
"success": False,
"performed": False,
"mutation_allowed": False,
"reasons": [f"repository identity resolve failed: {_redact(str(exc))}"],
"role_kind": role,
}
# Workspace / control / parity gates.
try:
_verify_role_mutation_workspace(
remote, worktree_path=worktree_path, task="push_branch"
)
except Exception as exc:
return {
"success": False,
"performed": False,
"mutation_allowed": False,
"reasons": [f"workspace gate failed: {_redact(str(exc))}"],
"role_kind": role,
"host": h,
"org": o,
"repo": r,
"pr_number": pr_number,
}
control_clean = True
try:
porcelain = _get_workspace_porcelain(PROJECT_ROOT)
# Untracked-only dirt is ignored; tracked edits contaminate control.
tracked_dirty = [
line for line in (porcelain or "").splitlines()
if line.strip() and not line.startswith("??")
]
control_clean = not bool(tracked_dirty)
except Exception:
control_clean = False
master_ok = True
try:
stale = _master_parity_block("gitea.branch.push")
master_ok = not bool(stale)
except Exception:
master_ok = False
wt = (worktree_path or "").strip() or (
os.environ.get("GITEA_AUTHOR_WORKTREE")
or os.environ.get("GITEA_ACTIVE_WORKTREE")
or ""
).strip()
has_lock = False
try:
lock = issue_lock_store.read_session_issue_lock()
if lock and int(lock.get("issue_number") or 0) == int(pr_number):
has_lock = issue_lock_store.is_lease_live(lock)
if not has_lock:
lock2 = issue_lock_store.load_issue_lock(
remote=remote if remote in REMOTES else (h or "unknown"),
org=o,
repo=r,
issue_number=pr_number,
)
has_lock = bool(lock2 and issue_lock_store.is_lease_live(lock2))
except Exception:
has_lock = False
auth = _auth(h)
if not auth:
return {
"success": False,
"performed": False,
"mutation_allowed": False,
"reasons": ["credentials unavailable (fail closed)"],
"role_kind": role,
"host": h,
"org": o,
"repo": r,
"pr_number": pr_number,
}
base = repo_api_url(h, o, r)
try:
pr = api_request("GET", f"{base}/pulls/{pr_number}", auth) or {}
except Exception as exc:
return {
"success": False,
"performed": False,
"mutation_allowed": False,
"reasons": [f"PR details could not be retrieved: {_redact(str(exc))}"],
"role_kind": role,
"host": h,
"org": o,
"repo": r,
"pr_number": pr_number,
}
if (pr.get("state") or "").strip().lower() != "open":
return {
"success": False,
"performed": False,
"mutation_allowed": False,
"reasons": [
f"PR is not open (state={pr.get('state')}); refuse update"
],
"role_kind": role,
"host": h,
"org": o,
"repo": r,
"pr_number": pr_number,
}
head_obj = pr.get("head") or {}
base_obj = pr.get("base") or {}
live_pr_head = (head_obj.get("sha") or "").strip() or None
source_branch = (head_obj.get("ref") or "").strip() or None
base_branch = (base_obj.get("ref") or "").strip() or "master"
live_base_head = (base_obj.get("sha") or "").strip() or None
try:
br = api_request("GET", f"{base}/branches/{base_branch}", auth) or {}
tip = ((br.get("commit") or {}).get("id") or "").strip()
if tip:
live_base_head = tip
except Exception:
pass
mergeable = pr.get("mergeable")
has_conflicts = False if mergeable is True else (True if mergeable is False else None)
owns_branch = True
if wt and source_branch:
try:
# Best-effort: worktree branch should match PR source branch.
import subprocess as _sp
out = _sp.check_output(
["git", "-C", wt, "rev-parse", "--abbrev-ref", "HEAD"],
text=True,
stderr=_sp.DEVNULL,
).strip()
if out and out != source_branch:
owns_branch = False
except Exception:
owns_branch = None # unknown — do not hard-fail solely on probe
preflight = pr_sync_status.assess_update_pr_branch_preflight(
role_kind=role,
expected_pr_head_sha=expected_pr_head_sha,
live_pr_head_sha=live_pr_head,
expected_base_head_sha=expected_base_head_sha,
live_base_head_sha=live_base_head,
has_conflicts=has_conflicts,
mergeable=mergeable,
style="merge",
has_author_lock=has_lock,
worktree_path=wt or None,
worktree_owns_source_branch=owns_branch,
control_checkout_clean=control_clean,
master_parity_ok=master_ok,
force_push=False,
rebase=False,
)
preflight["host"] = h
preflight["org"] = o
preflight["repo"] = r
preflight["pr_number"] = pr_number
preflight["source_branch"] = source_branch
preflight["base_branch"] = base_branch
preflight["worktree_path"] = wt or None
preflight["profile_name"] = profile.get("profile_name")
if not preflight.get("mutation_allowed"):
preflight["success"] = False
preflight["performed"] = False
if preflight.get("author_remediation_handoff"):
preflight["recommended_next_action"] = (
pr_sync_status.ACTION_AUTHOR_CONFLICT_REMEDIATION
)
return preflight
# Native Gitea update: POST .../pulls/{index}/update?style=merge
update_url = f"{base}/pulls/{pr_number}/update?style=merge"
try:
api_request("POST", update_url, auth)
except Exception as exc:
msg = _redact(str(exc)).lower()
conflictish = any(
token in msg
for token in ("conflict", "409", "merge conflict", "not mergeable")
)
if conflictish:
return {
"success": False,
"performed": False,
"mutation_allowed": False,
"author_remediation_handoff": True,
"recommended_next_action": (
pr_sync_status.ACTION_AUTHOR_CONFLICT_REMEDIATION
),
"reasons": [
"Gitea refused update-by-merge due to conflicts; "
"no partial remote update applied",
_redact(str(exc)),
],
"host": h,
"org": o,
"repo": r,
"pr_number": pr_number,
"expected_pr_head_sha": expected_pr_head_sha,
"expected_base_head_sha": expected_base_head_sha,
"live_pr_head_sha_before": live_pr_head,
"style": "merge",
"role_kind": role,
}
return {
"success": False,
"performed": False,
"mutation_allowed": False,
"reasons": [
f"update-by-merge API failed (fail closed): {_redact(str(exc))}"
],
"host": h,
"org": o,
"repo": r,
"pr_number": pr_number,
"style": "merge",
"role_kind": role,
}
# Re-read new head after successful update.
new_head = None
try:
pr_after = api_request("GET", f"{base}/pulls/{pr_number}", auth) or {}
new_head = ((pr_after.get("head") or {}).get("sha") or "").strip() or None
mergeable_after = pr_after.get("mergeable")
except Exception:
pr_after = {}
mergeable_after = None
transition = pr_sync_status.assess_post_update_head_transition(
former_pr_head_sha=live_pr_head,
new_pr_head_sha=new_head,
former_approval_head_sha=live_pr_head,
prepared_verdict_head_sha=live_pr_head,
)
return {
"success": True,
"performed": True,
"mutation_allowed": True,
"style": "merge",
"force_push": False,
"rebase": False,
"host": h,
"org": o,
"repo": r,
"pr_number": pr_number,
"source_branch": source_branch,
"base_branch": base_branch,
"former_pr_head_sha": live_pr_head,
"new_pr_head_sha": new_head,
"base_head_sha": live_base_head,
"mergeable_after": mergeable_after,
"approval_invalidated_for_former_head": transition.get(
"approval_invalidated", True
),
"reviewer_lease_superseded": transition.get("reviewer_lease_superseded"),
"merger_lease_superseded": transition.get("merger_lease_superseded"),
"prepared_verdict_invalidated": transition.get(
"prepared_verdict_invalidated"
),
"recommended_next_action": transition.get(
"recommended_next_action",
pr_sync_status.ACTION_FRESH_REVIEW_REQUIRED,
),
"transition": transition,
"role_kind": role,
"profile_name": profile.get("profile_name"),
"worktree_path": wt or None,
"reasons": list(transition.get("reasons") or []) + [
"update-by-merge completed via native Gitea API (style=merge only)"
],
}
@mcp.tool()
def gitea_assess_conflict_fix_push(
pr_number: int,
@@ -13867,6 +14478,8 @@ def gitea_resolve_task_capability(
"commit_files",
"gitea_commit_files",
"address_pr_change_requests",
"update_pr_branch_by_merge",
"gitea_update_pr_branch_by_merge",
"delete_branch",
"cleanup_merged_pr_branch",
"reconciliation_cleanup",