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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user