Merge remote-tracking branch 'prgs/master' into feat/issue-532-merged-cleanup-worktree-aliases

# Conflicts:
#	gitea_mcp_server.py
#	merged_cleanup_reconcile.py
This commit is contained in:
2026-07-09 08:20:21 -04:00
22 changed files with 2083 additions and 7 deletions
+127 -1
View File
@@ -828,6 +828,13 @@ import audit_reconciliation_mode # noqa: E402
import review_merge_state_machine # noqa: E402
import pr_work_lease # noqa: E402
import native_mcp_preference # noqa: E402
import master_parity_gate # noqa: E402
# Master-parity baseline (#420): the commit this server process started at.
# Captured once so that, at mutation time, we can detect when the on-disk
# master has advanced past the running code and fail closed until restart.
# Read-only operations are never blocked by staleness.
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
import worktree_cleanup_audit # noqa: E402
@@ -4737,6 +4744,34 @@ def gitea_reconcile_merged_cleanups(
)
delete_capability_allowed = not _profile_operation_gate("gitea.branch.delete")
# #534: discover reviewer scratch trees and active leases for those PRs.
scratch_candidates = merged_cleanup_reconcile.discover_reviewer_scratch_worktrees(
PROJECT_ROOT
)
active_reviewer_leases: dict[int, bool] = {}
pr_states: dict[int, dict] = {}
for scratch in scratch_candidates:
pr_num = int(scratch["pr_number"])
if pr_num in active_reviewer_leases:
continue
try:
comments = api_get_all(f"{base}/issues/{pr_num}/comments", auth)
except Exception:
comments = []
lease = reviewer_pr_lease.find_active_reviewer_lease(
comments, pr_number=pr_num
)
active_reviewer_leases[pr_num] = bool(lease)
try:
pr_live = api_request("GET", f"{base}/pulls/{pr_num}", auth)
except Exception:
pr_live = {}
pr_states[pr_num] = {
"merged": bool((pr_live or {}).get("merged") or (pr_live or {}).get("merged_at")),
"closed": (pr_live or {}).get("state") == "closed",
}
report = merged_cleanup_reconcile.build_reconciliation_report(
project_root=PROJECT_ROOT,
closed_prs=merged_closed,
@@ -4745,6 +4780,8 @@ def gitea_reconcile_merged_cleanups(
head_on_master=head_on_master,
delete_capability_allowed=delete_capability_allowed,
target_ref=master_ref,
active_reviewer_leases=active_reviewer_leases,
pr_states=pr_states,
)
if dry_run:
@@ -4790,6 +4827,14 @@ def gitea_reconcile_merged_cleanups(
)
actions.append({"action": "remove_local_worktree", **result})
for scratch in report.get("reviewer_scratch_entries") or []:
if not scratch.get("safe_to_remove_worktree"):
continue
result = merged_cleanup_reconcile.remove_reviewer_scratch_worktree(
PROJECT_ROOT, scratch.get("worktree_path") or ""
)
actions.append({"action": "remove_reviewer_scratch_worktree", **result})
report["dry_run"] = False
report["executed"] = True
report["actions"] = actions
@@ -5550,15 +5595,41 @@ def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
return False
def _current_master_parity() -> dict:
"""Assess this process's code against the on-disk master HEAD (#420)."""
current_head = master_parity_gate.read_git_head(PROJECT_ROOT)
return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head)
def _master_parity_block(op: str) -> list[str]:
"""Fail-closed staleness reasons for a mutation *op* (#420).
Read-only operations (``gitea.read``) are never blocked: a stale server can
still be inspected. Any other (mutating) operation is refused when the
on-disk master has definitively advanced past the running code, since newly
merged capability gates would not yet be loaded in memory.
"""
if op == "gitea.read":
return []
return master_parity_gate.parity_block_reasons(_current_master_parity())
def _profile_operation_gate(op: str) -> list[str]:
"""Profile permission check for a single gated operation (#126, #216).
"""Profile permission check for a single gated operation (#126, #216, #420).
Issue discussion comments are gated separately from the gitea.pr.*
review/merge family: listing requires ``gitea.read``, creating requires
``gitea.issue.comment``. Closing a PR requires the distinct
``gitea.pr.close`` (#216). Returns a list of block reasons (empty =
allowed); an unreadable profile fails closed.
A mutating operation is additionally refused when the server code is stale
relative to master (#420), so a long-running process cannot bypass a
capability gate that has since been merged.
"""
stale_reasons = _master_parity_block(op)
if stale_reasons:
return stale_reasons
try:
profile = get_profile()
except Exception as exc:
@@ -7662,6 +7733,24 @@ def gitea_get_runtime_context(
PROJECT_ROOT),
}
parity = _current_master_parity()
result["master_parity"] = {
"in_parity": parity["in_parity"],
"stale": parity["stale"],
"restart_required": parity["restart_required"],
"startup_head": parity["startup_head"],
"current_head": parity["current_head"],
"summary": master_parity_gate.format_parity(parity),
"mutation_gate_enforced": not master_parity_gate.gate_disabled(),
}
if parity["stale"] and not master_parity_gate.gate_disabled():
safe_next_action = (
"Server code is stale relative to master; restart the Gitea MCP "
"server to load current capability gates before mutating. "
f"({master_parity_gate.format_parity(parity)})"
)
result["safe_next_action"] = safe_next_action
if reveal and h:
result["server"] = gitea_url(h, "").rstrip("/")
@@ -7669,6 +7758,43 @@ def gitea_get_runtime_context(
@mcp.tool()
def gitea_assess_master_parity(
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Read-only: is the running server code in parity with the on-disk master?
The MCP server loads its capability-gate code into memory at startup;
``master`` advancing (e.g. a newly merged security gate) does not take
effect until the process restarts. This tool compares the commit the
process started at against the current workspace ``HEAD`` and reports
whether a restart is required before mutations are safe again (#420).
Never mutates and makes no network calls. Read-only operations are never
blocked by staleness; only mutating operations fail closed while stale.
Returns:
dict with 'in_parity', 'stale', 'restart_required', 'startup_head',
'current_head', 'mutation_gate_enforced', 'summary', 'reasons', and a
'report' recovery payload when stale.
"""
parity = _current_master_parity()
enforced = not master_parity_gate.gate_disabled()
out = {
"in_parity": parity["in_parity"],
"stale": parity["stale"],
"restart_required": parity["restart_required"],
"determinable": parity["determinable"],
"startup_head": parity["startup_head"],
"current_head": parity["current_head"],
"mutation_gate_enforced": enforced,
"summary": master_parity_gate.format_parity(parity),
"reasons": parity["reasons"],
"process_root": PROJECT_ROOT,
}
if parity["stale"] and enforced:
out["report"] = master_parity_gate.parity_report(parity)
return out
def gitea_record_pre_review_command(
command: str,
cwd: str | None = None,