Compare commits

..
Author SHA1 Message Date
sysadmin 899ef8ec7f Merge branch 'master' into fix/issue-685-side-effect-free-resolver
Resolve gitea_resolve_task_capability conflict by keeping both #685
runtime_reconnect_required blocker and #702 stale_binding_recovery
annotation. Keep resolve-path stale-binding assessment report-only
(auto_recover=False) so capability resolution never mutates env or
session state. Preserve #725 merger-lease and #728 PR-sync code from
master. Regression: resolve uses report-only binding assessment.
2026-07-17 14:04:45 -04:00
sysadmin 58d6b5844f Merge pull request 'feat(controller): route outdated and conflicting PRs through synchronization lifecycle' (#728) from feat/issue-727-pr-sync-status into master 2026-07-17 12:58:35 -05:00
sysadmin c1c61e9b15 Merge branch 'master' into feat/issue-727-pr-sync-status
Resolve task_capability_map conflict by keeping PR-sync entries and #725
merger-lease map entries. Fix author ownership for PR #728 (issue #727):
prove lock via linked issue/session/branch instead of issue_number==pr_number.
Add regression tests for 727/728 ownership and unrelated issue rejection.
2026-07-17 13:57:22 -04:00
sysadmin fca3296883 Merge pull request 'fix(mcp): acquire merger PR lease with structured capability resolution (Fixes #718)' (#725) from issue-718-fix-merger-lease into master 2026-07-17 12:52:21 -05:00
jcwalker3 d7b0b0e772 Merge branch 'master' into feat/issue-727-pr-sync-status 2026-07-17 12:30:54 -05:00
sysadmin 83bc7246ff fix(mcp): forward explicit org/repo into lease/review anti-stomp
_verify_role_mutation_workspace dropped org/repo, so anti-stomp resolved bare
remote=prgs to Timesheet and failed closed wrong_repo on Gitea-Tools. Forward
explicit org/repo for merger/reviewer lease acquire, adopt, review, merge, and
lease release. Add regression for merger acquire forwarding.

Fixes #718
Refs #723
2026-07-17 13:30:52 -04:00
sysadmin 29c2cf2ef6 fix(mcp): fail closed when merger lease cannot resolve live PR head
gitea_acquire_merger_pr_lease previously continued when GET /pulls returned
an empty head SHA. Fail closed so exact-head scoping never proceeds with an
unresolvable live head. Add regression coverage for empty head payloads.

Fixes #718
Refs #723
2026-07-17 13:25:34 -04:00
jcwalker3 4a160e0e0c Merge branch 'master' into issue-718-fix-merger-lease 2026-07-17 12:21:36 -05:00
sysadmin 14d885b2d2 Merge pull request 'feat: preserve and resume prepared review verdict drafts (#609)' (#611) from feat/issue-609-prepared-review-verdict-resume into master 2026-07-17 12:19:50 -05:00
jcwalker3 a3dbf668fd Merge branch 'master' into feat/issue-609-prepared-review-verdict-resume 2026-07-17 12:15:31 -05:00
sysadmin fb9191e559 Merge pull request 'feat(recovery): stale worktree binding demotion and crash-orphan lease recovery (Closes #702)' (#703) from fix/issue-702-stale-binding-lease-recovery into master 2026-07-17 10:55:44 -05:00
jcwalker3 d867acd9db Merge branch 'master' into feat/issue-609-prepared-review-verdict-resume 2026-07-17 10:33:33 -05:00
sysadmin 98fbfb3de7 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.
2026-07-17 10:42:53 -04:00
jcwalker3 8814c04e3a Merge branch 'master' into feat/issue-609-prepared-review-verdict-resume 2026-07-17 09:32:42 -05:00
jcwalker3 f34ec86b90 Merge branch 'master' into fix/issue-685-side-effect-free-resolver 2026-07-17 09:32:07 -05:00
jcwalker3 7cb65028fd Merge branch 'master' into issue-718-fix-merger-lease 2026-07-17 09:31:59 -05:00
sysadmin 4f894009f6 fix(mcp): complete merger lease acquire + structured capability resolve (#718, #723)
Harden gitea_acquire_merger_pr_lease with required candidate_head, live-head
match, and fail-closed POST handling. Add capability-map aliases for reviewer
and merger lease acquire/adopt. Make unknown tasks return structured
unknown_task (no ValueError escape). Expand tests for roles, head scoping,
aliases, and unknown_task.

Closes #718. Addresses #723 resolver unknown_task and lease task mapping.
2026-07-17 10:09:36 -04:00
sysadmin 99640f90d0 fix(pr-611): resolve master merge conflicts for #609 draft feature
Preserve KIND_REVIEW_DRAFT and draft-resume tests while integrating
master recovery-critical session kinds and RECOVERY_CRITICAL_KINDS.
2026-07-17 06:44:40 -04:00
sysadmin 1421fa8568 Fix #718: Implement gitea_acquire_merger_pr_lease
This implements the native merger lease acquisition tool to allow a merger session to acquire its own lease when a reviewer lease does not exist or has expired, unblocking #679 adoption.
2026-07-17 01:30:12 -04:00
sysadmin c780ded653 fix(mcp): make capability resolver side-effect free (Closes #685)
Stale-runtime detection remains fail-closed, but gitea_resolve_task_capability
must never touch mcp_config.json, spawn recovery threads, or call os._exit.

- Remove _trigger_mcp_auto_restart from the read-only diagnostics path
- Return blocker_kind=runtime_reconnect_required with mutation_performed=false
- Keep exact_safe_next_action pointing at IDE/client reconnect when stale
- Add regression suite across author/reviewer/merger/reconciler profiles
2026-07-16 21:21:39 -04:00
sysadmin 23e366d9d6 feat: preserve and resume prepared review verdict drafts (#609)
Add non-terminal review draft save/resume tools so a fully validated
reviewer verdict can survive MCP disconnect or stale-runtime restart
without counting as a formal review.

- gitea_save_review_draft: durable draft with PR, head SHA, target SHA,
  worktree, validation evidence, intended verdict, and blocker reason
- gitea_resume_review_draft: live revalidation of head/target/#332/lease/
  identity/worktree before mark-ready or optional submit
- Drafts never substitute for formal approval without revalidation

Closes #609
2026-07-09 18:58:12 -04:00
13 changed files with 3414 additions and 88 deletions
+1118 -61
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -18,11 +18,13 @@ DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease"
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
SANCTIONED_PROVENANCE_SOURCES = frozenset({
SOURCE_ADOPT,
SOURCE_ACQUIRE,
SOURCE_ACQUIRE_MERGER,
SOURCE_HEARTBEAT,
})
@@ -209,8 +211,10 @@ _SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
r"(?is)\b("
r"gitea_adopt_merger_pr_lease|"
r"gitea_acquire_reviewer_pr_lease|"
r"gitea_acquire_merger_pr_lease|"
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
r"lease_proof_source\s*[:=]\s*gitea_acquire_merger_pr_lease|"
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
r"adoption_comment_id\s*[:=]|"
+648
View File
@@ -0,0 +1,648 @@
"""PR synchronization and conflict-remediation lifecycle (#PR-SYNC).
Pure assessment helpers for:
* ``gitea_assess_pr_sync_status`` — recommend the next sanctioned action for an
open PR when the base branch advances, approvals go stale, or conflicts appear.
* ``gitea_update_pr_branch_by_merge`` preflight — author-only, fail-closed pin
of both PR head and live base head; never rebase/force-push.
All recommendation logic is hermetic (no network) so unit tests cover every
acceptance path without Gitea credentials.
"""
from __future__ import annotations
import re
from typing import Any
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
# Recommended next actions (controller / skill vocabulary).
ACTION_MERGE_NOW = "merge_now"
ACTION_UPDATE_BRANCH_BY_MERGE = "update_branch_by_merge"
ACTION_AUTHOR_CONFLICT_REMEDIATION = "author_conflict_remediation"
ACTION_FRESH_REVIEW_REQUIRED = "fresh_review_required"
ACTION_BLOCKED = "blocked"
_VALID_ACTIONS = frozenset({
ACTION_MERGE_NOW,
ACTION_UPDATE_BRANCH_BY_MERGE,
ACTION_AUTHOR_CONFLICT_REMEDIATION,
ACTION_FRESH_REVIEW_REQUIRED,
ACTION_BLOCKED,
})
# Author-only mutation; reviewer/merger must never update author branches.
_AUTHOR_UPDATE_ROLES = frozenset({"author"})
_DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "limited"})
# Allowed Gitea update style — merge only (never rebase).
UPDATE_STYLE_MERGE = "merge"
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
def _normalize_sha(value: str | None) -> str | None:
text = (value or "").strip().lower()
if not text:
return None
return text if _FULL_SHA.match(text) else None
def _normalize_role(role: str | None) -> str:
return (role or "").strip().lower() or "unknown"
def assess_pr_sync_status(
*,
host: str | None = None,
org: str | None = None,
repo: str | None = None,
pr_number: int,
pr_state: str | None = None,
source_branch: str | None = None,
pr_head_sha: str | None = None,
base_head_sha: str | None = None,
commits_behind: int | None = None,
mergeable: bool | None = None,
has_conflicts: bool | None = None,
branch_protection_requires_current_base: bool | None = None,
approval_at_current_head: bool | None = None,
checks_status: str | None = None,
active_author_lock: bool | None = None,
active_reviewer_lease: bool | None = None,
active_merger_lease: bool | None = None,
prepared_verdict_head_sha: str | None = None,
checks_required: bool = True,
) -> dict[str, Any]:
"""Recommend the next sanctioned PR lifecycle action.
Decision order (fail closed):
1. Incomplete identity / open state / head SHAs → ``blocked``
2. Conflicts (or mergeable false with conflict signal) →
``author_conflict_remediation``
3. Head changed after approval / prepared verdict for former head →
``fresh_review_required`` (unless conflicts already routed author work)
4. Behind live base + branch protection requires current base +
auto-mergeable → ``update_branch_by_merge``
5. Approval at current head + mergeable + checks ok (+ update not
required) → ``merge_now``
6. Otherwise ``blocked`` with structured reasons
"""
reasons: list[str] = []
pr_head = _normalize_sha(pr_head_sha)
base_head = _normalize_sha(base_head_sha)
prepared_head = _normalize_sha(prepared_verdict_head_sha)
state = (pr_state or "").strip().lower()
checks = (checks_status or "unknown").strip().lower()
behind = commits_behind if isinstance(commits_behind, int) and commits_behind >= 0 else None
requires_current = bool(branch_protection_requires_current_base)
approval_ok = bool(approval_at_current_head)
# Derive conflict when caller only supplies mergeable=False.
if has_conflicts is None and mergeable is False:
has_conflicts = True
conflicts = bool(has_conflicts) if has_conflicts is not None else None
result: dict[str, Any] = {
"host": (host or "").strip() or None,
"org": (org or "").strip() or None,
"repo": (repo or "").strip() or None,
"pr_number": pr_number,
"pr_state": state or None,
"source_branch": (source_branch or "").strip() or None,
"pr_head_sha": pr_head,
"base_head_sha": base_head,
"commits_behind": behind,
"mergeable": mergeable,
"has_conflicts": conflicts,
"branch_protection_requires_current_base": requires_current,
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
"checks_status": checks,
"active_locks_and_leases": {
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
"merger_lease": bool(active_merger_lease) if active_merger_lease is not None else None,
},
"prepared_verdict_head_sha": prepared_head,
"recommended_next_action": ACTION_BLOCKED,
"approval_valid_for_merge": False,
"stale_approval": False,
"stale_prepared_verdict": False,
"reasons": reasons,
"success": True,
"performed": False,
}
if not isinstance(pr_number, int) or pr_number <= 0:
reasons.append("pr_number must be a positive integer (fail closed)")
return result
if state and state not in ("open",):
reasons.append(f"PR is not open (state={state}); cannot synchronize or merge")
result["recommended_next_action"] = ACTION_BLOCKED
return result
if not state:
reasons.append("PR state missing (fail closed)")
return result
if pr_head is None:
reasons.append(
"exact PR head SHA missing or not a full 40-char hex SHA (fail closed)"
)
if base_head is None:
reasons.append(
"exact live base head SHA missing or not a full 40-char hex SHA (fail closed)"
)
if behind is None:
reasons.append("commits_behind missing or invalid (fail closed)")
if mergeable is None:
reasons.append("mergeable signal missing (fail closed)")
if approval_at_current_head is None:
reasons.append("approval_at_current_head missing (fail closed)")
if branch_protection_requires_current_base is None:
reasons.append(
"branch_protection_requires_current_base missing (fail closed)"
)
if reasons:
result["recommended_next_action"] = ACTION_BLOCKED
return result
# Stale prepared verdict / approval across heads.
stale_prepared = bool(prepared_head and prepared_head != pr_head)
result["stale_prepared_verdict"] = stale_prepared
stale_approval = not approval_ok
result["stale_approval"] = stale_approval
result["approval_valid_for_merge"] = bool(approval_ok and not stale_prepared)
# ── Conflicts: author remediation in dedicated worktree ──────────────
if conflicts is True or mergeable is False:
if conflicts is True or mergeable is False:
# Distinguish pure non-mergeable without explicit conflict flag
# still routes author remediation (Gitea cannot auto-update).
reasons.append(
f"PR #{pr_number} has conflicts or is not mergeable at head "
f"{pr_head}; route author conflict remediation in the existing "
"issue/PR worktree under branches/ (never force-push or rebase)"
)
if stale_approval:
reasons.append(
"any approval at a former head is invalid; after remediation "
"require a fresh independent review at the new exact head"
)
result["recommended_next_action"] = ACTION_AUTHOR_CONFLICT_REMEDIATION
result["approval_valid_for_merge"] = False
return result
# ── Stale approval / prepared verdict at former head ─────────────────
if not approval_ok or stale_prepared:
if behind and behind > 0 and requires_current and mergeable is True:
# Must update first; update will also invalidate approval.
reasons.append(
f"PR is {behind} commit(s) behind live base and branch protection "
"requires current base; automatic merge-from-base is available"
)
reasons.append(
"approval is not valid at the current head (or prepared verdict "
"is head-scoped to a former SHA); after update require fresh review"
)
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
result["approval_valid_for_merge"] = False
return result
reasons.append(
"approval is not valid at the exact current PR head "
f"({pr_head}); never reuse a former-head approval for merge"
)
if stale_prepared:
reasons.append(
f"prepared verdict pinned to former head {prepared_head} cannot "
f"authorize review/merge at current head {pr_head}"
)
if active_reviewer_lease:
reasons.append(
"active reviewer lease must be released or superseded for the "
"new head before a fresh independent review"
)
if active_merger_lease:
reasons.append(
"active merger lease cannot cross heads; release or re-acquire "
"at the new exact head"
)
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
result["approval_valid_for_merge"] = False
return result
# ── Outdated + protection requires current base, auto-updatable ──────
if behind and behind > 0 and requires_current:
if mergeable is True and conflicts is not True:
reasons.append(
f"PR is {behind} commit(s) behind live base {base_head}; "
"branch protection requires the PR branch to contain current base; "
"Gitea can merge base into the PR branch without conflicts"
)
reasons.append(
"route author-only update_branch_by_merge; never rebase or force-push; "
"new head invalidates prior approval and requires fresh independent review"
)
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
# Approval today is at old head that will change — not mergeable yet
# for final merge until re-reviewed, but assess marks update path.
result["approval_valid_for_merge"] = False
result["stale_approval"] = True # will become stale after update
return result
reasons.append(
"update required by branch protection but automatic merge is not available"
)
result["recommended_next_action"] = ACTION_BLOCKED
return result
# ── Checks gate for merge_now ────────────────────────────────────────
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
if checks in ("pending", "running", "queued"):
reasons.append(f"required checks are not finished (status={checks})")
result["recommended_next_action"] = ACTION_BLOCKED
return result
if checks in ("failure", "failed", "error", "cancelled"):
reasons.append(f"required checks failed (status={checks})")
result["recommended_next_action"] = ACTION_BLOCKED
return result
# unknown — fail closed when checks_required
if checks == "unknown":
reasons.append("checks status unknown (fail closed)")
result["recommended_next_action"] = ACTION_BLOCKED
return result
# ── Ready to merge without update ────────────────────────────────────
# Includes: current with approval; outdated when update is NOT required.
if approval_ok and mergeable is True and conflicts is not True:
if behind and behind > 0 and not requires_current:
reasons.append(
f"PR is {behind} commit(s) behind live base but branch protection "
"does not require the latest base; preserve the approved head"
)
else:
reasons.append(
"PR has a valid approval at the exact current head, is conflict-free "
"and mergeable; do not update the branch unnecessarily"
)
reasons.append("route directly to the sanctioned merger workflow (merge_now)")
result["recommended_next_action"] = ACTION_MERGE_NOW
result["approval_valid_for_merge"] = True
return result
reasons.append("no sanctioned next action matched the observed PR state (fail closed)")
result["recommended_next_action"] = ACTION_BLOCKED
return result
def assess_update_pr_branch_preflight(
*,
role_kind: str | None,
expected_pr_head_sha: str | None,
live_pr_head_sha: str | None,
expected_base_head_sha: str | None,
live_base_head_sha: str | None,
has_conflicts: bool | None = None,
mergeable: bool | None = None,
style: str | None = UPDATE_STYLE_MERGE,
has_author_lock: bool | None = None,
worktree_path: str | None = None,
worktree_owns_source_branch: bool | None = None,
control_checkout_clean: bool | None = None,
master_parity_ok: bool | None = None,
force_push: bool = False,
rebase: bool = False,
) -> dict[str, Any]:
"""Author-only preflight for update-by-merge; fail closed on any race.
When conflicts exist, returns a structured author-remediation handoff and
sets ``mutation_allowed=False`` so no partial remote update is performed.
"""
reasons: list[str] = []
role = _normalize_role(role_kind)
exp_pr = _normalize_sha(expected_pr_head_sha)
live_pr = _normalize_sha(live_pr_head_sha)
exp_base = _normalize_sha(expected_base_head_sha)
live_base = _normalize_sha(live_base_head_sha)
style_norm = (style or "").strip().lower() or UPDATE_STYLE_MERGE
conflicts = has_conflicts
if conflicts is None and mergeable is False:
conflicts = True
result: dict[str, Any] = {
"mutation_allowed": False,
"performed": False,
"style": style_norm,
"role_kind": role,
"expected_pr_head_sha": exp_pr,
"live_pr_head_sha": live_pr,
"expected_base_head_sha": exp_base,
"live_base_head_sha": live_base,
"head_race": False,
"base_race": False,
"has_conflicts": conflicts,
"author_remediation_handoff": False,
"approval_invalidated_for_former_head": False,
"force_push": bool(force_push),
"rebase": bool(rebase),
"reasons": reasons,
"success": True,
}
# Role gate — author only.
if role not in _AUTHOR_UPDATE_ROLES:
reasons.append(
f"role '{role}' cannot update an author PR branch "
"(author-only; reviewer/merger denial)"
)
return result
if force_push:
reasons.append("force-push is forbidden for PR branch update (fail closed)")
return result
if rebase or style_norm in _FORBIDDEN_UPDATE_STYLES:
reasons.append(
f"update style '{style_norm}' forbidden; only style=merge is allowed "
"(never rebase)"
)
return result
if style_norm != UPDATE_STYLE_MERGE:
reasons.append(
f"unknown update style '{style_norm}'; expected '{UPDATE_STYLE_MERGE}'"
)
return result
if not exp_pr or not live_pr:
reasons.append(
"expected and live PR head SHAs must be full 40-char hex (fail closed)"
)
if not exp_base or not live_base:
reasons.append(
"expected and live base head SHAs must be full 40-char hex (fail closed)"
)
if reasons:
return result
if exp_pr != live_pr:
result["head_race"] = True
reasons.append(
f"PR head race: expected {exp_pr} but live is {live_pr} "
"(fail closed; no partial mutation)"
)
return result
if exp_base != live_base:
result["base_race"] = True
reasons.append(
f"base head race: expected {exp_base} but live is {live_base} "
"(fail closed; no partial mutation)"
)
return result
if has_author_lock is not True:
reasons.append(
"existing issue/PR lock required before update_branch_by_merge (fail closed)"
)
wt = (worktree_path or "").strip()
if not wt:
reasons.append("existing PR worktree path required under branches/ (fail closed)")
else:
norm = wt.replace("\\", "/")
if "/branches/" not in norm and not norm.startswith("branches/"):
reasons.append(
f"worktree_path '{wt}' must be under branches/ (fail closed)"
)
if worktree_owns_source_branch is False:
reasons.append(
"worktree does not own the PR source branch (fail closed)"
)
if control_checkout_clean is False:
reasons.append("control checkout is not clean (fail closed)")
if master_parity_ok is False:
reasons.append("runtime/master parity failed (fail closed)")
if conflicts is True:
result["author_remediation_handoff"] = True
reasons.append(
"conflicts present: return structured author-remediation handoff "
"without creating a partial remote update"
)
reasons.append(
"resolve conflicts explicitly in the existing dedicated worktree, "
"run focused and regression tests, commit/push via sanctioned author "
"workflow, then route the new exact head to independent review"
)
return result
if mergeable is False and conflicts is not False:
result["author_remediation_handoff"] = True
reasons.append(
"PR is not mergeable; refuse automatic update and hand off to "
"author conflict remediation (fail closed)"
)
return result
if reasons:
return result
result["mutation_allowed"] = True
reasons.append(
"preflight passed: author may merge live base into the PR branch via "
"native MCP update (style=merge only)"
)
return result
def assess_post_update_head_transition(
*,
former_pr_head_sha: str | None,
new_pr_head_sha: str | None,
former_approval_head_sha: str | None = None,
former_reviewer_lease_head_sha: str | None = None,
former_merger_lease_head_sha: str | None = None,
prepared_verdict_head_sha: str | None = None,
) -> dict[str, Any]:
"""After a successful update-by-merge, invalidate cross-head artifacts.
The new head never inherits approval, reviewer/merger leases, or prepared
verdicts from the former head.
"""
former = _normalize_sha(former_pr_head_sha)
new = _normalize_sha(new_pr_head_sha)
reasons: list[str] = []
result: dict[str, Any] = {
"former_pr_head_sha": former,
"new_pr_head_sha": new,
"head_changed": bool(former and new and former != new),
"approval_invalidated": False,
"reviewer_lease_superseded": False,
"merger_lease_superseded": False,
"prepared_verdict_invalidated": False,
"recommended_next_action": ACTION_BLOCKED,
"reasons": reasons,
"success": True,
}
if not former or not new:
reasons.append("former and new PR head SHAs required (fail closed)")
return result
if former == new:
reasons.append(
"PR head unchanged after update; unexpected for merge-from-base"
)
result["recommended_next_action"] = ACTION_BLOCKED
return result
result["head_changed"] = True
# Approval at former head is always void at new head.
former_approval = _normalize_sha(former_approval_head_sha) or former
if former_approval != new:
result["approval_invalidated"] = True
reasons.append(
f"approval for former head {former_approval} is invalid at new head "
f"{new}; never preserve approval across a head change"
)
rev_lease = _normalize_sha(former_reviewer_lease_head_sha)
if rev_lease and rev_lease != new:
result["reviewer_lease_superseded"] = True
reasons.append(
f"reviewer lease for head {rev_lease} cannot cross to {new}; "
"release or supersede before fresh review"
)
elif rev_lease is None:
# Unknown lease head still must not authorize at new head.
result["reviewer_lease_superseded"] = True
reasons.append(
"any reviewer lease scoped to the former head is superseded at the new head"
)
mer_lease = _normalize_sha(former_merger_lease_head_sha)
if mer_lease and mer_lease != new:
result["merger_lease_superseded"] = True
reasons.append(
f"merger lease for head {mer_lease} cannot cross to {new}"
)
else:
result["merger_lease_superseded"] = True
reasons.append(
"any merger lease scoped to the former head is superseded at the new head"
)
prepared = _normalize_sha(prepared_verdict_head_sha)
if prepared and prepared != new:
result["prepared_verdict_invalidated"] = True
reasons.append(
f"prepared verdict for head {prepared} cannot authorize the new head {new}"
)
elif prepared is None:
result["prepared_verdict_invalidated"] = True
reasons.append(
"prepared verdicts associated with the former head are invalidated"
)
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
reasons.append(
"route the new exact head to independent review "
f"({ACTION_FRESH_REVIEW_REQUIRED})"
)
return result
def assess_sequential_queue_step(
*,
just_merged: bool,
master_refreshed: bool,
next_pr_number: int | None = None,
) -> dict[str, Any]:
"""Controller sequential processing: reassess only after merge + master refresh."""
reasons: list[str] = []
if just_merged and not master_refreshed:
reasons.append(
"master must be refreshed after each merge before reassessing the next PR "
"(do not update every PR simultaneously)"
)
return {
"reassess_allowed": False,
"process_next": False,
"next_pr_number": next_pr_number,
"reasons": reasons,
"success": True,
}
if not just_merged:
reasons.append("no merge completed; sequential step does not advance")
return {
"reassess_allowed": False,
"process_next": False,
"next_pr_number": next_pr_number,
"reasons": reasons,
"success": True,
}
if next_pr_number:
reasons.append(
"merge completed and live master refreshed; reassess the next PR "
f"#{next_pr_number}"
)
else:
reasons.append(
"merge completed and live master refreshed; reassess the next PR"
)
return {
"reassess_allowed": True,
"process_next": True,
"next_pr_number": next_pr_number,
"post_merge_cleanup_handoff": True,
"reasons": reasons,
"success": True,
}
def merge_approval_usable_at_head(
*,
current_head_sha: str | None,
approved_head_sha: str | None,
) -> dict[str, Any]:
"""Stale approval cannot authorize merge (head-scoped only)."""
current = _normalize_sha(current_head_sha)
approved = _normalize_sha(approved_head_sha)
ok = bool(current and approved and current == approved)
reasons: list[str] = []
if not ok:
reasons.append(
f"stale approval: approved head '{approved or '(none)'}' does not "
f"match current head '{current or '(none)'}'; cannot authorize merge"
)
return {
"approval_usable": ok,
"current_head_sha": current,
"approved_head_sha": approved,
"reasons": reasons,
}
def lease_usable_at_head(
*,
lease_kind: str,
lease_head_sha: str | None,
current_head_sha: str | None,
) -> dict[str, Any]:
"""Reviewer/merger leases cannot cross heads."""
current = _normalize_sha(current_head_sha)
lease_head = _normalize_sha(lease_head_sha)
kind = (lease_kind or "").strip().lower() or "unknown"
ok = bool(current and lease_head and current == lease_head)
reasons: list[str] = []
if not ok:
reasons.append(
f"stale {kind} lease: lease head '{lease_head or '(none)'}' cannot "
f"authorize work at current head '{current or '(none)'}'"
)
return {
"lease_usable": ok,
"lease_kind": kind,
"lease_head_sha": lease_head,
"current_head_sha": current,
"reasons": reasons,
}
@@ -33,22 +33,34 @@ Steps:
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
2. Verify authenticated identity + active profile.
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
4. Capability evidence (#179): cite the exact gitea_resolve_task_capability
4. **PR sync assess (required):** call `gitea_assess_pr_sync_status` with
explicit `remote`/`org`/`repo`. Route on `recommended_next_action`:
- `merge_now` → continue merger path (do **not** update the branch)
- `update_branch_by_merge` → STOP; hand off to **author** for
`gitea_update_pr_branch_by_merge` (pins expected PR head + base head)
- `author_conflict_remediation` → STOP; author worktree conflict fix
- `fresh_review_required` → STOP; independent re-review at current head
- `blocked` → STOP and diagnose
Never merge on a former-head approval after update/remediation.
5. Capability evidence (#179): cite the exact gitea_resolve_task_capability
output (or runtime context) proving merge_pr is allowed — a bare
"capability checks passed" claim is downgraded.
5. Final live-state recheck (#179), immediately before the merge mutation —
6. Final live-state recheck (#179), immediately before the merge mutation —
re-read the live PR and prove:
- PR still open
- live head SHA still equals the pinned/reviewed head SHA
- base branch unchanged
- no undismissed REQUEST_CHANGES / blocking review state remains
If any recheck fails → STOP, re-pin, re-validate.
6. If any gate fails → STOP and report.
7. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
7. If any gate fails → STOP and report.
8. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
pinning the reviewed head SHA (expected_head_sha) and, where supported,
the changed-file set.
8. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
9. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
10. **Sequential queue:** after merge, refresh live `master`, run post-merge
cleanup handoff, then reassess the **next** PR (do not batch-update all
open PRs).
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
- Record merge mutations separately from cleanup mutations in the controller handoff.
@@ -950,9 +950,53 @@ Final reports must state:
* final live head SHA before merge
* whether any push occurred during validation
## 26D. PR synchronization and conflict-remediation lifecycle
**Do not treat “approved” as the final readiness state.** For every approved
open PR, call `gitea_assess_pr_sync_status` (native MCP only) and route by
`recommended_next_action`:
| Action | Meaning | Next role / tools |
|--------|---------|-------------------|
| `merge_now` | Valid approval at exact current head; conflict-free; update not required; checks ok | Merger: sanctioned merge workflow only — **do not** update the branch |
| `update_branch_by_merge` | Behind live base; protection requires current base; Gitea can merge base without conflicts | **Author only:** `gitea_update_pr_branch_by_merge` with pinned `expected_pr_head_sha` + `expected_base_head_sha` |
| `author_conflict_remediation` | Conflicts / not auto-updatable | **Author only:** existing `branches/` worktree, issue lock, merge master, resolve, test, push; never force-push/rebase |
| `fresh_review_required` | Head changed after approval, or update/remediation produced a new head | Independent reviewer at the **new exact head**; old approvals/leases/verdicts are void |
| `blocked` | Other gate (checks, incomplete facts, closed PR, …) | Diagnose; do not merge or update |
### Hard rules
* Pin both expected PR head and expected base head for every update. Either
race → fail closed with no partial mutation.
* Never rebase or force-push author branches.
* Never update an author branch from a reviewer or merger profile.
* Never preserve approval, reviewer lease, merger lease, or prepared verdict
across a head change.
* Process PRs **sequentially**: synchronize/remediate one → review new head →
merge → refresh live `master` → reassess the next PR. Do not update every
open PR at once (each merge restales the rest).
* Conflict remediation only in the existing dedicated issue/PR worktree under
`branches/`. Do not delete that worktree until the updated PR is merged and
cleanup eligibility is proven.
* Post-merge: canonical cleanup handoff to reconciler (section 28).
### After `gitea_update_pr_branch_by_merge` succeeds
1. Treat former-head approval as invalidated.
2. Release/supersede obsolete reviewer and merger leases.
3. Route `recommended_next_action=fresh_review_required` at the new head.
4. Independent re-review, then merger lease/adopt + merge at the new head only.
All Gitea reads/mutations use native MCP. Never substitute direct API, curl,
tea/gh, Web UI mutation by an LLM, database changes, raw Git push, or token
access.
## 27. Merge rules
Before merge, rerun fresh live checks:
Before merge, call `gitea_assess_pr_sync_status` when the PR is approved/open
and may be behind base. Only proceed with merge when
`recommended_next_action` is `merge_now` and approval remains at the current
head. Then rerun fresh live checks:
* whoami
* active profile/runtime
+30
View File
@@ -73,6 +73,24 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.branch.push",
"role": "author",
},
# PR synchronization lifecycle: assess is read-only (any role with gitea.read);
# update-by-merge is author-only and mutates the PR head via Gitea API.
"assess_pr_sync_status": {
"permission": "gitea.read",
"role": "author",
},
"gitea_assess_pr_sync_status": {
"permission": "gitea.read",
"role": "author",
},
"update_pr_branch_by_merge": {
"permission": "gitea.branch.push",
"role": "author",
},
"gitea_update_pr_branch_by_merge": {
"permission": "gitea.branch.push",
"role": "author",
},
"review_pr": {
"permission": "gitea.pr.review",
"role": "reviewer",
@@ -107,6 +125,18 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.comment",
"role": "merger",
},
"gitea_adopt_merger_pr_lease": {
"permission": "gitea.pr.comment",
"role": "merger",
},
"acquire_merger_pr_lease": {
"permission": "gitea.pr.comment",
"role": "merger",
},
"gitea_acquire_merger_pr_lease": {
"permission": "gitea.pr.comment",
"role": "merger",
},
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
# Apply path posts lease release + audit comments (gitea.pr.comment).
"cleanup_obsolete_reviewer_comment_lease": {
@@ -0,0 +1,430 @@
"""#685: gitea_resolve_task_capability must be side-effect free.
Stale-runtime detection remains fail-closed, but the resolver must never:
* touch mcp_config.json (or any MCP client config)
* spawn recovery threads
* call os._exit / terminate the serving process
* claim that an auto-restart was triggered
Recovery is owned by the IDE/client reconnect path only.
"""
from __future__ import annotations
import os
import tempfile
import threading
import unittest
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import sys
ROOT = str(Path(__file__).resolve().parent.parent)
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
import gitea_mcp_server as mcp_server
ROLE_PROFILES = (
("create_issue", "prgs-author", "author"),
("review_pr", "prgs-reviewer", "reviewer"),
("merge_pr", "prgs-merger", "merger"),
("reconciliation_cleanup", "prgs-reconciler", "reconciler"),
)
def _stale_self_ps_mocks(profile: str = "prgs-author"):
"""Build subprocess mocks: self PID is stale vs code mtime."""
mock_getpid = MagicMock(return_value=12345)
mock_exists = MagicMock(return_value=True)
code_time = datetime(2026, 7, 8, 14, 0, 0)
mock_getmtime = MagicMock(return_value=code_time.timestamp())
ps_output = (
" PID LSTART COMMAND\n"
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
)
mock_run_ps = MagicMock()
mock_run_ps.stdout = ps_output
mock_run_env = MagicMock()
mock_run_env.stdout = f"GITEA_MCP_PROFILE={profile}"
mock_run_git = MagicMock()
mock_run_git.stdout = "SAME"
def side_effect(args, **kwargs):
if args[0] == "ps" and "eww" in args:
return mock_run_env
if args[0] == "ps":
return mock_run_ps
if args[0] == "git":
return mock_run_git
raise ValueError(f"Unexpected subprocess args: {args}")
mock_run = MagicMock(side_effect=side_effect)
return mock_getpid, mock_exists, mock_getmtime, mock_run
class TestIssue685DiagnosticsNoSideEffects(unittest.TestCase):
def setUp(self):
mcp_server._process_boot_head_sha = None
def tearDown(self):
mcp_server._process_boot_head_sha = None
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
@patch("subprocess.run")
@patch("os.path.getmtime")
@patch("os.path.exists")
@patch("os.getpid")
@patch("os.utime")
@patch("threading.Thread")
@patch("os._exit")
def test_stale_self_does_not_touch_config_or_exit(
self,
mock_exit,
mock_thread,
mock_utime,
mock_getpid,
mock_exists,
mock_getmtime,
mock_run,
):
mock_getpid.return_value = 12345
mock_exists.return_value = True
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
before_threads = threading.active_count()
reasons = mcp_server._check_mcp_runtimes_diagnostics(
"create_issue", ["prgs-author"]
)
after_threads = threading.active_count()
self.assertTrue(
any("stale-runtime" in r and "active Gitea MCP server process is stale" in r
for r in reasons),
reasons,
)
# Must not claim auto-restart / config touch
blob = " ".join(reasons)
self.assertNotIn("Auto-restart has been triggered", blob)
self.assertNotIn("touched mcp_config", blob)
self.assertNotIn("will cleanly exit", blob)
mock_utime.assert_not_called()
mock_thread.assert_not_called()
mock_exit.assert_not_called()
self.assertEqual(before_threads, after_threads)
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
@patch("subprocess.run")
@patch("os.path.getmtime")
@patch("os.path.exists")
@patch("os.getpid")
@patch("os.utime")
def test_repeated_stale_calls_do_not_trigger_restart_loop(
self, mock_utime, mock_getpid, mock_exists, mock_getmtime, mock_run
):
mock_getpid.return_value = 12345
mock_exists.return_value = True
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
for _ in range(5):
reasons = mcp_server._check_mcp_runtimes_diagnostics(
"create_issue", ["prgs-author"]
)
self.assertTrue(any("stale-runtime" in r for r in reasons))
mock_utime.assert_not_called()
def test_trigger_mcp_auto_restart_removed(self):
"""#685 AC: auto-restart helper is removed (unreachable from read-only)."""
self.assertFalse(hasattr(mcp_server, "_trigger_mcp_auto_restart"))
self.assertFalse(hasattr(mcp_server, "_restart_triggered"))
class TestIssue685ResolverTypedBlocker(unittest.TestCase):
def setUp(self):
mcp_server._process_boot_head_sha = None
def tearDown(self):
mcp_server._process_boot_head_sha = None
if hasattr(mcp_server, "capability_stop_terminal"):
mcp_server.capability_stop_terminal.clear()
def _resolve_with_stale_runtime(self, task: str, profile_name: str, role: str):
allowed = [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.issue.close",
"gitea.branch.create",
"gitea.branch.push",
"gitea.branch.delete",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.pr.review",
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.merge",
"gitea.pr.close",
"gitea.repo.commit",
]
profile = {
"profile_name": profile_name,
"role": role,
"allowed_operations": allowed,
"forbidden_operations": [],
}
config = {
"profiles": {
profile_name: {
"role": role,
"allowed_operations": allowed,
"forbidden_operations": [],
}
}
}
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
profile_name
)
with patch.dict(
os.environ,
{
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
"GITEA_MCP_PROFILE": profile_name,
},
clear=False,
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
mcp_server.gitea_config, "load_config", return_value=config
), patch.object(
mcp_server, "_authenticated_username", return_value="test-user"
), patch.object(
mcp_server, "_ensure_matching_profile", return_value=None
), patch.object(
mcp_server, "record_preflight_check", return_value=None
), patch.object(
mcp_server, "record_mutation_authority", return_value=None
), patch.object(
mcp_server, "init_review_decision_lock", return_value=None
), patch(
"subprocess.run", mock_run
), patch(
"os.path.getmtime", mock_getmtime
), patch(
"os.path.exists", mock_exists
), patch(
"os.getpid", mock_getpid
), patch(
"os.utime"
) as mock_utime, patch(
"threading.Thread"
) as mock_thread, patch(
"os._exit"
) as mock_exit:
result = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
return result, mock_utime, mock_thread, mock_exit
def test_stale_returns_typed_blocker_fields(self):
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
"create_issue", "prgs-author", "author"
)
self.assertTrue(result.get("restart_required"), result)
self.assertTrue(result.get("stop_required"), result)
self.assertEqual(result.get("blocker_kind"), "runtime_reconnect_required")
self.assertIs(result.get("mutation_performed"), False)
action = result.get("exact_safe_next_action") or ""
self.assertIn("reconnect", action.lower())
self.assertNotIn("None; ready for operations", action)
reason = result.get("reason") or ""
self.assertIn("stale-runtime", reason)
self.assertNotIn("Auto-restart has been triggered", reason)
mock_utime.assert_not_called()
mock_thread.assert_not_called()
mock_exit.assert_not_called()
def test_all_four_role_profiles_get_same_side_effect_free_contract(self):
for task, profile, role in ROLE_PROFILES:
with self.subTest(task=task, profile=profile):
# Skip tasks that may be unknown on this branch
try:
import task_capability_map as tcm
tcm.required_permission(task)
except Exception:
self.skipTest(f"task {task} not in capability map")
result, mock_utime, mock_thread, mock_exit = (
self._resolve_with_stale_runtime(task, profile, role)
)
self.assertTrue(
result.get("restart_required") or result.get("stop_required"),
result,
)
self.assertEqual(
result.get("blocker_kind"), "runtime_reconnect_required", result
)
self.assertIs(result.get("mutation_performed"), False, result)
mock_utime.assert_not_called()
mock_thread.assert_not_called()
mock_exit.assert_not_called()
def test_config_mtime_and_contents_unchanged(self):
with tempfile.TemporaryDirectory() as tmp:
cfg = os.path.join(tmp, "mcp_config.json")
original = '{"servers": {"gitea-author": {}}}'
with open(cfg, "w", encoding="utf-8") as fh:
fh.write(original)
mtime_before = os.path.getmtime(cfg)
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
"create_issue", "prgs-author", "author"
)
# Force-path also must not use real utime when diagnostics runs
with open(cfg, encoding="utf-8") as fh:
after = fh.read()
self.assertEqual(after, original)
self.assertEqual(os.path.getmtime(cfg), mtime_before)
mock_utime.assert_not_called()
self.assertTrue(result.get("restart_required"), result)
class TestIssue685MutationGatesStillFailClosed(unittest.TestCase):
def test_parity_stale_still_reports_restart_required(self):
"""Mutation-facing parity gate remains fail-closed when heads differ."""
import master_parity_gate as mpg
out = mpg.assess_master_parity(
{"startup_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
)
self.assertFalse(out.get("in_parity"))
self.assertTrue(out.get("restart_required") or out.get("stale"))
class TestIssue685DocstringReadOnlyContract(unittest.TestCase):
def test_resolve_docstring_declares_side_effect_free(self):
doc = mcp_server.gitea_resolve_task_capability.__doc__ or ""
lower = doc.lower()
self.assertTrue(
"side-effect" in lower or "read-only" in lower or "does not mutate" in lower,
doc,
)
self.assertNotIn("auto-restart", lower)
self.assertNotIn("os._exit", lower)
class TestIssue685MergeCoexistenceWithMasterAnnotations(unittest.TestCase):
"""After merging master: #685 reconnect blocker + #702 report-only binding."""
def setUp(self):
mcp_server._process_boot_head_sha = None
def tearDown(self):
mcp_server._process_boot_head_sha = None
if hasattr(mcp_server, "capability_stop_terminal"):
mcp_server.capability_stop_terminal.clear()
def test_resolve_uses_report_only_stale_binding_assessment(self):
"""Capability resolve must call stale-binding assess with auto_recover=False."""
allowed = [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.branch.push",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.pr.review",
"gitea.pr.merge",
"gitea.repo.commit",
]
profile = {
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": allowed,
"forbidden_operations": [],
}
config = {
"profiles": {
"prgs-author": {
"role": "author",
"allowed_operations": allowed,
"forbidden_operations": [],
}
}
}
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
"prgs-author"
)
fake_binding = {
"classification": "missing_path",
"active_worktree": "/tmp/gone",
"reasons": ["path missing"],
}
with patch.dict(
os.environ,
{
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
"GITEA_MCP_PROFILE": "prgs-author",
},
clear=False,
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
mcp_server.gitea_config, "load_config", return_value=config
), patch.object(
mcp_server, "_authenticated_username", return_value="test-user"
), patch.object(
mcp_server, "_ensure_matching_profile", return_value=None
), patch.object(
mcp_server, "record_preflight_check", return_value=None
), patch.object(
mcp_server, "record_mutation_authority", return_value=None
), patch.object(
mcp_server, "init_review_decision_lock", return_value=None
), patch.object(
mcp_server,
"_assess_stale_active_binding",
return_value=fake_binding,
) as mock_assess, patch(
"subprocess.run", mock_run
), patch(
"os.path.getmtime", mock_getmtime
), patch(
"os.path.exists", mock_exists
), patch(
"os.getpid", mock_getpid
), patch(
"os.utime"
) as mock_utime, patch(
"threading.Thread"
) as mock_thread, patch(
"os._exit"
) as mock_exit:
result = mcp_server.gitea_resolve_task_capability(
task="create_issue", remote="prgs"
)
mock_assess.assert_called()
# Every call must be report-only (no env clear / session-state write).
for call in mock_assess.call_args_list:
self.assertFalse(call.kwargs.get("auto_recover", True))
self.assertEqual(
result.get("blocker_kind"), "runtime_reconnect_required", result
)
self.assertEqual(result.get("stale_binding_recovery"), fake_binding, result)
self.assertIs(result.get("mutation_performed"), False, result)
mock_utime.assert_not_called()
mock_thread.assert_not_called()
mock_exit.assert_not_called()
if __name__ == "__main__":
unittest.main()
+7 -15
View File
@@ -111,21 +111,13 @@ class TestMcpStaleRuntime(unittest.TestCase):
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
@patch("threading.Thread")
@patch("os.utime")
@patch("os.path.exists")
@patch.dict("os.environ", {"MCP_CONFIG_PATH": "/tmp/mcp_config.json"})
def test_auto_restart_trigger_touches_and_spawns(self, mock_exists, mock_utime, mock_thread):
mock_exists.return_value = True
gitea_mcp_server._restart_triggered = False
# Ensure we are not skipped in test mode for testing purposes
with patch("gitea_mcp_server._preflight_in_test_mode", return_value=False):
gitea_mcp_server._trigger_mcp_auto_restart()
mock_utime.assert_called_once_with("/tmp/mcp_config.json", None)
mock_thread.assert_called_once()
self.assertTrue(gitea_mcp_server._restart_triggered)
def test_auto_restart_helper_removed_from_read_only_path(self):
"""#685: config-touch / os._exit self-recovery is no longer on the server."""
self.assertFalse(
hasattr(gitea_mcp_server, "_trigger_mcp_auto_restart"),
"_trigger_mcp_auto_restart must not remain (side-effect-free resolver)",
)
self.assertFalse(hasattr(gitea_mcp_server, "_restart_triggered"))
if __name__ == "__main__":
+466
View File
@@ -0,0 +1,466 @@
"""Merger lease acquisition + capability resolution tests (#718, #723)."""
from __future__ import annotations
import os
import sys
import unittest
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_mcp_server as mcp_server
import reviewer_pr_lease as leases
import task_capability_map as tcm
HEAD = "a" * 40
LIVE_HEAD = HEAD
class TestMergerLeaseAcquireTool(unittest.TestCase):
def setUp(self):
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "merger"
mcp_server._preflight_resolved_task = "acquire_merger_pr_lease"
leases.clear_session_lease()
def tearDown(self):
leases.clear_session_lease()
def _merger_profile(self, **extra):
p = {
"username": "merger-user",
"profile_name": "prgs-merger",
"role": "merger",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.merge",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.review",
"gitea.pr.request_changes",
],
}
p.update(extra)
return p
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_lease_success(
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
):
mock_profile.return_value = self._merger_profile()
mock_api.side_effect = [
{"state": "open", "head": {"sha": LIVE_HEAD}},
{"id": 456},
]
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
session_id="merger-session",
candidate_head=HEAD,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(res["success"], res)
self.assertTrue(res["acquired"])
self.assertEqual(res["comment_id"], 456)
self.assertEqual(res["session_id"], "merger-session")
self.assertEqual(res.get("repo"), "Scaled-Tech-Consulting/Gitea-Tools")
body = mock_api.call_args_list[-1][0][3]["body"]
self.assertIn("reviewer_identity: merger-user", body)
self.assertIn("profile: prgs-merger", body)
session_lease = leases._SESSION_LEASE
self.assertIsNotNone(session_lease)
provenance = session_lease.get("lease_provenance") or {}
self.assertEqual(provenance.get("source"), "gitea_acquire_merger_pr_lease")
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments")
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_lease_fails_if_active_exists(
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
):
mock_profile.return_value = self._merger_profile()
active_body = leases.format_lease_body(
repo="Scaled-Tech-Consulting/Gitea-Tools",
pr_number=718,
issue_number=None,
reviewer_identity="other-user",
profile="prgs-reviewer",
session_id="other-session",
worktree="branches/review-1",
phase="claimed",
candidate_head=HEAD,
target_branch="master",
target_branch_sha="b" * 40,
last_activity=datetime.now(timezone.utc),
)
_comments.return_value = [
{"id": 1, "body": active_body, "user": {"login": "other-user"}}
]
mock_api.return_value = {"state": "open", "head": {"sha": LIVE_HEAD}}
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
session_id="merger-session",
candidate_head=HEAD,
)
self.assertFalse(res["success"])
self.assertFalse(res["acquired"])
self.assertIn("already has active reviewer lease", " ".join(res["reasons"]))
self.assertEqual(
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
)
self.assertIsNone(leases._SESSION_LEASE)
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_lease_fails_workspace_binding(self, _verify, mock_profile):
mock_profile.return_value = self._merger_profile()
_verify.side_effect = RuntimeError("Branches-only mutation guard")
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
session_id="merger-session",
candidate_head=HEAD,
)
self.assertFalse(res["success"])
self.assertIn("Branches-only mutation guard", res["reasons"][0])
self.assertIsNone(leases._SESSION_LEASE)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_forwards_explicit_org_repo_to_workspace_verify(
self, mock_verify, _comments, mock_profile, _resolve, _auth, mock_api
):
"""Explicit org/repo must reach anti-stomp (prgs bare default is Timesheet)."""
mock_profile.return_value = self._merger_profile()
mock_api.side_effect = [
{"state": "open", "head": {"sha": LIVE_HEAD}},
{"id": 789},
]
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
session_id="merger-session",
candidate_head=HEAD,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(res["success"], res)
kwargs = mock_verify.call_args.kwargs
self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting")
self.assertEqual(kwargs.get("repo"), "Gitea-Tools")
self.assertEqual(kwargs.get("task"), "acquire_merger_pr_lease")
@patch("gitea_mcp_server.get_profile")
def test_acquire_merger_requires_candidate_head(self, mock_profile):
mock_profile.return_value = self._merger_profile()
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
candidate_head=None,
)
self.assertFalse(res["success"])
self.assertTrue(any("candidate_head is required" in r for r in res["reasons"]))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_head_mismatch(
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
):
mock_profile.return_value = self._merger_profile()
mock_api.return_value = {"state": "open", "head": {"sha": "b" * 40}}
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
candidate_head=HEAD,
)
self.assertFalse(res["success"])
self.assertTrue(any("does not match live PR head" in r for r in res["reasons"]))
self.assertIsNone(leases._SESSION_LEASE)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._auth", return_value="token pass")
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
@patch("gitea_mcp_server.get_profile")
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
@patch("gitea_mcp_server._verify_role_mutation_workspace")
def test_acquire_merger_fails_closed_when_live_head_unresolvable(
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
):
"""Empty/missing live head must not silently proceed past exact-head gate."""
mock_profile.return_value = self._merger_profile()
# PR payload present but head.sha missing / empty / non-dict.
mock_api.return_value = {"state": "open", "head": {}}
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
session_id="merger-session",
candidate_head=HEAD,
)
self.assertFalse(res["success"], res)
self.assertFalse(res.get("acquired"), res)
self.assertTrue(
any("live PR head could not be resolved" in r for r in res["reasons"]),
res,
)
self.assertIsNone(res.get("live_head"))
self.assertEqual(res.get("candidate_head"), HEAD)
self.assertEqual(
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
)
self.assertIsNone(leases._SESSION_LEASE)
# Completely empty GET /pulls body also fails closed.
mock_api.return_value = {}
res2 = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
candidate_head=HEAD,
)
self.assertFalse(res2["success"], res2)
self.assertTrue(
any("live PR head could not be resolved" in r for r in res2["reasons"]),
res2,
)
self.assertIsNone(leases._SESSION_LEASE)
@patch("gitea_mcp_server._profile_operation_gate")
def test_author_denied_merge_permission(self, mock_gate):
"""Author profile lacks gitea.pr.merge → fail closed before mutation."""
def gate(op):
if op == "gitea.pr.merge":
return [f"profile lacks {op}"]
return None
mock_gate.side_effect = gate
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
candidate_head=HEAD,
)
self.assertFalse(res["success"])
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
@patch("gitea_mcp_server._profile_operation_gate")
def test_reviewer_denied_merge_permission(self, mock_gate):
def gate(op):
if op == "gitea.pr.merge":
return [f"profile lacks {op}"]
return None
mock_gate.side_effect = gate
res = mcp_server.gitea_acquire_merger_pr_lease(
pr_number=718,
worktree="branches/issue-718",
candidate_head=HEAD,
)
self.assertFalse(res["success"])
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
class TestCapabilityMapLeaseTasks(unittest.TestCase):
def test_merger_acquire_map_entries(self):
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
self.assertEqual(tcm.required_role(task), "merger")
def test_reviewer_acquire_map_entries(self):
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
self.assertEqual(tcm.required_role(task), "reviewer")
def test_adopt_merger_aliases(self):
for task in ("adopt_merger_pr_lease", "gitea_adopt_merger_pr_lease"):
self.assertEqual(tcm.required_role(task), "merger")
class TestResolveTaskCapabilityLeaseAndUnknown(unittest.TestCase):
def setUp(self):
mcp_server._process_boot_head_sha = None
if hasattr(mcp_server, "capability_stop_terminal"):
mcp_server.capability_stop_terminal.clear()
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
@patch.object(mcp_server, "record_preflight_check", return_value=None)
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
@patch.object(mcp_server, "get_profile")
@patch.object(mcp_server.gitea_config, "load_config")
def test_resolve_acquire_reviewer_authorized(
self, mock_cfg, mock_profile, *_mocks
):
mock_profile.return_value = {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": ["gitea.pr.comment", "gitea.read", "gitea.pr.review"],
"forbidden_operations": [],
}
mock_cfg.return_value = {
"profiles": {
"prgs-reviewer": {
"role": "reviewer",
"allowed_operations": [
"gitea.pr.comment",
"gitea.read",
"gitea.pr.review",
],
"forbidden_operations": [],
}
}
}
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-reviewer"}, clear=False):
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
self.assertEqual(res["required_role_kind"], "reviewer", res)
self.assertEqual(
res["required_operation_permission"], "gitea.pr.comment", res
)
self.assertTrue(res.get("allowed_in_current_session"), res)
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
@patch.object(mcp_server, "record_preflight_check", return_value=None)
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
@patch.object(mcp_server, "_authenticated_username", return_value="jcwalker3")
@patch.object(mcp_server, "get_profile")
@patch.object(mcp_server.gitea_config, "load_config")
def test_resolve_acquire_reviewer_author_denied(
self, mock_cfg, mock_profile, *_mocks
):
mock_profile.return_value = {
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": ["gitea.pr.comment", "gitea.branch.push", "gitea.read"],
"forbidden_operations": [],
}
mock_cfg.return_value = {
"profiles": {
"prgs-author": {
"role": "author",
"allowed_operations": [
"gitea.pr.comment",
"gitea.branch.push",
"gitea.read",
],
"forbidden_operations": [],
},
"prgs-reviewer": {
"role": "reviewer",
"allowed_operations": ["gitea.pr.comment", "gitea.pr.review"],
"forbidden_operations": [],
},
}
}
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False):
res = mcp_server.gitea_resolve_task_capability(
task="acquire_reviewer_pr_lease", remote="prgs"
)
self.assertFalse(res.get("allowed_in_current_session"), res)
self.assertEqual(res["required_role_kind"], "reviewer")
guidance = " ".join(res.get("task_role_guidance") or [])
self.assertTrue(
"cannot perform reviewer" in guidance.lower()
or "reviewer" in guidance.lower()
or res.get("stop_required"),
res,
)
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
@patch.object(mcp_server, "record_preflight_check", return_value=None)
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
@patch.object(mcp_server, "get_profile")
def test_resolve_unknown_task_structured_no_raise(self, mock_profile, *_mocks):
mock_profile.return_value = {
"profile_name": "prgs-reviewer",
"role": "reviewer",
"allowed_operations": ["gitea.pr.comment"],
"forbidden_operations": [],
}
# Must not raise ValueError into the tool boundary.
res = mcp_server.gitea_resolve_task_capability(
task="some_made_up_task", remote="prgs"
)
self.assertIsInstance(res, dict)
self.assertEqual(res.get("reason_code"), "unknown_task", res)
self.assertFalse(res.get("allowed_in_current_session"))
self.assertTrue(res.get("stop_required"))
self.assertIs(res.get("mutation_performed"), False)
self.assertNotIn("token", str(res).lower())
self.assertNotIn("password", str(res).lower())
guidance = " ".join(res.get("task_role_guidance") or [])
self.assertIn("Unknown task/action", guidance)
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
@patch.object(mcp_server, "record_preflight_check", return_value=None)
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
@patch.object(mcp_server, "get_profile")
@patch.object(mcp_server.gitea_config, "load_config")
def test_resolve_merger_acquire_aliases(
self, mock_cfg, mock_profile, *_mocks
):
mock_profile.return_value = {
"profile_name": "prgs-merger",
"role": "merger",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.merge",
],
"forbidden_operations": [],
}
mock_cfg.return_value = {
"profiles": {
"prgs-merger": {
"role": "merger",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.merge",
],
"forbidden_operations": [],
}
}
}
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-merger"}, clear=False):
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
self.assertEqual(res["required_role_kind"], "merger", res)
self.assertEqual(
res["required_operation_permission"], "gitea.pr.comment", res
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,196 @@
"""#727 / PR #728: author ownership when tracking issue number != PR number.
Regression for REQUEST_CHANGES: gitea_update_pr_branch_by_merge must not require
issue_number == pr_number. Ownership is proven via linked issue + lock/branch
context and fails closed for unrelated issues.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import issue_lock_store
import gitea_mcp_server as mcp
def _live_lock(
*,
issue_number: int,
branch_name: str = "feat/issue-727-pr-sync-status",
worktree_path: str = "/tmp/branches/issue-727-pr-sync-status",
remote: str = "prgs",
org: str = "Scaled-Tech-Consulting",
repo: str = "Gitea-Tools",
) -> dict:
now = datetime.now(timezone.utc)
return {
"issue_number": issue_number,
"branch_name": branch_name,
"worktree_path": worktree_path,
"remote": remote,
"org": org,
"repo": repo,
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"acquired_at": now.isoformat(),
"expires_at": (now + timedelta(hours=2)).isoformat(),
"owner_pid": os.getpid(),
"status": "active",
}
class TestAuthorOwnershipIssuePrMismatch(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="gitea-issue-locks-")
self.lock_dir = self._tmp.name
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir
def tearDown(self):
# Clear session pointer for this process.
try:
ptr = issue_lock_store.session_pointer_path(self.lock_dir)
if os.path.isfile(ptr):
os.unlink(ptr)
except Exception:
pass
os.environ.pop("GITEA_ISSUE_LOCK_DIR", None)
self._tmp.cleanup()
def test_issue_727_pr_728_session_lock_accepted(self):
"""Tracking issue #727 lock is valid ownership for PR #728."""
lock = _live_lock(issue_number=727)
issue_lock_store.bind_session_lock(lock)
result = mcp._prove_author_ownership_for_pr(
pr_number=728,
pr_title="feat: pr sync",
pr_body="Closes #727\n\nNative PR sync lifecycle.",
source_branch="feat/issue-727-pr-sync-status",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path=lock["worktree_path"],
)
self.assertTrue(result["proven"], result)
self.assertTrue(result["has_author_lock"])
self.assertEqual(result["matched_issue"], 727)
self.assertEqual(result["matched_via"], "session_lock")
self.assertIn(727, result["linked_issues"])
def test_issue_727_pr_728_durable_lock_accepted(self):
lock = _live_lock(issue_number=727)
path = issue_lock_store.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=727,
lock_dir=self.lock_dir,
)
issue_lock_store.save_lock_file(path, lock)
result = mcp._prove_author_ownership_for_pr(
pr_number=728,
pr_title="feat: pr sync",
pr_body="Fixes #727",
source_branch="feat/issue-727-pr-sync-status",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(result["proven"], result)
self.assertEqual(result["matched_issue"], 727)
self.assertEqual(result["matched_via"], "durable_lock")
def test_legacy_same_number_still_accepted(self):
lock = _live_lock(
issue_number=100,
branch_name="fix/issue-100",
worktree_path="/tmp/branches/issue-100",
)
issue_lock_store.bind_session_lock(lock)
result = mcp._prove_author_ownership_for_pr(
pr_number=100,
pr_title="fix something",
pr_body="Closes #100",
source_branch="fix/issue-100",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path=lock["worktree_path"],
)
self.assertTrue(result["proven"], result)
self.assertEqual(result["matched_issue"], 100)
def test_unrelated_issue_lock_rejected(self):
"""Lock for issue #999 must not authorize PR #728 linked only to #727."""
lock = _live_lock(
issue_number=999,
branch_name="feat/issue-999",
worktree_path="/tmp/branches/issue-999",
)
issue_lock_store.bind_session_lock(lock)
path = issue_lock_store.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=999,
lock_dir=self.lock_dir,
)
issue_lock_store.save_lock_file(path, lock)
result = mcp._prove_author_ownership_for_pr(
pr_number=728,
pr_title="feat: pr sync",
pr_body="Closes #727",
source_branch="feat/issue-727-pr-sync-status",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path=lock["worktree_path"],
)
self.assertFalse(result["proven"], result)
self.assertFalse(result["has_author_lock"])
self.assertIsNone(result["matched_issue"])
self.assertTrue(result["reasons"])
def test_branch_mismatch_on_session_lock_fail_closed(self):
lock = _live_lock(
issue_number=727,
branch_name="feat/other-branch",
)
issue_lock_store.bind_session_lock(lock)
result = mcp._prove_author_ownership_for_pr(
pr_number=728,
pr_title="feat: pr sync",
pr_body="Closes #727",
source_branch="feat/issue-727-pr-sync-status",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path=lock["worktree_path"],
)
self.assertFalse(result["proven"], result)
self.assertTrue(any("branch" in r for r in result["reasons"]))
def test_no_lock_fail_closed(self):
result = mcp._prove_author_ownership_for_pr(
pr_number=728,
pr_title="feat: pr sync",
pr_body="Closes #727",
source_branch="feat/issue-727-pr-sync-status",
remote="prgs",
host=None,
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertFalse(result["proven"], result)
self.assertTrue(any("no live author issue lock" in r for r in result["reasons"]))
if __name__ == "__main__":
unittest.main()
+338
View File
@@ -0,0 +1,338 @@
"""Hermetic tests for PR synchronization / conflict-remediation lifecycle."""
from __future__ import annotations
import unittest
from pr_sync_status import (
ACTION_AUTHOR_CONFLICT_REMEDIATION,
ACTION_BLOCKED,
ACTION_FRESH_REVIEW_REQUIRED,
ACTION_MERGE_NOW,
ACTION_UPDATE_BRANCH_BY_MERGE,
assess_post_update_head_transition,
assess_pr_sync_status,
assess_sequential_queue_step,
assess_update_pr_branch_preflight,
lease_usable_at_head,
merge_approval_usable_at_head,
)
def _sha(prefix: str) -> str:
return (prefix + "0" * 40)[:40]
PR_HEAD = _sha("aaaaaaaa")
BASE_HEAD = _sha("bbbbbbbb")
NEW_HEAD = _sha("cccccccc")
OLD_HEAD = _sha("dddddddd")
def _base_kwargs(**overrides):
data = {
"host": "gitea.prgs.cc",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"pr_number": 100,
"pr_state": "open",
"source_branch": "fix/issue-100",
"pr_head_sha": PR_HEAD,
"base_head_sha": BASE_HEAD,
"commits_behind": 0,
"mergeable": True,
"has_conflicts": False,
"branch_protection_requires_current_base": False,
"approval_at_current_head": True,
"checks_status": "success",
"active_author_lock": True,
"active_reviewer_lease": False,
"active_merger_lease": False,
}
data.update(overrides)
return data
class TestAssessPrSyncStatus(unittest.TestCase):
def test_approved_current_mergeable_merge_now(self):
result = assess_pr_sync_status(**_base_kwargs())
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
self.assertTrue(result["approval_valid_for_merge"])
self.assertFalse(result["stale_approval"])
self.assertEqual(result["pr_head_sha"], PR_HEAD)
self.assertEqual(result["base_head_sha"], BASE_HEAD)
def test_approved_outdated_update_not_required_merge_now(self):
result = assess_pr_sync_status(
**_base_kwargs(
commits_behind=3,
branch_protection_requires_current_base=False,
)
)
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
self.assertTrue(result["approval_valid_for_merge"])
self.assertTrue(any("does not require" in r for r in result["reasons"]))
def test_outdated_update_required_no_conflict(self):
result = assess_pr_sync_status(
**_base_kwargs(
commits_behind=2,
branch_protection_requires_current_base=True,
mergeable=True,
has_conflicts=False,
)
)
self.assertEqual(
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
)
self.assertFalse(result["approval_valid_for_merge"])
self.assertTrue(any("update_branch_by_merge" in r for r in result["reasons"]))
def test_outdated_has_conflicts_author_remediation(self):
result = assess_pr_sync_status(
**_base_kwargs(
commits_behind=5,
branch_protection_requires_current_base=True,
mergeable=False,
has_conflicts=True,
)
)
self.assertEqual(
result["recommended_next_action"], ACTION_AUTHOR_CONFLICT_REMEDIATION
)
self.assertFalse(result["approval_valid_for_merge"])
def test_stale_approval_fresh_review_required(self):
result = assess_pr_sync_status(
**_base_kwargs(
approval_at_current_head=False,
commits_behind=0,
)
)
self.assertEqual(
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
)
self.assertTrue(result["stale_approval"])
self.assertFalse(result["approval_valid_for_merge"])
def test_stale_prepared_verdict_cannot_cross_heads(self):
result = assess_pr_sync_status(
**_base_kwargs(
approval_at_current_head=True,
prepared_verdict_head_sha=OLD_HEAD,
)
)
self.assertEqual(
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
)
self.assertTrue(result["stale_prepared_verdict"])
self.assertFalse(result["approval_valid_for_merge"])
def test_missing_heads_fail_closed(self):
result = assess_pr_sync_status(
**_base_kwargs(pr_head_sha="short", base_head_sha=None)
)
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
self.assertTrue(any("PR head" in r for r in result["reasons"]))
def test_closed_pr_blocked(self):
result = assess_pr_sync_status(**_base_kwargs(pr_state="closed"))
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
def test_failed_checks_block_merge_now(self):
result = assess_pr_sync_status(**_base_kwargs(checks_status="failure"))
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
def test_stale_approval_with_update_required_routes_update(self):
result = assess_pr_sync_status(
**_base_kwargs(
approval_at_current_head=False,
commits_behind=1,
branch_protection_requires_current_base=True,
mergeable=True,
has_conflicts=False,
)
)
self.assertEqual(
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
)
class TestUpdatePrBranchPreflight(unittest.TestCase):
def _ok_kwargs(self, **overrides):
data = {
"role_kind": "author",
"expected_pr_head_sha": PR_HEAD,
"live_pr_head_sha": PR_HEAD,
"expected_base_head_sha": BASE_HEAD,
"live_base_head_sha": BASE_HEAD,
"has_conflicts": False,
"mergeable": True,
"style": "merge",
"has_author_lock": True,
"worktree_path": "/Users/x/Development/Gitea-Tools/branches/issue-100",
"worktree_owns_source_branch": True,
"control_checkout_clean": True,
"master_parity_ok": True,
"force_push": False,
"rebase": False,
}
data.update(overrides)
return data
def test_author_allowed_when_preflight_clean(self):
result = assess_update_pr_branch_preflight(**self._ok_kwargs())
self.assertTrue(result["mutation_allowed"])
self.assertFalse(result["head_race"])
self.assertFalse(result["base_race"])
def test_head_race_fail_closed(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(live_pr_head_sha=NEW_HEAD)
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(result["head_race"])
self.assertTrue(any("PR head race" in r for r in result["reasons"]))
def test_base_race_fail_closed(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(live_base_head_sha=NEW_HEAD)
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(result["base_race"])
self.assertTrue(any("base head race" in r for r in result["reasons"]))
def test_conflicts_structured_handoff_no_mutation(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(has_conflicts=True, mergeable=False)
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(result["author_remediation_handoff"])
self.assertTrue(any("conflicts" in r.lower() for r in result["reasons"]))
def test_reviewer_denied(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(role_kind="reviewer")
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("author-only" in r for r in result["reasons"]))
def test_merger_denied(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(role_kind="merger")
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("author-only" in r for r in result["reasons"]))
def test_no_force_push(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(force_push=True)
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("force-push" in r for r in result["reasons"]))
def test_no_rebase(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(style="rebase", rebase=True)
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("rebase" in r for r in result["reasons"]))
def test_missing_lock_fail_closed(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(has_author_lock=False)
)
self.assertFalse(result["mutation_allowed"])
def test_worktree_not_under_branches(self):
result = assess_update_pr_branch_preflight(
**self._ok_kwargs(worktree_path="/tmp/not-a-branches-path")
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("branches/" in r for r in result["reasons"]))
class TestPostUpdateHeadTransition(unittest.TestCase):
def test_update_invalidates_approval_and_requires_fresh_review(self):
result = assess_post_update_head_transition(
former_pr_head_sha=PR_HEAD,
new_pr_head_sha=NEW_HEAD,
former_approval_head_sha=PR_HEAD,
former_reviewer_lease_head_sha=PR_HEAD,
former_merger_lease_head_sha=PR_HEAD,
prepared_verdict_head_sha=PR_HEAD,
)
self.assertTrue(result["head_changed"])
self.assertTrue(result["approval_invalidated"])
self.assertTrue(result["reviewer_lease_superseded"])
self.assertTrue(result["merger_lease_superseded"])
self.assertTrue(result["prepared_verdict_invalidated"])
self.assertEqual(
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
)
def test_same_head_unexpected(self):
result = assess_post_update_head_transition(
former_pr_head_sha=PR_HEAD,
new_pr_head_sha=PR_HEAD,
)
self.assertFalse(result["head_changed"])
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
class TestStaleArtifacts(unittest.TestCase):
def test_stale_approval_cannot_authorize_merge(self):
result = merge_approval_usable_at_head(
current_head_sha=NEW_HEAD,
approved_head_sha=PR_HEAD,
)
self.assertFalse(result["approval_usable"])
def test_fresh_approval_usable(self):
result = merge_approval_usable_at_head(
current_head_sha=NEW_HEAD,
approved_head_sha=NEW_HEAD,
)
self.assertTrue(result["approval_usable"])
def test_stale_reviewer_lease_cannot_cross_heads(self):
result = lease_usable_at_head(
lease_kind="reviewer",
lease_head_sha=PR_HEAD,
current_head_sha=NEW_HEAD,
)
self.assertFalse(result["lease_usable"])
def test_stale_merger_lease_cannot_cross_heads(self):
result = lease_usable_at_head(
lease_kind="merger",
lease_head_sha=PR_HEAD,
current_head_sha=NEW_HEAD,
)
self.assertFalse(result["lease_usable"])
class TestSequentialQueue(unittest.TestCase):
def test_reassess_after_merge_and_master_refresh(self):
result = assess_sequential_queue_step(
just_merged=True,
master_refreshed=True,
next_pr_number=101,
)
self.assertTrue(result["reassess_allowed"])
self.assertTrue(result["process_next"])
self.assertTrue(result["post_merge_cleanup_handoff"])
self.assertEqual(result["next_pr_number"], 101)
def test_block_reassess_without_master_refresh(self):
result = assess_sequential_queue_step(
just_merged=True,
master_refreshed=False,
next_pr_number=101,
)
self.assertFalse(result["reassess_allowed"])
self.assertTrue(any("master must be refreshed" in r for r in result["reasons"]))
if __name__ == "__main__":
unittest.main()
+14 -4
View File
@@ -231,9 +231,16 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
def test_resolve_unknown_task_fails_closed(self):
# #723: unknown tasks return structured unknown_task (no ValueError escape).
with patch.dict(os.environ, self._env("author-profile")):
with self.assertRaises(ValueError):
mcp_server.gitea_resolve_task_capability(task="invalid_task_xyz", remote="prgs")
res = mcp_server.gitea_resolve_task_capability(
task="invalid_task_xyz", remote="prgs"
)
self.assertIsInstance(res, dict)
self.assertEqual(res.get("reason_code"), "unknown_task", res)
self.assertFalse(res.get("allowed_in_current_session"))
self.assertTrue(res.get("stop_required"))
self.assertIs(res.get("mutation_performed"), False)
# Additional regression tests per #145 for permission boundaries and structured guidance
def test_issue_comment_does_not_imply_close(self):
@@ -439,8 +446,11 @@ class TestResolveTaskCapability(unittest.TestCase):
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
for unknown in ("close_pull_request", "close", "pr_close"):
with self.assertRaises(ValueError):
mcp_server.gitea_resolve_task_capability(task=unknown, remote="prgs")
unk = mcp_server.gitea_resolve_task_capability(
task=unknown, remote="prgs"
)
self.assertEqual(unk.get("reason_code"), "unknown_task", unk)
self.assertFalse(unk.get("allowed_in_current_session"))
if __name__ == "__main__":
unittest.main()
+101 -2
View File
@@ -68,8 +68,6 @@ class TestReviewDrafts(unittest.TestCase):
@patch("gitea_mcp_server.api_request")
def test_save_draft_success(self, mock_api):
print("GITEA_MCP_SERVER FILE:", gitea_mcp_server.__file__)
print("DIR OF GITEA_MCP_SERVER:", dir(gitea_mcp_server))
mock_api.return_value = {
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
@@ -285,3 +283,104 @@ class TestReviewDrafts(unittest.TestCase):
)
self.assertFalse(res.get("success"))
self.assertIn("worktree binding mismatch", res.get("reasons")[0])
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._fetch_pr_comments")
def test_resume_draft_foreign_lease(self, mock_comments, mock_api):
mock_api.return_value = {
"state": "open",
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
}
gitea_mcp_server.gitea_save_review_draft(
pr_number=587,
action="approve",
body="Good changes",
expected_head_sha="headsha1234567890",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
reviewer_pr_lease.record_session_lease({
"pr_number": 587,
"session_id": "my-session",
})
mock_comments.return_value = [
{
"id": 9,
"user": {"username": "sysadmin"},
"body": (
"<!-- mcp-review-lease:v1 -->\n"
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
"pr: #587\n"
"reviewer_identity: sysadmin\n"
"profile: prgs-reviewer\n"
"session_id: foreign-session-999\n"
"phase: claimed\n"
),
}
]
res = gitea_mcp_server.gitea_resume_review_draft(
pr_number=587,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
self.assertFalse(res.get("success"))
self.assertTrue(
any("foreign-session-999" in r or "session_id" in r for r in (res.get("reasons") or []))
)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._fetch_pr_comments")
@patch("gitea_mcp_server.terminal_review_hard_stop_reasons")
def test_resume_draft_terminal_lock_blocks(self, mock_hard_stop, mock_comments, mock_api):
mock_api.return_value = {
"state": "open",
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
}
gitea_mcp_server.gitea_save_review_draft(
pr_number=587,
action="approve",
body="Good changes",
expected_head_sha="headsha1234567890",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
reviewer_pr_lease.record_session_lease({
"pr_number": 587,
"session_id": "session-1234",
})
mock_comments.return_value = [
{
"id": 1,
"user": {"username": "sysadmin"},
"body": (
"<!-- mcp-review-lease:v1 -->\n"
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
"pr: #587\n"
"reviewer_identity: sysadmin\n"
"profile: prgs-reviewer\n"
"session_id: session-1234\n"
"phase: claimed\n"
),
}
]
mock_hard_stop.return_value = [
"terminal review mutation already recorded for PR #587 (approve); #332 hard-stop"
]
res = gitea_mcp_server.gitea_resume_review_draft(
pr_number=587,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
self.assertFalse(res.get("success"))
self.assertTrue(any("#332" in r or "terminal" in r for r in (res.get("reasons") or [])))