Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a5284b98 | ||
|
|
6b568d8805 | ||
|
|
8a851eb87e | ||
|
|
ad59053cd7 | ||
|
|
854818e65a | ||
|
|
9d2c652ae8 | ||
|
|
adc61255b2 | ||
|
|
bde5c5fb20 | ||
|
|
a517655aad | ||
|
|
447595b72b | ||
|
|
5e0e474350 | ||
|
|
043df0f7df | ||
|
|
f858c1d1b2 | ||
|
|
08f67007c5 | ||
|
|
3edeba4d7f | ||
|
|
0425bf9a43 | ||
|
|
03c64a3219 | ||
|
|
eac7afe5cb | ||
|
|
e349839fd7 | ||
|
|
fdab6b6c69 | ||
|
|
277ec5269d | ||
|
|
b00e09a781 | ||
|
|
b05075fd25 | ||
|
|
61c3a57df5 | ||
|
|
c908ed6050 | ||
|
|
0a38d92382 | ||
|
|
15a8a76e99 |
@@ -0,0 +1,150 @@
|
|||||||
|
"""Canonical dependency parsing and live-state resolution for the allocator (#758).
|
||||||
|
|
||||||
|
The work allocator previously inferred dependency state from two lowercase
|
||||||
|
substrings (``"blocked on #"`` / ``"downstream of #"``). The repository's
|
||||||
|
canonical declaration form is a ``Depends:`` field inside the issue body's
|
||||||
|
linkage line, for example::
|
||||||
|
|
||||||
|
* Parent: #631 · Depends: #633, #634 · Related: #630, #434
|
||||||
|
|
||||||
|
That form matched neither substring, so dependency-blocked issues were emitted
|
||||||
|
as eligible candidates. This module replaces substring inference with:
|
||||||
|
|
||||||
|
1. structured parsing of ``Depends:`` declarations into issue references, and
|
||||||
|
2. resolution of each reference against **live issue state**, never body text.
|
||||||
|
|
||||||
|
Both halves fail closed: a reference whose state cannot be established makes
|
||||||
|
the owning candidate ineligible rather than assignable.
|
||||||
|
|
||||||
|
No issue number is special-cased here (#758 AC4/AC14); the parser is driven
|
||||||
|
entirely by the declaration syntax.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
# Live issue states, as reported by Gitea.
|
||||||
|
DEP_STATE_OPEN = "open"
|
||||||
|
DEP_STATE_CLOSED = "closed"
|
||||||
|
|
||||||
|
# "Depends:" / "Depends on:" introduces the declaration. "Dependencies" does
|
||||||
|
# not match: after "depend" it continues with "e", not "s".
|
||||||
|
_DEPENDS_KEYWORD = re.compile(r"depends(?:\s+on)?\s*:?\s*", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Immediately after the keyword, consume only the contiguous run of issue
|
||||||
|
# references. Anchoring the run this way means the declaration ends naturally
|
||||||
|
# at the next separator ("·", newline) or sibling field ("Related:"), without
|
||||||
|
# needing to enumerate separators.
|
||||||
|
_DEP_RUN = re.compile(
|
||||||
|
r"\s*(#\d+(?:\s*(?:,|and|&)\s*#\d+)*)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy marker retained so previously-recognized bodies keep working.
|
||||||
|
_LEGACY_BLOCKED = re.compile(r"blocked\s+on\s+#(\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
_ISSUE_REF = re.compile(r"#(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dependency_refs(body: str | None) -> tuple[int, ...]:
|
||||||
|
"""Extract declared dependency issue numbers from an issue *body*.
|
||||||
|
|
||||||
|
Recognizes the canonical ``Depends: #N, #N`` field (including the
|
||||||
|
``Depends on`` spelling) plus the legacy ``blocked on #N`` marker.
|
||||||
|
Returns references in first-seen order with duplicates removed. Malformed
|
||||||
|
or absent declarations yield an empty tuple rather than raising.
|
||||||
|
"""
|
||||||
|
if not body:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
refs: list[int] = []
|
||||||
|
|
||||||
|
def _add(value: str) -> None:
|
||||||
|
number = int(value)
|
||||||
|
if number > 0 and number not in refs:
|
||||||
|
refs.append(number)
|
||||||
|
|
||||||
|
for match in _DEPENDS_KEYWORD.finditer(body):
|
||||||
|
run = _DEP_RUN.match(body, match.end())
|
||||||
|
if not run:
|
||||||
|
continue
|
||||||
|
for ref in _ISSUE_REF.findall(run.group(1)):
|
||||||
|
_add(ref)
|
||||||
|
|
||||||
|
for ref in _LEGACY_BLOCKED.findall(body):
|
||||||
|
_add(ref)
|
||||||
|
|
||||||
|
return tuple(refs)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dependency_state(
|
||||||
|
refs: Iterable[int],
|
||||||
|
state_lookup: Callable[[int], str | None],
|
||||||
|
*,
|
||||||
|
subject: str = "candidate",
|
||||||
|
) -> dict:
|
||||||
|
"""Resolve declared *refs* against live issue state.
|
||||||
|
|
||||||
|
*state_lookup* maps an issue number to its live state string, or to
|
||||||
|
``None`` when that evidence could not be obtained. A reference is:
|
||||||
|
|
||||||
|
* **met** when live state is ``closed``;
|
||||||
|
* **unmet** when live state is any other live value (``open``, etc.);
|
||||||
|
* **unavailable** when state is ``None`` or the lookup raises.
|
||||||
|
|
||||||
|
Unmet *and* unavailable both mark the candidate ineligible (#758 AC6/AC7):
|
||||||
|
allocation must never assume a dependency is satisfied.
|
||||||
|
"""
|
||||||
|
unmet: list[int] = []
|
||||||
|
unavailable: list[int] = []
|
||||||
|
met: list[int] = []
|
||||||
|
|
||||||
|
for ref in refs:
|
||||||
|
try:
|
||||||
|
number = int(ref)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
state = state_lookup(number)
|
||||||
|
except Exception: # noqa: BLE001 — unavailable evidence fails closed
|
||||||
|
state = None
|
||||||
|
normalized = (str(state).strip().lower() if state is not None else "") or None
|
||||||
|
if normalized is None:
|
||||||
|
unavailable.append(number)
|
||||||
|
elif normalized == DEP_STATE_CLOSED:
|
||||||
|
met.append(number)
|
||||||
|
else:
|
||||||
|
unmet.append(number)
|
||||||
|
|
||||||
|
reason: str | None = None
|
||||||
|
if unmet and unavailable:
|
||||||
|
reason = (
|
||||||
|
f"{subject} has unresolved dependencies "
|
||||||
|
f"{_fmt(unmet)} and unverifiable dependencies {_fmt(unavailable)} "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
elif unmet:
|
||||||
|
reason = (
|
||||||
|
f"{subject} depends on unresolved issue(s) {_fmt(unmet)}; "
|
||||||
|
"they are not closed"
|
||||||
|
)
|
||||||
|
elif unavailable:
|
||||||
|
reason = (
|
||||||
|
f"{subject} dependency evidence unavailable for {_fmt(unavailable)} "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"refs": tuple(int(x) for x in refs),
|
||||||
|
"met": tuple(met),
|
||||||
|
"unmet": tuple(unmet),
|
||||||
|
"unavailable": tuple(unavailable),
|
||||||
|
"dependency_unmet": bool(unmet or unavailable),
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(numbers: Iterable[int]) -> str:
|
||||||
|
return ", ".join(f"#{n}" for n in numbers)
|
||||||
+26
-1
@@ -41,6 +41,15 @@ OUTCOME_NO_SAFE = "no_safe_work"
|
|||||||
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
||||||
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
||||||
|
|
||||||
|
# Human-readable statement of how a winner is chosen (#758 AC10). Reported
|
||||||
|
# alongside allocator results so the flat status:ready tier and its
|
||||||
|
# oldest-number tie-break are explicit rather than incidental.
|
||||||
|
SELECTION_POLICY = (
|
||||||
|
"rank complete inventory by (priority desc, PRs before issues, "
|
||||||
|
"number asc); status:ready issues share priority 20, so the oldest "
|
||||||
|
"eligible number wins ties; result limits never affect selection"
|
||||||
|
)
|
||||||
|
|
||||||
ROLE_AUTHOR = "author"
|
ROLE_AUTHOR = "author"
|
||||||
ROLE_REVIEWER = "reviewer"
|
ROLE_REVIEWER = "reviewer"
|
||||||
ROLE_MERGER = "merger"
|
ROLE_MERGER = "merger"
|
||||||
@@ -242,7 +251,23 @@ def classify_skip(
|
|||||||
|
|
||||||
|
|
||||||
def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
|
def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
|
||||||
"""Higher priority first; then lower number (older issues) for stability."""
|
"""Rank candidates deterministically (#758 AC10).
|
||||||
|
|
||||||
|
Ordering key, in precedence order:
|
||||||
|
|
||||||
|
1. ``priority`` descending — the loader scores ``status:ready`` issues at
|
||||||
|
20 and everything else at 1, so the ready queue ties at a single value
|
||||||
|
by design;
|
||||||
|
2. PRs before issues — in-flight review work drains before new authoring;
|
||||||
|
3. ``number`` ascending — oldest first, which is what actually breaks the
|
||||||
|
flat ``status:ready`` tie.
|
||||||
|
|
||||||
|
Because the ready tier is intentionally flat, rule 3 decides most real
|
||||||
|
selections. That is only safe when ranking sees the *complete* candidate
|
||||||
|
inventory: truncating before this call silently redefines "oldest" as
|
||||||
|
"oldest among whatever survived the slice", which is the defect #758
|
||||||
|
fixed. Callers must rank everything and bound reporting afterwards.
|
||||||
|
"""
|
||||||
return sorted(
|
return sorted(
|
||||||
candidates,
|
candidates,
|
||||||
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
|
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
|
||||||
|
|||||||
+20
-2
@@ -33,6 +33,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import author_mutation_worktree
|
import author_mutation_worktree
|
||||||
|
import create_issue_bootstrap
|
||||||
import master_parity_gate
|
import master_parity_gate
|
||||||
import remote_repo_guard
|
import remote_repo_guard
|
||||||
import root_checkout_guard
|
import root_checkout_guard
|
||||||
@@ -354,6 +355,7 @@ def assess_anti_stomp_preflight(
|
|||||||
remote_master_sha: str | None = None,
|
remote_master_sha: str | None = None,
|
||||||
check_root_checkout: bool = True,
|
check_root_checkout: bool = True,
|
||||||
check_worktree: bool = True,
|
check_worktree: bool = True,
|
||||||
|
create_issue_bootstrap_assessment: dict[str, Any] | None = None,
|
||||||
# stale runtime (master parity)
|
# stale runtime (master parity)
|
||||||
startup_head: str | None = None,
|
startup_head: str | None = None,
|
||||||
current_code_head: str | None = None,
|
current_code_head: str | None = None,
|
||||||
@@ -584,12 +586,28 @@ def assess_anti_stomp_preflight(
|
|||||||
project_root=project_root,
|
project_root=project_root,
|
||||||
current_branch=current_branch,
|
current_branch=current_branch,
|
||||||
)
|
)
|
||||||
|
# #757: the #274 guard consults the server-derived create_issue
|
||||||
|
# bootstrap before blocking the canonical control checkout. Route this
|
||||||
|
# guard's decision through the *same* predicate on the *same*
|
||||||
|
# assessment so the two cannot disagree about identical evidence.
|
||||||
|
# Only the wrong-worktree verdict is waived; every other check in this
|
||||||
|
# assessment (root checkout, repo, role, stale runtime, lease, ...) is
|
||||||
|
# evaluated independently and still applies.
|
||||||
|
bootstrap_waived = wt.get("block") and (
|
||||||
|
create_issue_bootstrap.bootstrap_permits_control_checkout(
|
||||||
|
create_issue_bootstrap_assessment,
|
||||||
|
task=task_name,
|
||||||
|
workspace_path=workspace_path,
|
||||||
|
canonical_repo_root=project_root,
|
||||||
|
)
|
||||||
|
)
|
||||||
checks["worktree"] = {
|
checks["worktree"] = {
|
||||||
"block": bool(wt.get("block")),
|
"block": bool(wt.get("block")) and not bootstrap_waived,
|
||||||
"reasons": list(wt.get("reasons") or []),
|
"reasons": list(wt.get("reasons") or []),
|
||||||
"under_branches": wt.get("under_branches"),
|
"under_branches": wt.get("under_branches"),
|
||||||
|
"create_issue_bootstrap_waived": bool(bootstrap_waived),
|
||||||
}
|
}
|
||||||
if wt.get("block"):
|
if wt.get("block") and not bootstrap_waived:
|
||||||
blockers.append(
|
blockers.append(
|
||||||
_blocker(
|
_blocker(
|
||||||
BLOCKER_WRONG_WORKTREE,
|
BLOCKER_WRONG_WORKTREE,
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""Sanctioned pre-issue bootstrap for ``create_issue`` (#749).
|
||||||
|
|
||||||
|
``gitea_create_issue`` is a pure remote mutation: it creates a tracking issue
|
||||||
|
and writes nothing to the local working tree. The issue-first gate forbids
|
||||||
|
creating ``branches/issue-<N>-*`` before the issue number exists, while the
|
||||||
|
#274 branches-only guard previously demanded that worktree first — a deadlock.
|
||||||
|
|
||||||
|
This module defines a **narrow, phase-scoped** exemption:
|
||||||
|
|
||||||
|
* Only tasks in :data:`CREATE_ISSUE_TASKS` may use it.
|
||||||
|
* Only the **canonical control checkout** may be used (never an arbitrary
|
||||||
|
directory, unrelated worktree, or foreign clone).
|
||||||
|
* The control checkout must be clean, on an accepted base branch, and
|
||||||
|
base-equivalent to live master when a remote tip is known.
|
||||||
|
* Every post-creation author mutation keeps the ordinary ``branches/`` rule.
|
||||||
|
|
||||||
|
The exemption cannot widen: unknown tasks, dirty roots, drifted HEADs, non-base
|
||||||
|
branches, and non-control workspaces fall through to the existing fail-closed
|
||||||
|
guards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from author_mutation_worktree import BASE_BRANCHES, is_path_under_branches
|
||||||
|
from reviewer_worktree import parse_dirty_tracked_files
|
||||||
|
|
||||||
|
CREATE_ISSUE_TASKS = frozenset({"create_issue", "gitea_create_issue"})
|
||||||
|
|
||||||
|
# Satisfiable before an issue number exists — never names issue-<N>.
|
||||||
|
EXACT_NEXT_ACTION_BOOTSTRAP = (
|
||||||
|
"Restore the canonical control checkout to a clean accepted base branch "
|
||||||
|
"(master/main/dev) that matches live master, with no tracked local edits "
|
||||||
|
"and no detached HEAD. Re-resolve the exact create_issue task, then re-run "
|
||||||
|
"gitea_create_issue from that clean control checkout. Do not create "
|
||||||
|
"branches/issue-<N>-* worktrees, dummy directories, or borrow unrelated "
|
||||||
|
"worktrees before the issue exists."
|
||||||
|
)
|
||||||
|
|
||||||
|
EXACT_NEXT_ACTION_POST_CREATE = (
|
||||||
|
"After the issue exists: create a registered worktree under "
|
||||||
|
"branches/issue-<N>-* from clean master, claim/lock the issue, set "
|
||||||
|
"GITEA_AUTHOR_WORKTREE / worktree_path to that path, then continue author "
|
||||||
|
"mutations from the issue-backed worktree only."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_create_issue_task(task: str | None) -> bool:
|
||||||
|
"""True when *task* is the create_issue mutation (or tool alias)."""
|
||||||
|
return (task or "").strip() in CREATE_ISSUE_TASKS
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_sha(value: str | None) -> str | None:
|
||||||
|
"""Normalize a Git object id for comparison, or ``None`` when unknown.
|
||||||
|
|
||||||
|
Whitespace and case are the only permitted variation between two spellings
|
||||||
|
of the same commit; anything else is a different commit. Empty and
|
||||||
|
whitespace-only values normalize to ``None`` so an unknown tip can never
|
||||||
|
compare equal to another unknown tip.
|
||||||
|
"""
|
||||||
|
normalized = (value or "").strip().lower()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_create_issue_bootstrap(
|
||||||
|
*,
|
||||||
|
workspace_path: str,
|
||||||
|
canonical_repo_root: str,
|
||||||
|
current_branch: str | None = None,
|
||||||
|
head_sha: str | None = None,
|
||||||
|
porcelain_status: str = "",
|
||||||
|
remote_master_sha: str | None = None,
|
||||||
|
remote_master_sha_error: str | None = None,
|
||||||
|
task: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether create_issue may proceed from the control checkout.
|
||||||
|
|
||||||
|
Returns a structured assessment:
|
||||||
|
|
||||||
|
* ``not_applicable`` — not a create_issue task, or workspace is already a
|
||||||
|
``branches/`` worktree (use ordinary guards).
|
||||||
|
* ``allowed`` — create_issue bootstrap may proceed from this control root.
|
||||||
|
* ``block`` — create_issue was attempted from control checkout but gates
|
||||||
|
failed (dirty, wrong branch, base race, etc.).
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
root = os.path.realpath(canonical_repo_root or "")
|
||||||
|
workspace = os.path.realpath(workspace_path or root or ".")
|
||||||
|
branch = (current_branch or "").strip()
|
||||||
|
dirty = parse_dirty_tracked_files(porcelain_status or "")
|
||||||
|
under_branches = is_path_under_branches(workspace, root) if root else False
|
||||||
|
|
||||||
|
if not is_create_issue_task(task):
|
||||||
|
return _result(
|
||||||
|
not_applicable=True,
|
||||||
|
allowed=False,
|
||||||
|
block=False,
|
||||||
|
reasons=["task is not create_issue"],
|
||||||
|
workspace=workspace,
|
||||||
|
root=root,
|
||||||
|
branch=branch,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=under_branches,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Registered branches/ worktrees keep the normal path (no bootstrap).
|
||||||
|
if under_branches:
|
||||||
|
return _result(
|
||||||
|
not_applicable=True,
|
||||||
|
allowed=False,
|
||||||
|
block=False,
|
||||||
|
reasons=["workspace is under branches/; ordinary #274 path applies"],
|
||||||
|
workspace=workspace,
|
||||||
|
root=root,
|
||||||
|
branch=branch,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only the exact canonical control checkout is eligible.
|
||||||
|
if not root or workspace != root:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap requires the canonical control checkout; "
|
||||||
|
f"workspace '{workspace}' is not the repository root '{root or '(unknown)'}'"
|
||||||
|
)
|
||||||
|
return _result(
|
||||||
|
not_applicable=False,
|
||||||
|
allowed=False,
|
||||||
|
block=True,
|
||||||
|
reasons=reasons,
|
||||||
|
workspace=workspace,
|
||||||
|
root=root,
|
||||||
|
branch=branch,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=False,
|
||||||
|
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
if dirty:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: control checkout has tracked local "
|
||||||
|
f"edits (dirty files: {', '.join(dirty)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not branch:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: control checkout is detached HEAD; "
|
||||||
|
"expected an accepted base branch (master/main/dev)"
|
||||||
|
)
|
||||||
|
elif branch not in BASE_BRANCHES:
|
||||||
|
reasons.append(
|
||||||
|
f"create_issue bootstrap blocked: control checkout branch '{branch}' "
|
||||||
|
f"is not an accepted base branch ({'/'.join(sorted(BASE_BRANCHES))})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# #757 AC3/AC4: base-equivalence must be *proven*, never assumed. An
|
||||||
|
# unknown tip on either side is not evidence of agreement, so a missing
|
||||||
|
# local HEAD, an unresolvable live master, or a resolver failure all block.
|
||||||
|
remote_tip = normalize_sha(remote_master_sha)
|
||||||
|
local_tip = normalize_sha(head_sha)
|
||||||
|
resolver_error = (remote_master_sha_error or "").strip() or None
|
||||||
|
|
||||||
|
if not local_tip:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: control checkout HEAD SHA is "
|
||||||
|
"unknown; base equivalence to live master cannot be proven "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolver_error:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: live master tip could not be "
|
||||||
|
f"resolved ({resolver_error}); base equivalence cannot be proven "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
elif not remote_tip:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: live master tip is unknown; base "
|
||||||
|
"equivalence to live master cannot be proven (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if remote_tip and local_tip and remote_tip != local_tip:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: control checkout HEAD does not match "
|
||||||
|
f"live master (HEAD {local_tip[:12]}, live master {remote_tip[:12]})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return _result(
|
||||||
|
not_applicable=False,
|
||||||
|
allowed=False,
|
||||||
|
block=True,
|
||||||
|
reasons=reasons,
|
||||||
|
workspace=workspace,
|
||||||
|
root=root,
|
||||||
|
branch=branch or None,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=False,
|
||||||
|
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
|
||||||
|
local_head_sha=local_tip,
|
||||||
|
remote_master_sha=remote_tip,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _result(
|
||||||
|
not_applicable=False,
|
||||||
|
allowed=True,
|
||||||
|
block=False,
|
||||||
|
reasons=[],
|
||||||
|
workspace=workspace,
|
||||||
|
root=root,
|
||||||
|
branch=branch or None,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=False,
|
||||||
|
exact_next_action=EXACT_NEXT_ACTION_POST_CREATE,
|
||||||
|
bootstrap_path="clean_canonical_control_checkout",
|
||||||
|
local_head_sha=local_tip,
|
||||||
|
remote_master_sha=remote_tip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_permits_control_checkout(
|
||||||
|
assessment: Any,
|
||||||
|
*,
|
||||||
|
task: str | None,
|
||||||
|
workspace_path: str | None,
|
||||||
|
canonical_repo_root: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Single interpretation of a bootstrap assessment (#757).
|
||||||
|
|
||||||
|
Both author-mutation guards — the #274 branches-only enforcer and the #604
|
||||||
|
anti-stomp preflight — route their "may this workspace mutate" decision
|
||||||
|
through this predicate, so the two can never reach opposite conclusions
|
||||||
|
about identical evidence.
|
||||||
|
|
||||||
|
Fail-closed by construction. Every proof obligation must be present and
|
||||||
|
affirmative in *assessment*, and the assessment must describe the very
|
||||||
|
workspace and canonical root being guarded. A missing, malformed, refused,
|
||||||
|
incomplete, or contradictory assessment returns ``False``, which leaves the
|
||||||
|
caller's ordinary block in force.
|
||||||
|
|
||||||
|
``assessment`` is server-derived only: it is produced by
|
||||||
|
:func:`assess_create_issue_bootstrap` from inspected repository state. It is
|
||||||
|
never accepted from an MCP tool argument, so no caller can assert
|
||||||
|
eligibility it has not proven.
|
||||||
|
"""
|
||||||
|
if not isinstance(assessment, dict):
|
||||||
|
return False
|
||||||
|
if not is_create_issue_task(task):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Positive proof: the assessment must affirmatively allow, with no
|
||||||
|
# competing refusal or not-applicable disposition recorded alongside it.
|
||||||
|
if assessment.get("allowed") is not True:
|
||||||
|
return False
|
||||||
|
if assessment.get("proven") is not True:
|
||||||
|
return False
|
||||||
|
if assessment.get("block") is not False:
|
||||||
|
return False
|
||||||
|
if assessment.get("not_applicable") is not False:
|
||||||
|
return False
|
||||||
|
if assessment.get("reasons"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Scope proof: only the create_issue bootstrap, only via the clean
|
||||||
|
# canonical control checkout path.
|
||||||
|
if assessment.get("task_scope") != "create_issue_only":
|
||||||
|
return False
|
||||||
|
if assessment.get("bootstrap_path") != "clean_canonical_control_checkout":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# State proof: clean, and not a branches/ worktree (those keep #274).
|
||||||
|
if assessment.get("dirty_files"):
|
||||||
|
return False
|
||||||
|
if assessment.get("under_branches") is not False:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Base-equivalence proof (#757 AC3/AC4): both tips must be recorded,
|
||||||
|
# nonempty, and equal. Re-derived here rather than trusted from the
|
||||||
|
# assessment's own flag, so a hand-built or truncated assessment cannot
|
||||||
|
# assert agreement it never proved.
|
||||||
|
if assessment.get("base_tips_verified") is not True:
|
||||||
|
return False
|
||||||
|
local_tip = normalize_sha(assessment.get("local_head_sha"))
|
||||||
|
remote_tip = normalize_sha(assessment.get("remote_master_sha"))
|
||||||
|
if not local_tip or not remote_tip or local_tip != remote_tip:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Binding proof: the assessment must describe *this* workspace and root,
|
||||||
|
# and that workspace must be exactly the canonical control checkout.
|
||||||
|
root = os.path.realpath(canonical_repo_root or "")
|
||||||
|
workspace = os.path.realpath(workspace_path or root or ".")
|
||||||
|
if not root or workspace != root:
|
||||||
|
return False
|
||||||
|
if assessment.get("canonical_repo_root") != root:
|
||||||
|
return False
|
||||||
|
if assessment.get("workspace_path") != workspace:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def format_create_issue_bootstrap_error(assessment: dict[str, Any]) -> str:
|
||||||
|
"""RuntimeError / typed-block message for a failed bootstrap assessment."""
|
||||||
|
reasons = "; ".join(
|
||||||
|
assessment.get("reasons") or ["create_issue bootstrap failed"]
|
||||||
|
)
|
||||||
|
next_action = (
|
||||||
|
assessment.get("exact_next_action") or EXACT_NEXT_ACTION_BOOTSTRAP
|
||||||
|
)
|
||||||
|
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||||
|
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||||
|
return (
|
||||||
|
f"Create-issue bootstrap guard (#749): {reasons}. "
|
||||||
|
f"canonical repository root: {root}; workspace: {workspace}. "
|
||||||
|
f"exact_next_action: {next_action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
*,
|
||||||
|
not_applicable: bool,
|
||||||
|
allowed: bool,
|
||||||
|
block: bool,
|
||||||
|
reasons: list[str],
|
||||||
|
workspace: str,
|
||||||
|
root: str,
|
||||||
|
branch: str | None,
|
||||||
|
dirty: list[str],
|
||||||
|
under_branches: bool,
|
||||||
|
exact_next_action: str | None = None,
|
||||||
|
bootstrap_path: str | None = None,
|
||||||
|
local_head_sha: str | None = None,
|
||||||
|
remote_master_sha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# #757 AC3/AC4: equality is recorded only when BOTH tips are known, so a
|
||||||
|
# consumer can never read agreement out of two missing values.
|
||||||
|
base_tips_verified = bool(
|
||||||
|
local_head_sha and remote_master_sha and local_head_sha == remote_master_sha
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"not_applicable": not_applicable,
|
||||||
|
"allowed": allowed,
|
||||||
|
"block": block,
|
||||||
|
"proven": allowed and not block,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"canonical_repo_root": root,
|
||||||
|
"current_branch": branch,
|
||||||
|
"dirty_files": list(dirty),
|
||||||
|
"under_branches": under_branches,
|
||||||
|
"exact_next_action": exact_next_action,
|
||||||
|
"bootstrap_path": bootstrap_path,
|
||||||
|
"task_scope": "create_issue_only",
|
||||||
|
"local_head_sha": local_head_sha,
|
||||||
|
"remote_master_sha": remote_master_sha,
|
||||||
|
"base_tips_verified": base_tips_verified,
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Installation root vs canonical target repository root
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document (tracked as issue #741, building on #706 and #739/#740) explains
|
||||||
|
the two distinct filesystem roots the Gitea-Tools MCP server reasons about, why
|
||||||
|
conflating them silently targets the wrong repository, and which rule applies
|
||||||
|
when you add a new consumer.
|
||||||
|
|
||||||
|
It is the repository-scope companion to
|
||||||
|
[`gitea-execution-profiles.md`](gitea-execution-profiles.md) (the profile model)
|
||||||
|
and [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md)
|
||||||
|
(the per-role namespace model).
|
||||||
|
|
||||||
|
## The two roots
|
||||||
|
|
||||||
|
| | Installation root | Canonical target repository root |
|
||||||
|
|---|---|---|
|
||||||
|
| What it is | The checkout the server *code* lives in | The working root of the repository whose issues/PRs/branches the namespace *mutates* |
|
||||||
|
| How it is derived | `PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))` | Configured per namespace, then pinned immutably into the session |
|
||||||
|
| Configured by | Nothing — it follows the script | `canonical_repository_root` profile field, or the `GITEA_CANONICAL_REPOSITORY_ROOT` environment variable |
|
||||||
|
| Changes at runtime? | No | No — first bind wins for the life of the process |
|
||||||
|
| Accessor | `PROJECT_ROOT` | `_canonical_local_git_root()` (filesystem) / `_canonical_repository_slug()` (identity) |
|
||||||
|
|
||||||
|
For a **single-repository** namespace — every Gitea-Tools namespace today — the
|
||||||
|
two roots are the same path, and nothing about the existing behaviour changes.
|
||||||
|
The distinction only becomes observable once a namespace is pointed at a
|
||||||
|
different repository.
|
||||||
|
|
||||||
|
## Which root does my code need?
|
||||||
|
|
||||||
|
Ask what the operation is *about*, not where the file happens to sit.
|
||||||
|
|
||||||
|
**Use the installation root (`PROJECT_ROOT`)** when the operation concerns the
|
||||||
|
Gitea-Tools software itself:
|
||||||
|
|
||||||
|
- server implementation / version parity (`master_parity_gate`, the
|
||||||
|
`startup_head` vs `current_head` staleness gate);
|
||||||
|
- loading the server's own workflow, schema and skill files;
|
||||||
|
- self-code hashing and stale-runtime detection;
|
||||||
|
- locating installed scripts such as `mirror_refs.sh`.
|
||||||
|
|
||||||
|
These are intentionally install-scoped. Do not "fix" them.
|
||||||
|
|
||||||
|
**Use the canonical target root (`_canonical_local_git_root()`)** when the
|
||||||
|
operation concerns the repository being worked on:
|
||||||
|
|
||||||
|
- `git remote get-url` for repository identity;
|
||||||
|
- branch creation, push, and commit;
|
||||||
|
- ancestry and merge-base proofs;
|
||||||
|
- worktree inventory, cleanup, and branch deletion;
|
||||||
|
- any local git subprocess whose result feeds a mutation guard.
|
||||||
|
|
||||||
|
**If you cannot tell, fail closed.** An ambiguous consumer that guesses the
|
||||||
|
install root is the exact defect class #741 exists to eliminate.
|
||||||
|
|
||||||
|
## Why conflating them inverts the guards
|
||||||
|
|
||||||
|
Before #741, `_local_git_remote_url()` ran `git remote get-url` with
|
||||||
|
`cwd=PROJECT_ROOT` unconditionally. Every consumer of repository *identity* —
|
||||||
|
`_resolve`, the #530 remote/repo guard, the anti-stomp org/repo fill,
|
||||||
|
`_workspace_repository_slug` — therefore read the Gitea-Tools remote and called
|
||||||
|
it "the workspace", no matter which repository the namespace was bound to.
|
||||||
|
|
||||||
|
For a namespace whose canonical root points elsewhere, this **inverts** the
|
||||||
|
guard rather than merely weakening it:
|
||||||
|
|
||||||
|
- an operation naming the genuinely bound target repository is **rejected**,
|
||||||
|
because that slug does not appear in the Gitea-Tools remote URL;
|
||||||
|
- an operation naming Gitea-Tools is **accepted**.
|
||||||
|
|
||||||
|
The filesystem guards (#274 branches-only and worktree membership) had already
|
||||||
|
been migrated to the canonical root by #706, so the two halves of a single
|
||||||
|
assessment described two different repositories.
|
||||||
|
|
||||||
|
A related subtlety: repository identity must not be derived by looking a remote
|
||||||
|
up by *name*. A target checkout commonly names its remote `origin` rather than
|
||||||
|
`prgs`, so a name-keyed lookup returns nothing and the omitted coordinates fall
|
||||||
|
through to the remote-wide default *target* — an unrelated repository.
|
||||||
|
`_canonical_repository_slug()` probes candidate remote names against the
|
||||||
|
canonical root instead.
|
||||||
|
|
||||||
|
`_canonical_local_git_root()` is now the one place a target root is resolved.
|
||||||
|
Do not re-derive it; new code that needs a target root calls that helper.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Declare the binding on the profile, alongside `allowed_repositories`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"example-author": {
|
||||||
|
"role": "author",
|
||||||
|
"canonical_repository_root": "/absolute/path/to/target-repo",
|
||||||
|
"allowed_repositories": ["Example-Org/target-repo"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The namespace-scoped environment variable
|
||||||
|
`GITEA_CANONICAL_REPOSITORY_ROOT` overrides the profile field, and is normally
|
||||||
|
exported next to the server `cwd` in the MCP client configuration.
|
||||||
|
|
||||||
|
Validation is layered, and each layer fails closed:
|
||||||
|
|
||||||
|
1. **Config load.** The path must be a non-empty absolute string. All supported
|
||||||
|
loaders — v1, v2-`environments`, and v2-`contexts` — validate it identically.
|
||||||
|
(Before #741 only the v2-`contexts` loader validated it, and
|
||||||
|
v2-`environments` silently *dropped* the field during flattening, so the
|
||||||
|
namespace fell back to the install root — a fail-open.)
|
||||||
|
2. **Bind time.** The path must exist, be a git repository, and resolve to a
|
||||||
|
repository identity matching the session's authorized slug. A configured but
|
||||||
|
unresolvable root is never replaced by the install identity.
|
||||||
|
3. **Every mutation.** The pinned root is compared against the live configured
|
||||||
|
value; a mismatch is treated as a forged or conflicting binding.
|
||||||
|
|
||||||
|
`allowed_repositories` remains a separate authorization boundary (#714): the
|
||||||
|
canonical root determines *which* repository is derived, and
|
||||||
|
`allowed_repositories` determines whether the session may act on it. A root that
|
||||||
|
resolves to a repository outside that list fails closed.
|
||||||
|
|
||||||
|
## Explicit coordinates confirm, never override
|
||||||
|
|
||||||
|
Explicit `org`/`repo` arguments may **confirm** an existing canonical binding.
|
||||||
|
They can never establish, complete, or replace one. A request naming a
|
||||||
|
repository that contradicts the binding fails closed, in both directions:
|
||||||
|
|
||||||
|
- a Gitea-Tools-rooted namespace cannot mutate another repository;
|
||||||
|
- a namespace rooted at another repository cannot mutate Gitea-Tools.
|
||||||
|
|
||||||
|
This matters because both-explicit coordinates short-circuit the #530
|
||||||
|
remote/repo match check, so without this rule a caller could name any repository
|
||||||
|
and skip validation entirely.
|
||||||
|
|
||||||
|
No request-supplied workspace, remote, owner, repository, or worktree can
|
||||||
|
replace the immutable root.
|
||||||
|
|
||||||
|
## Parity is reported per dimension
|
||||||
|
|
||||||
|
`gitea_assess_master_parity` reports two separately labelled dimensions:
|
||||||
|
|
||||||
|
- `server_implementation` — the Gitea-Tools installation checkout. Its
|
||||||
|
`startup_head` / `current_head` / `stale` / `restart_required` fields keep
|
||||||
|
their original meaning, and **only this dimension gates mutations**: the
|
||||||
|
running process executes the code it started with, so a merged fix is not live
|
||||||
|
until the daemon restarts.
|
||||||
|
- `target_repository` — the configured canonical target checkout and its
|
||||||
|
last-known remote master.
|
||||||
|
|
||||||
|
"In parity" is a statement about one dimension, never about the whole system.
|
||||||
|
Read the dimension you actually care about.
|
||||||
@@ -317,6 +317,44 @@ Least-privilege constraints:
|
|||||||
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
||||||
`issue.close`, which the production normalizer rejects or drops).
|
`issue.close`, which the production normalizer rejects or drops).
|
||||||
|
|
||||||
|
### Post-merge moot-lease cleanup ownership (`gitea.pr.comment`)
|
||||||
|
|
||||||
|
Neutralising a reviewer lease left behind on an already-merged/closed PR is
|
||||||
|
reconciliation work too. `task_capability_map` maps
|
||||||
|
`cleanup_post_merge_moot_lease` — and its tool-name alias
|
||||||
|
`gitea_cleanup_post_merge_moot_lease` — to role `reconciler` with permission
|
||||||
|
`gitea.pr.comment` (#745). Both names carry the **same** contract.
|
||||||
|
|
||||||
|
The permission alone is deliberately not sufficient: author, reviewer and
|
||||||
|
merger profiles all hold `gitea.pr.comment` for ordinary PR discussion, so the
|
||||||
|
role gate — not the permission gate — is what keeps the terminal lease marker
|
||||||
|
reconciler-owned.
|
||||||
|
|
||||||
|
`gitea_cleanup_post_merge_moot_lease` splits its two modes on purpose:
|
||||||
|
|
||||||
|
- **`apply=false` (assessment) requires only `gitea.read`, with no role gate.**
|
||||||
|
This matches `gitea_cleanup_stale_review_decision_lock` and
|
||||||
|
`gitea_cleanup_obsolete_reviewer_comment_lease`, whose assessment paths are
|
||||||
|
likewise read-gated, so an operator can diagnose a stuck lease from whichever
|
||||||
|
namespace happens to be attached without switching roles. The dry run
|
||||||
|
performs no mutation and records append-only evidence in-session.
|
||||||
|
- **`apply=true` (mutation) requires all of the following**, in order: the
|
||||||
|
session must have resolved exactly `cleanup_post_merge_moot_lease` (resolving
|
||||||
|
any other task — including a sibling reconciler task — does not authorize
|
||||||
|
it); the active role must be `reconciler`; the profile must hold
|
||||||
|
`gitea.pr.comment`; the explicit `org`/`repo` must agree with the canonical
|
||||||
|
repository identity, which is derived from the session binding and can never
|
||||||
|
be overridden by request parameters; and matching dry-run evidence must show
|
||||||
|
`lease_moot`, `cleanup_allowed`, and the same PR, lease session, candidate
|
||||||
|
head and lease marker id that are live at apply time.
|
||||||
|
|
||||||
|
Everything else fails closed: a live lease on an open PR, an already-terminal
|
||||||
|
(idempotent) lease, a lease superseded between the dry run and the apply, a
|
||||||
|
malformed lease missing session/head/marker, and any foreign-repository target.
|
||||||
|
The cleanup only ever appends a terminal `phase: released` marker
|
||||||
|
(`blocker: post-merge-moot`) — it never edits or deletes another session's
|
||||||
|
comment, and it never merges or adopts a lease.
|
||||||
|
|
||||||
Launch a static `gitea-reconciler` MCP namespace with
|
Launch a static `gitea-reconciler` MCP namespace with
|
||||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ It extracts the issue-first, isolated-worktree, no-self-review, profile-safety,
|
|||||||
merge-cleanup, fail-closed, and recovery rules into a reusable package that can
|
merge-cleanup, fail-closed, and recovery rules into a reusable package that can
|
||||||
be adapted to other repositories.
|
be adapted to other repositories.
|
||||||
|
|
||||||
|
### Sanctioned first mutation: `create_issue` from clean control (#749)
|
||||||
|
|
||||||
|
Creating a tracking issue has no issue number yet, so no `branches/issue-<N>-*`
|
||||||
|
worktree can exist. The sanctioned path is: clean canonical control checkout
|
||||||
|
(accepted base branch, base-equivalent to live master, no tracked dirt) →
|
||||||
|
resolve exact `create_issue` → `gitea_create_issue`. After the issue exists,
|
||||||
|
all further author mutations require a registered issue-backed worktree and
|
||||||
|
lock. Do not improvise with dummy directories, borrowed worktrees, or pre-issue
|
||||||
|
worktrees. See `skills/llm-project-workflow/workflows/create-issue.md` §18a.
|
||||||
|
|
||||||
## Principle: the profile is the role, not the LLM
|
## Principle: the profile is the role, not the LLM
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -272,6 +272,15 @@ def load_config(path=None):
|
|||||||
)
|
)
|
||||||
if not isinstance(data.get("profiles"), dict):
|
if not isinstance(data.get("profiles"), dict):
|
||||||
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
|
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
|
||||||
|
# #741: the v1 path returns `data` unflattened, so nothing else validates
|
||||||
|
# the cross-repository binding before gitea_auth.get_profile() reads it.
|
||||||
|
# Validate it here so every supported loader treats the field identically
|
||||||
|
# (a relative or blank path must never reach the runtime guard).
|
||||||
|
for _name, _profile in data["profiles"].items():
|
||||||
|
if isinstance(_profile, dict):
|
||||||
|
_validate_canonical_repository_root(
|
||||||
|
_name, _profile.get("canonical_repository_root")
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -358,6 +367,14 @@ def _flatten_identity(env_name, svc_name, svc, ident_name, ident):
|
|||||||
for key in ("role", "username", "execution_profile", "audit_label"):
|
for key in ("role", "username", "execution_profile", "audit_label"):
|
||||||
if ident.get(key):
|
if ident.get(key):
|
||||||
profile[key] = ident[key]
|
profile[key] = ident[key]
|
||||||
|
# #741: the cross-repository binding must survive flattening. Previously
|
||||||
|
# this key was silently dropped here, so a v2-environments namespace that
|
||||||
|
# declared canonical_repository_root fell back to the *installation* root
|
||||||
|
# and mutated Gitea-Tools instead of its target repository — a fail-open.
|
||||||
|
# Validate it exactly as the v2-contexts loader does before propagating.
|
||||||
|
_validate_canonical_repository_root(addr, ident.get("canonical_repository_root"))
|
||||||
|
if ident.get("canonical_repository_root"):
|
||||||
|
profile["canonical_repository_root"] = ident["canonical_repository_root"]
|
||||||
return addr, profile
|
return addr, profile
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+928
-152
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,15 @@ def _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
|||||||
return re.search(pattern, name) is not None
|
return re.search(pattern, name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
||||||
|
"""Public accessor for the exact issue-marker match (#753).
|
||||||
|
|
||||||
|
Dead-session lock recovery needs the same word-boundary matcher to detect
|
||||||
|
ambiguous branch claims, so it is exposed rather than reached into.
|
||||||
|
"""
|
||||||
|
return _branch_carries_issue_marker(branch_name, issue_number)
|
||||||
|
|
||||||
|
|
||||||
def assess_own_branch_adoption(
|
def assess_own_branch_adoption(
|
||||||
*,
|
*,
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""Dead-session author issue-lock recovery (#753).
|
||||||
|
|
||||||
|
A durable author issue lock records the PID of the MCP session that took it.
|
||||||
|
When that process exits, ``issue_lock_store.assess_lock_freshness`` classifies
|
||||||
|
the lock as ``stale`` (``live=False``) even while its lease is still within TTL,
|
||||||
|
so every ownership check that requires a *live* lock fails closed.
|
||||||
|
|
||||||
|
Re-taking the lock through ``gitea_lock_issue`` is unreachable for real work:
|
||||||
|
``issue_lock_worktree.assess_issue_lock_worktree`` demands the worktree be
|
||||||
|
base-equivalent to ``master``/``main``/``dev``, and a branch that already
|
||||||
|
carries commits is ahead of its base by construction. The existing
|
||||||
|
``assess_expired_lock_reclaim`` affordance does not apply either, because
|
||||||
|
``assess_same_issue_lease_conflict`` only consults it once the lease has
|
||||||
|
*expired* — a dead PID under an unexpired lease never reaches it.
|
||||||
|
|
||||||
|
This module is the pure evidence assessor for that one narrow case. It grants
|
||||||
|
recovery only when every element of durable ownership still matches exactly and
|
||||||
|
the recorded process is demonstrably dead. It never trusts caller assertions:
|
||||||
|
every field is compared against durable lock state or live observation supplied
|
||||||
|
by the caller. It performs no mutation and no network I/O.
|
||||||
|
|
||||||
|
Recovery deliberately does **not** relax base-equivalence for brand-new issue
|
||||||
|
claims — only for a lock whose own prior record already proves the branch,
|
||||||
|
worktree, head, and author.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
from issue_lock_store import is_process_alive
|
||||||
|
from reviewer_worktree import parse_dirty_tracked_files
|
||||||
|
|
||||||
|
# Outcome values
|
||||||
|
RECOVERY_SANCTIONED = "RECOVERY_SANCTIONED"
|
||||||
|
NO_CANDIDATE = "NO_CANDIDATE"
|
||||||
|
REFUSED = "REFUSED"
|
||||||
|
|
||||||
|
# Durable fields a lock must carry before it can be considered at all.
|
||||||
|
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
|
||||||
|
|
||||||
|
|
||||||
|
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||||
|
if not left or not right:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return os.path.realpath(left) == os.path.realpath(right)
|
||||||
|
except OSError:
|
||||||
|
return left == right
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
claimant = lock.get("claimant")
|
||||||
|
if not isinstance(claimant, Mapping):
|
||||||
|
lease = lock.get("work_lease")
|
||||||
|
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
|
||||||
|
return dict(claimant) if isinstance(claimant, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _recorded_pid(lock: Mapping[str, Any]) -> Any:
|
||||||
|
pid = lock.get("session_pid")
|
||||||
|
if pid is None:
|
||||||
|
pid = lock.get("pid")
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
|
||||||
|
"""Names of durable fields that are missing or unusable."""
|
||||||
|
missing: list[str] = []
|
||||||
|
for field in REQUIRED_LOCK_FIELDS:
|
||||||
|
if not _text(lock.get(field)):
|
||||||
|
missing.append(field)
|
||||||
|
pid = _recorded_pid(lock)
|
||||||
|
if pid is None or _text(pid) == "":
|
||||||
|
missing.append("session_pid/pid")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if int(pid) <= 0:
|
||||||
|
missing.append("session_pid/pid")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
missing.append("session_pid/pid")
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def assess_dead_session_lock_recovery(
|
||||||
|
existing_lock: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str,
|
||||||
|
worktree_path: str,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
identity: str | None,
|
||||||
|
profile: str | None,
|
||||||
|
current_branch: str | None,
|
||||||
|
porcelain_status: str,
|
||||||
|
head_sha: str | None,
|
||||||
|
remote_head_sha: str | None,
|
||||||
|
pr_head_sha: str | None = None,
|
||||||
|
pr_number: int | None = None,
|
||||||
|
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
|
||||||
|
candidate_branches: Iterable[str] | None = None,
|
||||||
|
current_pid: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Decide whether a dead-session author lock may be natively recovered.
|
||||||
|
|
||||||
|
Returns a dict with ``recovery_sanctioned`` (bool), ``outcome``, ``reasons``
|
||||||
|
(why it was refused, or the positive proof when sanctioned), and
|
||||||
|
``evidence`` (a redaction-safe record for auditing).
|
||||||
|
|
||||||
|
``NO_CANDIDATE`` means no recovery was attempted at all — there is no
|
||||||
|
existing lock, or the lock does not describe this issue. The caller must
|
||||||
|
treat that exactly as it treated the pre-#753 world. ``REFUSED`` means a
|
||||||
|
candidate existed but the evidence did not agree; the caller fails closed.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
evidence: dict[str, Any] = {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not existing_lock:
|
||||||
|
return _result(
|
||||||
|
NO_CANDIDATE, False, ["no existing durable lock for this issue"], evidence
|
||||||
|
)
|
||||||
|
|
||||||
|
lock = dict(existing_lock)
|
||||||
|
|
||||||
|
# ── Candidate identification ────────────────────────────────────────────
|
||||||
|
# Recovery only ever applies to a lock that already claims THIS issue.
|
||||||
|
# Anything else is not a recovery candidate and must not be reinterpreted.
|
||||||
|
if lock.get("issue_number") != issue_number:
|
||||||
|
return _result(
|
||||||
|
NO_CANDIDATE,
|
||||||
|
False,
|
||||||
|
[
|
||||||
|
f"existing lock targets issue #{lock.get('issue_number')}, "
|
||||||
|
f"not #{issue_number}; not a recovery candidate"
|
||||||
|
],
|
||||||
|
evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A malformed/incomplete durable record can never prove ownership.
|
||||||
|
missing = _malformed_reasons(lock)
|
||||||
|
if missing:
|
||||||
|
return _result(
|
||||||
|
REFUSED,
|
||||||
|
False,
|
||||||
|
[
|
||||||
|
"durable lock record is incomplete and cannot prove ownership "
|
||||||
|
f"(missing/unusable: {', '.join(missing)})"
|
||||||
|
],
|
||||||
|
evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded_pid = _recorded_pid(lock)
|
||||||
|
evidence["prior_session_pid"] = recorded_pid
|
||||||
|
evidence["replacement_session_pid"] = (
|
||||||
|
current_pid if current_pid is not None else os.getpid()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Repository scope ────────────────────────────────────────────────────
|
||||||
|
for field, expected in (("remote", remote), ("org", org), ("repo", repo)):
|
||||||
|
actual = _text(lock.get(field))
|
||||||
|
if actual != _text(expected):
|
||||||
|
reasons.append(
|
||||||
|
f"lock {field} '{actual}' does not match requested '{_text(expected)}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Branch identity ─────────────────────────────────────────────────────
|
||||||
|
locked_branch = _text(lock.get("branch_name"))
|
||||||
|
if locked_branch != _text(branch_name):
|
||||||
|
reasons.append(
|
||||||
|
f"lock branch '{locked_branch}' does not match requested "
|
||||||
|
f"'{_text(branch_name)}'"
|
||||||
|
)
|
||||||
|
evidence["locked_branch"] = locked_branch
|
||||||
|
|
||||||
|
# The worktree must actually be sitting on the locked branch. Without this
|
||||||
|
# a clean worktree parked elsewhere could stand in for the real work.
|
||||||
|
checked_out = _text(current_branch)
|
||||||
|
if not checked_out:
|
||||||
|
reasons.append(
|
||||||
|
"worktree is not on a named branch (detached HEAD); locked-branch "
|
||||||
|
"occupancy could not be proven"
|
||||||
|
)
|
||||||
|
elif checked_out != locked_branch:
|
||||||
|
reasons.append(
|
||||||
|
f"worktree is on branch '{checked_out}', not the locked branch "
|
||||||
|
f"'{locked_branch}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Worktree identity ───────────────────────────────────────────────────
|
||||||
|
locked_worktree = _text(lock.get("worktree_path"))
|
||||||
|
if not _same_realpath(locked_worktree, worktree_path):
|
||||||
|
reasons.append(
|
||||||
|
f"lock worktree '{locked_worktree}' does not match declared "
|
||||||
|
f"'{_text(worktree_path)}'"
|
||||||
|
)
|
||||||
|
evidence["locked_worktree_path"] = locked_worktree
|
||||||
|
|
||||||
|
# ── Cleanliness (never waived) ──────────────────────────────────────────
|
||||||
|
dirty_files = parse_dirty_tracked_files(porcelain_status)
|
||||||
|
if dirty_files:
|
||||||
|
reasons.append(
|
||||||
|
"worktree has tracked local edits; recovery requires a clean "
|
||||||
|
f"worktree (dirty files: {', '.join(dirty_files)})"
|
||||||
|
)
|
||||||
|
evidence["dirty_files"] = dirty_files
|
||||||
|
|
||||||
|
# ── Head agreement: local == remote == PR ───────────────────────────────
|
||||||
|
local_head = _text(head_sha)
|
||||||
|
remote_head = _text(remote_head_sha)
|
||||||
|
if not local_head:
|
||||||
|
reasons.append("local head SHA could not be determined")
|
||||||
|
if not remote_head:
|
||||||
|
reasons.append(
|
||||||
|
f"remote head for branch '{locked_branch}' could not be determined"
|
||||||
|
)
|
||||||
|
if local_head and remote_head and local_head != remote_head:
|
||||||
|
reasons.append(
|
||||||
|
f"local head {local_head} does not match remote branch head {remote_head}"
|
||||||
|
)
|
||||||
|
evidence["local_head"] = local_head or None
|
||||||
|
evidence["remote_head"] = remote_head or None
|
||||||
|
|
||||||
|
pr_head = _text(pr_head_sha)
|
||||||
|
if pr_head:
|
||||||
|
evidence["pr_head"] = pr_head
|
||||||
|
evidence["pr_number"] = pr_number
|
||||||
|
if local_head and pr_head != local_head:
|
||||||
|
reasons.append(
|
||||||
|
f"open PR #{pr_number} head {pr_head} does not match local head "
|
||||||
|
f"{local_head}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Author identity ─────────────────────────────────────────────────────
|
||||||
|
claimant = _lock_claimant(lock)
|
||||||
|
locked_identity = _text(claimant.get("username"))
|
||||||
|
locked_profile = _text(claimant.get("profile"))
|
||||||
|
evidence["locked_identity"] = locked_identity or None
|
||||||
|
evidence["locked_profile"] = locked_profile or None
|
||||||
|
if not locked_identity or not locked_profile:
|
||||||
|
reasons.append(
|
||||||
|
"durable lock does not record a claimant identity/profile; "
|
||||||
|
"author ownership could not be proven"
|
||||||
|
)
|
||||||
|
if not _text(identity) or not _text(profile):
|
||||||
|
reasons.append(
|
||||||
|
"active session identity/profile is unknown; author ownership "
|
||||||
|
"could not be proven"
|
||||||
|
)
|
||||||
|
if locked_identity and _text(identity) and locked_identity != _text(identity):
|
||||||
|
reasons.append(
|
||||||
|
f"lock claimant '{locked_identity}' does not match active identity "
|
||||||
|
f"'{_text(identity)}'"
|
||||||
|
)
|
||||||
|
if locked_profile and _text(profile) and locked_profile != _text(profile):
|
||||||
|
reasons.append(
|
||||||
|
f"lock profile '{locked_profile}' does not match active profile "
|
||||||
|
f"'{_text(profile)}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── The defining condition: the recorded owner must be dead ─────────────
|
||||||
|
prior_alive = is_process_alive(recorded_pid)
|
||||||
|
evidence["prior_pid_alive"] = prior_alive
|
||||||
|
if prior_alive:
|
||||||
|
reasons.append(
|
||||||
|
f"prior owner pid {recorded_pid} is still alive; this is not a "
|
||||||
|
"dead-session recovery"
|
||||||
|
)
|
||||||
|
if current_pid is not None and recorded_pid is not None:
|
||||||
|
try:
|
||||||
|
if int(recorded_pid) == int(current_pid):
|
||||||
|
reasons.append(
|
||||||
|
"recorded pid is the current session; nothing to recover"
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Competing ownership ─────────────────────────────────────────────────
|
||||||
|
competing: list[dict[str, Any]] = []
|
||||||
|
for entry in competing_live_locks or ():
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
same_issue = entry.get("issue_number") == issue_number
|
||||||
|
same_branch = _text(entry.get("branch_name")) == locked_branch
|
||||||
|
if not (same_issue or same_branch):
|
||||||
|
continue
|
||||||
|
# The lock we are recovering is not competition with itself.
|
||||||
|
if (
|
||||||
|
same_issue
|
||||||
|
and same_branch
|
||||||
|
and _same_realpath(_text(entry.get("worktree_path")), worktree_path)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
competing.append(
|
||||||
|
{
|
||||||
|
"issue_number": entry.get("issue_number"),
|
||||||
|
"branch_name": entry.get("branch_name"),
|
||||||
|
"worktree_path": entry.get("worktree_path"),
|
||||||
|
"pid": entry.get("pid"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if competing:
|
||||||
|
described = ", ".join(
|
||||||
|
f"issue #{c['issue_number']} branch '{c['branch_name']}'" for c in competing
|
||||||
|
)
|
||||||
|
reasons.append(f"competing live lock or lease exists ({described})")
|
||||||
|
evidence["competing_live_locks"] = competing
|
||||||
|
|
||||||
|
# ── Ambiguous branch claims ─────────────────────────────────────────────
|
||||||
|
others = [
|
||||||
|
name
|
||||||
|
for name in (candidate_branches or ())
|
||||||
|
if _text(name) and _text(name) != locked_branch
|
||||||
|
]
|
||||||
|
if others:
|
||||||
|
reasons.append(
|
||||||
|
"multiple branches claim this issue "
|
||||||
|
f"({', '.join(sorted(set(others)))}); ownership is ambiguous"
|
||||||
|
)
|
||||||
|
evidence["other_candidate_branches"] = sorted(set(others))
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return _result(REFUSED, False, reasons, evidence)
|
||||||
|
|
||||||
|
return _result(
|
||||||
|
RECOVERY_SANCTIONED,
|
||||||
|
True,
|
||||||
|
[
|
||||||
|
f"durable lock for issue #{issue_number} matches branch "
|
||||||
|
f"'{locked_branch}', worktree '{locked_worktree}', head {local_head}, "
|
||||||
|
f"and claimant '{locked_identity}'; recorded pid {recorded_pid} is dead"
|
||||||
|
],
|
||||||
|
evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
outcome: str,
|
||||||
|
sanctioned: bool,
|
||||||
|
reasons: list[str],
|
||||||
|
evidence: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"outcome": outcome,
|
||||||
|
"recovery_sanctioned": sanctioned,
|
||||||
|
"is_candidate": outcome != NO_CANDIDATE,
|
||||||
|
"reasons": reasons,
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def owning_pr_recovery_evidence(
|
||||||
|
assessment: Mapping[str, Any] | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Server-derived proof of the open PR a sanctioned recovery already owns (#755).
|
||||||
|
|
||||||
|
A dead-session recovery is, by construction, recovery of work that already
|
||||||
|
has an open PR — so the duplicate-work gate's linked-open-PR blocker would
|
||||||
|
otherwise discard every sanctioned recovery. This distils the completed
|
||||||
|
assessment into the minimum evidence that gate needs to tell "the PR this
|
||||||
|
lock already owns" apart from "a competing duplicate PR".
|
||||||
|
|
||||||
|
Returns ``None`` unless recovery was actually granted and the assessment's
|
||||||
|
own evidence names exactly one owning PR whose head agrees with the local
|
||||||
|
and remote heads. Nothing here is caller-supplied: every field is copied
|
||||||
|
from evidence the assessor built out of durable lock state plus live
|
||||||
|
git/Gitea observation, so a caller cannot manufacture an exemption.
|
||||||
|
"""
|
||||||
|
if not isinstance(assessment, Mapping):
|
||||||
|
return None
|
||||||
|
if assessment.get("outcome") != RECOVERY_SANCTIONED:
|
||||||
|
return None
|
||||||
|
if not assessment.get("recovery_sanctioned"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
evidence = assessment.get("evidence") or {}
|
||||||
|
branch_name = _text(evidence.get("locked_branch"))
|
||||||
|
pr_head = _text(evidence.get("pr_head"))
|
||||||
|
local_head = _text(evidence.get("local_head"))
|
||||||
|
remote_head = _text(evidence.get("remote_head"))
|
||||||
|
raw_pr_number = evidence.get("pr_number")
|
||||||
|
|
||||||
|
if raw_pr_number is None or not branch_name or not pr_head:
|
||||||
|
return None
|
||||||
|
# The assessor already required these to agree. Re-check, so a truncated or
|
||||||
|
# hand-built evidence map can never authorize an exemption.
|
||||||
|
if pr_head != local_head or pr_head != remote_head:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pr_number = int(raw_pr_number)
|
||||||
|
issue_number = int(evidence.get("issue_number"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"head_sha": pr_head,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_recovery_record(
|
||||||
|
assessment: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
recovered_at: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Durable, secret-free provenance for a completed recovery (#753 AC2/AC6)."""
|
||||||
|
evidence = dict(assessment.get("evidence") or {})
|
||||||
|
return {
|
||||||
|
"recovered": True,
|
||||||
|
"reason": "owning MCP session exited; durable ownership evidence matched",
|
||||||
|
"recovered_at": recovered_at,
|
||||||
|
"prior_session_pid": evidence.get("prior_session_pid"),
|
||||||
|
"replacement_session_pid": evidence.get("replacement_session_pid"),
|
||||||
|
"prior_pid_alive": evidence.get("prior_pid_alive"),
|
||||||
|
"branch_name": evidence.get("locked_branch"),
|
||||||
|
"worktree_path": evidence.get("locked_worktree_path"),
|
||||||
|
"local_head": evidence.get("local_head"),
|
||||||
|
"remote_head": evidence.get("remote_head"),
|
||||||
|
"pr_head": evidence.get("pr_head"),
|
||||||
|
"pr_number": evidence.get("pr_number"),
|
||||||
|
"identity": evidence.get("locked_identity"),
|
||||||
|
"profile": evidence.get("locked_profile"),
|
||||||
|
"proof": list(assessment.get("reasons") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_recovery_refusal(assessment: Mapping[str, Any]) -> str:
|
||||||
|
"""Single fail-closed message for a refused recovery attempt."""
|
||||||
|
reasons = list(assessment.get("reasons") or []) or [
|
||||||
|
"dead-session lock recovery evidence did not agree"
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
"Dead-session issue-lock recovery refused: "
|
||||||
|
+ "; ".join(reasons)
|
||||||
|
+ " (fail closed)"
|
||||||
|
)
|
||||||
+21
-2
@@ -92,8 +92,19 @@ def assess_issue_lock_worktree(
|
|||||||
inspected_git_root: str | None = None,
|
inspected_git_root: str | None = None,
|
||||||
base_branch: str | None = None,
|
base_branch: str | None = None,
|
||||||
base_branches: frozenset[str] | None = None,
|
base_branches: frozenset[str] | None = None,
|
||||||
|
recovery_sanctioned: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Fail closed when lock preconditions are not met on the declared worktree."""
|
"""Fail closed when lock preconditions are not met on the declared worktree.
|
||||||
|
|
||||||
|
``recovery_sanctioned`` is set only when ``issue_lock_recovery`` has already
|
||||||
|
proven, from the durable lock itself, that this is a dead-session recovery of
|
||||||
|
an existing claim (#753): same issue, branch, worktree, author, and head, with
|
||||||
|
the recording process dead. In that one case the base-equivalence requirement
|
||||||
|
is waived, because a branch that already carries the work is ahead of its base
|
||||||
|
by construction and could never satisfy it. Every other precondition —
|
||||||
|
notably worktree cleanliness — still applies unchanged, and brand-new issue
|
||||||
|
claims keep the full base-equivalence requirement.
|
||||||
|
"""
|
||||||
bases = base_branches or BASE_BRANCHES
|
bases = base_branches or BASE_BRANCHES
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
path = (worktree_path or "").strip()
|
path = (worktree_path or "").strip()
|
||||||
@@ -111,7 +122,11 @@ def assess_issue_lock_worktree(
|
|||||||
f"(dirty files: {', '.join(dirty_files)})"
|
f"(dirty files: {', '.join(dirty_files)})"
|
||||||
)
|
)
|
||||||
|
|
||||||
if base_equivalent is False:
|
if recovery_sanctioned:
|
||||||
|
# Base-equivalence intentionally not evaluated: ownership was proven
|
||||||
|
# against the durable lock record instead (#753).
|
||||||
|
pass
|
||||||
|
elif base_equivalent is False:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"issue lock worktree must be base-equivalent to one of "
|
"issue lock worktree must be base-equivalent to one of "
|
||||||
f"{_base_list(bases)} before implementation work; inspected "
|
f"{_base_list(bases)} before implementation work; inspected "
|
||||||
@@ -139,6 +154,7 @@ def assess_issue_lock_worktree(
|
|||||||
inspected_git_root=inspected_git_root,
|
inspected_git_root=inspected_git_root,
|
||||||
base_branch=base_branch,
|
base_branch=base_branch,
|
||||||
base_equivalent=base_equivalent,
|
base_equivalent=base_equivalent,
|
||||||
|
recovery_sanctioned=recovery_sanctioned,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -197,6 +213,7 @@ def _assessment(
|
|||||||
inspected_git_root: str | None = None,
|
inspected_git_root: str | None = None,
|
||||||
base_branch: str | None = None,
|
base_branch: str | None = None,
|
||||||
base_equivalent: bool | None = None,
|
base_equivalent: bool | None = None,
|
||||||
|
recovery_sanctioned: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"proven": proven,
|
"proven": proven,
|
||||||
@@ -208,6 +225,8 @@ def _assessment(
|
|||||||
"dirty_files": dirty_files,
|
"dirty_files": dirty_files,
|
||||||
"base_branch": base_branch,
|
"base_branch": base_branch,
|
||||||
"base_equivalent": base_equivalent,
|
"base_equivalent": base_equivalent,
|
||||||
|
"recovery_sanctioned": recovery_sanctioned,
|
||||||
|
"base_equivalence_waived": bool(recovery_sanctioned),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Mapping
|
||||||
|
|
||||||
import issue_claim_heartbeat as claim_hb
|
import issue_claim_heartbeat as claim_hb
|
||||||
|
|
||||||
@@ -27,6 +27,116 @@ def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
|||||||
return claim_hb._linked_open_pr(issue_number, open_prs)
|
return claim_hb._linked_open_pr(issue_number, open_prs)
|
||||||
|
|
||||||
|
|
||||||
|
def _pr_links_issue(issue_number: int, pr: Mapping[str, Any]) -> bool:
|
||||||
|
"""Same linkage rule ``claim_hb._linked_open_pr`` applies, per PR.
|
||||||
|
|
||||||
|
``_linked_open_pr`` only yields the *first* match, which cannot answer
|
||||||
|
"is there exactly one linked PR?" — a question the owning-PR exemption
|
||||||
|
below must answer before it can trust any of them.
|
||||||
|
"""
|
||||||
|
pattern = _issue_pattern(issue_number)
|
||||||
|
head = (pr.get("head") or {}).get("ref") or ""
|
||||||
|
text = f"{pr.get('title', '')} {pr.get('body', '')}".lower()
|
||||||
|
if pattern in head.lower():
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
f"closes #{int(issue_number)}" in text
|
||||||
|
or f"fixes #{int(issue_number)}" in text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _all_linked_open_prs(
|
||||||
|
issue_number: int, open_prs: list[dict]
|
||||||
|
) -> list[Mapping[str, Any]]:
|
||||||
|
return [pr for pr in (open_prs or []) if _pr_links_issue(issue_number, pr)]
|
||||||
|
|
||||||
|
|
||||||
|
def _assess_owning_pr_exemption(
|
||||||
|
issue_number: int,
|
||||||
|
*,
|
||||||
|
linked_open_prs: list[Mapping[str, Any]],
|
||||||
|
locked_branch: str | None,
|
||||||
|
recovered_owning_pr: Mapping[str, Any] | None,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Is the linked open PR provably the one a sanctioned recovery owns (#755)?
|
||||||
|
|
||||||
|
``recovered_owning_pr`` is produced by
|
||||||
|
``issue_lock_recovery.owning_pr_recovery_evidence`` from a completed
|
||||||
|
server-side recovery assessment — it is never a caller-supplied field.
|
||||||
|
Every element is re-checked here against the live PR list this gate was
|
||||||
|
given, so a stale or partial token cannot widen the exemption.
|
||||||
|
|
||||||
|
Returns ``(exempt, diagnostic_reasons)``. Diagnostics are only emitted when
|
||||||
|
a token was offered and rejected, so a blocked caller can see which element
|
||||||
|
of ownership disagreed.
|
||||||
|
"""
|
||||||
|
if not recovered_owning_pr:
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
notes: list[str] = []
|
||||||
|
token_issue = recovered_owning_pr.get("issue_number")
|
||||||
|
token_pr = recovered_owning_pr.get("pr_number")
|
||||||
|
token_branch = str(recovered_owning_pr.get("branch_name") or "").strip()
|
||||||
|
token_head = str(recovered_owning_pr.get("head_sha") or "").strip()
|
||||||
|
locked = (locked_branch or "").strip()
|
||||||
|
|
||||||
|
if token_issue is not None and int(token_issue) != int(issue_number):
|
||||||
|
notes.append(
|
||||||
|
f"recovery evidence is for issue #{token_issue}, not "
|
||||||
|
f"#{issue_number} (no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
if not locked or not token_branch or locked != token_branch:
|
||||||
|
notes.append(
|
||||||
|
f"recovery evidence branch '{token_branch or 'unknown'}' does not "
|
||||||
|
f"match the branch being locked '{locked or 'unknown'}' "
|
||||||
|
"(no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
if len(linked_open_prs) != 1:
|
||||||
|
numbers = ", ".join(
|
||||||
|
f"#{pr.get('number')}" for pr in linked_open_prs
|
||||||
|
) or "none"
|
||||||
|
notes.append(
|
||||||
|
f"{len(linked_open_prs)} open PRs link issue #{issue_number} "
|
||||||
|
f"({numbers}); recovery may only own exactly one "
|
||||||
|
"(no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
|
||||||
|
only = linked_open_prs[0]
|
||||||
|
head_obj = only.get("head") or {}
|
||||||
|
only_number = only.get("number")
|
||||||
|
only_ref = str(head_obj.get("ref") or "").strip()
|
||||||
|
only_sha = str(head_obj.get("sha") or "").strip()
|
||||||
|
|
||||||
|
if token_pr is None or only_number is None or int(only_number) != int(token_pr):
|
||||||
|
notes.append(
|
||||||
|
f"linked open PR #{only_number} is not the recovered owning PR "
|
||||||
|
f"#{token_pr} (no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
if only_ref != token_branch:
|
||||||
|
notes.append(
|
||||||
|
f"open PR #{only_number} head branch '{only_ref}' does not match "
|
||||||
|
f"the recovered branch '{token_branch}' (no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
if not token_head or not only_sha or only_sha != token_head:
|
||||||
|
notes.append(
|
||||||
|
f"open PR #{only_number} head {only_sha or 'unknown'} does not "
|
||||||
|
f"match the recovered head {token_head or 'unknown'} "
|
||||||
|
"(no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
|
||||||
|
return True, [
|
||||||
|
f"open PR #{only_number} is the exact PR already owned by the "
|
||||||
|
f"recovering lock for issue #{issue_number} (branch '{token_branch}', "
|
||||||
|
f"head {token_head}); not duplicate work"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _matching_branches(
|
def _matching_branches(
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
branch_names: list[str],
|
branch_names: list[str],
|
||||||
@@ -52,8 +162,15 @@ def assess_work_issue_duplicate_gate(
|
|||||||
claim_entry: dict | None = None,
|
claim_entry: dict | None = None,
|
||||||
locked_branch: str | None = None,
|
locked_branch: str | None = None,
|
||||||
phase: str = PHASE_LOCK,
|
phase: str = PHASE_LOCK,
|
||||||
|
recovered_owning_pr: Mapping[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Fail closed when duplicate work is already in flight for an issue."""
|
"""Fail closed when duplicate work is already in flight for an issue.
|
||||||
|
|
||||||
|
``recovered_owning_pr`` (#755) is server-derived evidence that a sanctioned
|
||||||
|
dead-session lock recovery already owns one specific open PR. It exempts
|
||||||
|
*only* that exact PR from the linked-open-PR blocker; every other duplicate
|
||||||
|
signal, and every mismatch, keeps failing closed.
|
||||||
|
"""
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
||||||
prs = list(open_prs or [])
|
prs = list(open_prs or [])
|
||||||
@@ -61,12 +178,23 @@ def assess_work_issue_duplicate_gate(
|
|||||||
pattern = _issue_pattern(issue_number)
|
pattern = _issue_pattern(issue_number)
|
||||||
|
|
||||||
linked = _linked_open_pr(issue_number, prs)
|
linked = _linked_open_pr(issue_number, prs)
|
||||||
|
linked_open_prs = _all_linked_open_prs(issue_number, prs)
|
||||||
|
owning_pr_exempted = False
|
||||||
|
exemption_notes: list[str] = []
|
||||||
if linked:
|
if linked:
|
||||||
reasons.append(
|
owning_pr_exempted, exemption_notes = _assess_owning_pr_exemption(
|
||||||
f"open PR #{linked.get('number')} already covers issue "
|
issue_number,
|
||||||
f"#{issue_number} (fail closed)"
|
linked_open_prs=linked_open_prs,
|
||||||
|
locked_branch=locked_branch,
|
||||||
|
recovered_owning_pr=recovered_owning_pr,
|
||||||
)
|
)
|
||||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
if not owning_pr_exempted:
|
||||||
|
reasons.append(
|
||||||
|
f"open PR #{linked.get('number')} already covers issue "
|
||||||
|
f"#{issue_number} (fail closed)"
|
||||||
|
)
|
||||||
|
reasons.extend(exemption_notes)
|
||||||
|
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||||
|
|
||||||
conflicting_branches = _matching_branches(
|
conflicting_branches = _matching_branches(
|
||||||
issue_number, branches, locked_branch=locked_branch
|
issue_number, branches, locked_branch=locked_branch
|
||||||
@@ -122,6 +250,9 @@ def assess_work_issue_duplicate_gate(
|
|||||||
"phase": phase,
|
"phase": phase,
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
|
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
|
||||||
|
"linked_open_pr_count": len(linked_open_prs),
|
||||||
|
"owning_pr_recovery_exempted": owning_pr_exempted,
|
||||||
|
"owning_pr_recovery_notes": list(exemption_notes),
|
||||||
"conflicting_branches": conflicting_branches,
|
"conflicting_branches": conflicting_branches,
|
||||||
"claim_status": status or None,
|
"claim_status": status or None,
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
|
|||||||
@@ -166,3 +166,128 @@ def format_parity(assessment: dict) -> str:
|
|||||||
if not assessment.get("determinable"):
|
if not assessment.get("determinable"):
|
||||||
return "parity indeterminate (baseline or current HEAD unknown)"
|
return "parity indeterminate (baseline or current HEAD unknown)"
|
||||||
return f"in parity at {_short(assessment.get('current_head'))}"
|
return f"in parity at {_short(assessment.get('current_head'))}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Target-repository parity (#739 F3)
|
||||||
|
#
|
||||||
|
# Everything above measures ONE dimension: the Gitea-Tools server's own
|
||||||
|
# implementation commit, comparing the SHA this process was loaded from against
|
||||||
|
# the SHA now on disk at PROJECT_ROOT. That is deliberate and is left untouched
|
||||||
|
# — it is what proves the in-memory capability gates are current.
|
||||||
|
#
|
||||||
|
# It is not, however, a statement about the repository a cross-repository
|
||||||
|
# namespace actually mutates. The assessment below is a separate, separately
|
||||||
|
# labelled dimension for the configured canonical target repository. It never
|
||||||
|
# feeds the mutation gate and never changes startup_head/current_head.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DEFAULT_TARGET_TRACKING_REF = "refs/remotes/origin/master"
|
||||||
|
|
||||||
|
|
||||||
|
def _git_capture(root: str, *args: str) -> str | None:
|
||||||
|
"""Run a read-only git command in *root*; ``None`` on any failure.
|
||||||
|
|
||||||
|
Deliberately does not honour ``GITEA_TEST_CURRENT_HEAD``: that override
|
||||||
|
exists to pin the *server's* HEAD, and applying it here would make a target
|
||||||
|
repository silently report the server's forced SHA.
|
||||||
|
"""
|
||||||
|
if not root:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", root, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if res.returncode != 0:
|
||||||
|
return None
|
||||||
|
return (res.stdout or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_target_repository_parity(
|
||||||
|
*,
|
||||||
|
canonical_root: str | None,
|
||||||
|
source: str | None,
|
||||||
|
tracking_ref: str = DEFAULT_TARGET_TRACKING_REF,
|
||||||
|
) -> dict:
|
||||||
|
"""Assess the configured cross-repository target checkout.
|
||||||
|
|
||||||
|
Reports the target's canonical root, repository identity, checked-out
|
||||||
|
commit, and last-known remote master commit, plus whether the checkout is
|
||||||
|
behind that ref. No network call is made: the remote side is read from the
|
||||||
|
existing remote-tracking ref, so a target that has never been fetched is
|
||||||
|
reported as indeterminate rather than guessed at.
|
||||||
|
|
||||||
|
An unconfigured namespace is ``configured=False`` and never ``stale`` — the
|
||||||
|
single-repository default has no second dimension to be stale about. A
|
||||||
|
configured root that cannot be read is ``determinable=False`` with reasons.
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"configured": bool(canonical_root),
|
||||||
|
"canonical_repository_root": None,
|
||||||
|
"source": source,
|
||||||
|
"repository_slug": None,
|
||||||
|
"checkout_head": None,
|
||||||
|
"tracking_ref": tracking_ref,
|
||||||
|
"remote_tracking_head": None,
|
||||||
|
"determinable": False,
|
||||||
|
"stale": False,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
if not canonical_root:
|
||||||
|
result["determinable"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["canonical_repository_root"] = canonical_root
|
||||||
|
if not os.path.isdir(canonical_root):
|
||||||
|
result["reasons"].append(
|
||||||
|
f"configured canonical repository root '{canonical_root}' "
|
||||||
|
f"does not exist or is not a directory"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
toplevel = _git_capture(canonical_root, "rev-parse", "--show-toplevel")
|
||||||
|
if not toplevel:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"configured canonical repository root '{canonical_root}' "
|
||||||
|
f"is not a git checkout"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
head = _git_capture(canonical_root, "rev-parse", "HEAD")
|
||||||
|
if not head:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"target repository HEAD could not be read at '{canonical_root}'"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
result["checkout_head"] = head
|
||||||
|
result["determinable"] = True
|
||||||
|
|
||||||
|
remote_url = _git_capture(canonical_root, "remote", "get-url", "origin")
|
||||||
|
if remote_url:
|
||||||
|
# Local import keeps this module dependency-light for its startup role.
|
||||||
|
import remote_repo_guard
|
||||||
|
|
||||||
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(remote_url)
|
||||||
|
if parsed:
|
||||||
|
result["repository_slug"] = f"{parsed[0]}/{parsed[1]}"
|
||||||
|
if not result["repository_slug"]:
|
||||||
|
result["reasons"].append(
|
||||||
|
"target repository identity could not be derived from its git remote"
|
||||||
|
)
|
||||||
|
|
||||||
|
tracking_head = _git_capture(canonical_root, "rev-parse", tracking_ref)
|
||||||
|
if not tracking_head:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"remote-tracking ref '{tracking_ref}' is unknown in the target "
|
||||||
|
f"checkout; target staleness is indeterminate (no fetch is "
|
||||||
|
f"performed by this assessment)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
result["remote_tracking_head"] = tracking_head
|
||||||
|
result["stale"] = tracking_head != head
|
||||||
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Reconciler authorization gate for post-merge moot-lease cleanup (#745).
|
||||||
|
|
||||||
|
``gitea_cleanup_post_merge_moot_lease`` (#515) posts a terminal ``phase:
|
||||||
|
released`` lease marker — a real, durable mutation of the PR lease ledger.
|
||||||
|
Before #745 it was gated on permissions alone (``gitea.read`` to enter,
|
||||||
|
``gitea.pr.comment`` to apply) with no canonical task and no role binding, so
|
||||||
|
any profile carrying ``gitea.pr.comment`` reached the mutation path while the
|
||||||
|
reconciler could not satisfy the operator-required resolve-exact-task ->
|
||||||
|
mutation sequence.
|
||||||
|
|
||||||
|
This module holds the pure half of that gate:
|
||||||
|
|
||||||
|
* the canonical task name and its tool-name alias;
|
||||||
|
* an **append-only** in-process ledger of read-only dry-run assessments;
|
||||||
|
* ``assess_apply_authorization``, which decides whether an apply may proceed.
|
||||||
|
|
||||||
|
Apply is authorized only when all of the following hold:
|
||||||
|
|
||||||
|
* the session resolved exactly the cleanup task (no other task substitutes);
|
||||||
|
* the active profile role is ``reconciler``;
|
||||||
|
* a prior dry run in this session recorded ``lease_moot`` and
|
||||||
|
``cleanup_allowed`` for the *same* repository, PR, lease session, candidate
|
||||||
|
head and lease marker id;
|
||||||
|
* the live assessment still agrees with that evidence, so a lease superseded
|
||||||
|
between the dry run and the apply fails closed;
|
||||||
|
* any caller-supplied expectations match the live lease exactly.
|
||||||
|
|
||||||
|
Everything else fails closed. The ledger is only ever appended to — a
|
||||||
|
superseded dry run stays visible as history instead of being rewritten — which
|
||||||
|
keeps the cleanup audit trail append-only end to end.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CLEANUP_TASK = "cleanup_post_merge_moot_lease"
|
||||||
|
CLEANUP_TOOL_ALIAS = "gitea_cleanup_post_merge_moot_lease"
|
||||||
|
REQUIRED_ROLE = "reconciler"
|
||||||
|
REQUIRED_PERMISSION = "gitea.pr.comment"
|
||||||
|
|
||||||
|
# The read-only assessment stays reachable under gitea.read for every role —
|
||||||
|
# the convention shared with cleanup_stale_review_decision_lock and
|
||||||
|
# cleanup_obsolete_reviewer_comment_lease — so any namespace can diagnose a
|
||||||
|
# stuck lease. Only the apply path demands CLEANUP_TASK + REQUIRED_ROLE.
|
||||||
|
ASSESSMENT_PERMISSION = "gitea.read"
|
||||||
|
|
||||||
|
_DRY_RUN_LEDGER: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_comment_id(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def record_dry_run(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
repository_slug: str | None,
|
||||||
|
lease_moot: bool,
|
||||||
|
cleanup_allowed: bool,
|
||||||
|
session_id: str | None,
|
||||||
|
candidate_head: str | None,
|
||||||
|
lease_comment_id: Any,
|
||||||
|
recorded_at: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Append one read-only assessment to the dry-run ledger.
|
||||||
|
|
||||||
|
Never rewrites or removes a prior entry: repeated dry runs accumulate and
|
||||||
|
``latest_dry_run`` returns the newest matching one.
|
||||||
|
"""
|
||||||
|
entry = {
|
||||||
|
"task": CLEANUP_TASK,
|
||||||
|
"pr_number": int(pr_number),
|
||||||
|
"repository_slug": _norm(repository_slug) or None,
|
||||||
|
"lease_moot": bool(lease_moot),
|
||||||
|
"cleanup_allowed": bool(cleanup_allowed),
|
||||||
|
"session_id": _norm(session_id) or None,
|
||||||
|
"candidate_head": _norm(candidate_head) or None,
|
||||||
|
"lease_comment_id": _norm_comment_id(lease_comment_id),
|
||||||
|
"recorded_at": (recorded_at or datetime.now(timezone.utc)).isoformat(),
|
||||||
|
}
|
||||||
|
_DRY_RUN_LEDGER.append(entry)
|
||||||
|
return dict(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def dry_run_history() -> tuple[dict[str, Any], ...]:
|
||||||
|
"""Immutable view of every recorded dry run, oldest first."""
|
||||||
|
return tuple(dict(entry) for entry in _DRY_RUN_LEDGER)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_dry_run(
|
||||||
|
*, pr_number: int, repository_slug: str | None
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Newest dry-run evidence for this repository + PR, or None."""
|
||||||
|
wanted_repo = _norm(repository_slug)
|
||||||
|
for entry in reversed(_DRY_RUN_LEDGER):
|
||||||
|
if entry["pr_number"] != int(pr_number):
|
||||||
|
continue
|
||||||
|
if _norm(entry.get("repository_slug")) != wanted_repo:
|
||||||
|
continue
|
||||||
|
return dict(entry)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_for_testing() -> None:
|
||||||
|
"""Drop ledger state between tests. Never called by production paths."""
|
||||||
|
_DRY_RUN_LEDGER.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def assess_apply_authorization(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
repository_slug: str | None,
|
||||||
|
resolved_task: str | None,
|
||||||
|
active_role_kind: str | None,
|
||||||
|
assessment: dict[str, Any],
|
||||||
|
evidence: dict[str, Any] | None,
|
||||||
|
expected_session_id: str | None = None,
|
||||||
|
expected_candidate_head: str | None = None,
|
||||||
|
expected_lease_comment_id: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Decide whether a moot-lease cleanup apply is authorized (fail closed).
|
||||||
|
|
||||||
|
Returns ``{"allowed", "reasons", "blocker_kind", "evidence_matched", ...}``.
|
||||||
|
``allowed`` is True only when every check passes; each failure contributes a
|
||||||
|
reason so the caller can report all of them together.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
blocker_kind: str | None = None
|
||||||
|
|
||||||
|
def _block(kind: str, reason: str) -> None:
|
||||||
|
nonlocal blocker_kind
|
||||||
|
reasons.append(reason)
|
||||||
|
if blocker_kind is None:
|
||||||
|
blocker_kind = kind
|
||||||
|
|
||||||
|
# 1. Exact resolved cleanup task. Resolving any other task — including a
|
||||||
|
# sibling reconciler task — does not authorize this mutation.
|
||||||
|
if _norm(resolved_task) != CLEANUP_TASK:
|
||||||
|
_block(
|
||||||
|
"unresolved_cleanup_task",
|
||||||
|
"post-merge moot-lease cleanup requires the session to resolve "
|
||||||
|
f"task '{CLEANUP_TASK}' immediately before apply; resolved task is "
|
||||||
|
f"{resolved_task!r} (fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Dedicated reconciler role, enforced independently of the permission.
|
||||||
|
if _norm(active_role_kind) != REQUIRED_ROLE:
|
||||||
|
_block(
|
||||||
|
"wrong_role",
|
||||||
|
f"profile role {active_role_kind!r} cannot apply post-merge "
|
||||||
|
f"moot-lease cleanup; required role is {REQUIRED_ROLE} even when "
|
||||||
|
f"{REQUIRED_PERMISSION} is present (fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Canonical repository identity must be established, never inferred from
|
||||||
|
# request parameters.
|
||||||
|
if not _norm(repository_slug):
|
||||||
|
_block(
|
||||||
|
"repository_binding",
|
||||||
|
"no canonical repository identity could be established for the "
|
||||||
|
"cleanup target (fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. The live safety assessment must still say the lease is moot/cleanable.
|
||||||
|
if not assessment.get("is_moot") or not assessment.get("cleanup_allowed"):
|
||||||
|
_block(
|
||||||
|
"lease_not_moot",
|
||||||
|
"live assessment does not report a moot, cleanable lease on PR "
|
||||||
|
f"#{pr_number} (lease_moot={bool(assessment.get('is_moot'))}, "
|
||||||
|
f"cleanup_allowed={bool(assessment.get('cleanup_allowed'))}) "
|
||||||
|
"(fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
live = assessment.get("active_lease") or {}
|
||||||
|
live_session = _norm(live.get("session_id"))
|
||||||
|
live_head = _norm(live.get("candidate_head"))
|
||||||
|
live_comment_id = _norm_comment_id(live.get("comment_id"))
|
||||||
|
|
||||||
|
# 5. A lease missing identifying fields is malformed and unsafe to act on.
|
||||||
|
if not live_session or not live_head or live_comment_id is None:
|
||||||
|
_block(
|
||||||
|
"malformed_lease",
|
||||||
|
"active lease is malformed: session_id / candidate_head / "
|
||||||
|
"comment_id must all be present to authorize cleanup "
|
||||||
|
f"(session_id={live.get('session_id')!r}, "
|
||||||
|
f"candidate_head={live.get('candidate_head')!r}, "
|
||||||
|
f"comment_id={live.get('comment_id')!r}) (fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Caller expectations, when supplied, must match the live lease exactly.
|
||||||
|
if expected_session_id is not None and _norm(expected_session_id) != live_session:
|
||||||
|
_block(
|
||||||
|
"lease_mismatch",
|
||||||
|
f"expected lease session {expected_session_id!r} does not match the "
|
||||||
|
f"live lease session {live.get('session_id')!r} (fail closed)",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
expected_candidate_head is not None
|
||||||
|
and _norm(expected_candidate_head) != live_head
|
||||||
|
):
|
||||||
|
_block(
|
||||||
|
"lease_mismatch",
|
||||||
|
f"expected candidate head {expected_candidate_head!r} does not "
|
||||||
|
f"match the live lease head {live.get('candidate_head')!r} "
|
||||||
|
"(fail closed)",
|
||||||
|
)
|
||||||
|
if expected_lease_comment_id is not None and (
|
||||||
|
_norm_comment_id(expected_lease_comment_id) != live_comment_id
|
||||||
|
):
|
||||||
|
_block(
|
||||||
|
"lease_mismatch",
|
||||||
|
f"expected lease marker {expected_lease_comment_id!r} does not "
|
||||||
|
f"match the live lease marker {live.get('comment_id')!r} "
|
||||||
|
"(fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7. Matching dry-run evidence recorded earlier in this session.
|
||||||
|
evidence_matched = False
|
||||||
|
if evidence is None:
|
||||||
|
_block(
|
||||||
|
"missing_dry_run_evidence",
|
||||||
|
"no read-only dry run recorded for this repository and PR; run the "
|
||||||
|
"tool with apply=false and confirm lease_moot / cleanup_allowed "
|
||||||
|
"before applying (fail closed)",
|
||||||
|
)
|
||||||
|
elif not evidence.get("lease_moot") or not evidence.get("cleanup_allowed"):
|
||||||
|
_block(
|
||||||
|
"dry_run_not_allowed",
|
||||||
|
"recorded dry run did not report an allowed cleanup "
|
||||||
|
f"(lease_moot={bool(evidence.get('lease_moot'))}, "
|
||||||
|
f"cleanup_allowed={bool(evidence.get('cleanup_allowed'))}) "
|
||||||
|
"(fail closed)",
|
||||||
|
)
|
||||||
|
elif int(evidence.get("pr_number") or -1) != int(pr_number) or _norm(
|
||||||
|
evidence.get("repository_slug")
|
||||||
|
) != _norm(repository_slug):
|
||||||
|
_block(
|
||||||
|
"dry_run_mismatch",
|
||||||
|
"recorded dry run targets a different repository or PR "
|
||||||
|
f"({evidence.get('repository_slug')}#{evidence.get('pr_number')} vs "
|
||||||
|
f"{repository_slug}#{pr_number}) (fail closed)",
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
_norm(evidence.get("session_id")) != live_session
|
||||||
|
or _norm(evidence.get("candidate_head")) != live_head
|
||||||
|
or _norm_comment_id(evidence.get("lease_comment_id")) != live_comment_id
|
||||||
|
):
|
||||||
|
_block(
|
||||||
|
"superseded_lease",
|
||||||
|
"the lease changed after the recorded dry run (dry run: "
|
||||||
|
f"session={evidence.get('session_id')!r}, "
|
||||||
|
f"head={evidence.get('candidate_head')!r}, "
|
||||||
|
f"marker={evidence.get('lease_comment_id')!r}; live: "
|
||||||
|
f"session={live.get('session_id')!r}, "
|
||||||
|
f"head={live.get('candidate_head')!r}, "
|
||||||
|
f"marker={live.get('comment_id')!r}); re-run the dry run "
|
||||||
|
"(fail closed)",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
evidence_matched = True
|
||||||
|
|
||||||
|
return {
|
||||||
|
"allowed": not reasons,
|
||||||
|
"reasons": reasons,
|
||||||
|
"blocker_kind": blocker_kind,
|
||||||
|
"evidence_matched": evidence_matched,
|
||||||
|
"required_task": CLEANUP_TASK,
|
||||||
|
"required_role_kind": REQUIRED_ROLE,
|
||||||
|
"required_permission": REQUIRED_PERMISSION,
|
||||||
|
}
|
||||||
+213
-12
@@ -41,6 +41,186 @@ _DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "
|
|||||||
UPDATE_STYLE_MERGE = "merge"
|
UPDATE_STYLE_MERGE = "merge"
|
||||||
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
||||||
|
|
||||||
|
# ── Commit check classifications (#751) ──────────────────────────────────
|
||||||
|
# Gitea's *combined* commit status reports ``state: pending`` both when a real
|
||||||
|
# check is executing and when the status-context collection is empty. Reading
|
||||||
|
# ``state`` alone therefore cannot distinguish "CI is running" from "no CI
|
||||||
|
# exists", which permanently blocks a merge-ready PR that no check will ever
|
||||||
|
# report on. These classifications are derived from the actual context
|
||||||
|
# collection plus the live branch-protection policy.
|
||||||
|
CHECKS_SUCCESS = "success"
|
||||||
|
CHECKS_FAILURE = "failure"
|
||||||
|
CHECKS_PENDING = "pending"
|
||||||
|
CHECKS_NONE = "none" # configured/produced nothing
|
||||||
|
CHECKS_NOT_REQUIRED = "not_required" # protection does not require checks
|
||||||
|
CHECKS_MISSING_REQUIRED = "missing_required" # required contexts have no result
|
||||||
|
CHECKS_UNKNOWN = "unknown" # indeterminable — fail closed
|
||||||
|
|
||||||
|
# Values that permit merge_now when checks are required.
|
||||||
|
_CHECKS_OK = frozenset({"success", "passed", "ok", "skipped", "not_required"})
|
||||||
|
|
||||||
|
# Raw per-context state vocabularies reported by Gitea.
|
||||||
|
_CTX_SUCCESS = frozenset({"success", "passed", "ok"})
|
||||||
|
_CTX_FAILURE = frozenset({"failure", "failed", "error", "cancelled", "canceled"})
|
||||||
|
_CTX_PENDING = frozenset({"pending", "running", "queued", "expected"})
|
||||||
|
_CTX_SKIPPED = frozenset({"skipped", "neutral"})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_context_rows(statuses: Any) -> list[dict[str, str]]:
|
||||||
|
"""Reduce a raw status collection to newest-wins ``{context, state}`` rows.
|
||||||
|
|
||||||
|
Gitea returns the status collection newest-first, so the first row seen for
|
||||||
|
a context wins. Rows without a usable state are discarded rather than being
|
||||||
|
silently treated as passing.
|
||||||
|
"""
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
if not isinstance(statuses, list):
|
||||||
|
return rows
|
||||||
|
for raw in statuses:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
context = (raw.get("context") or raw.get("name") or "").strip()
|
||||||
|
state = (raw.get("status") or raw.get("state") or "").strip().lower()
|
||||||
|
if not state:
|
||||||
|
continue
|
||||||
|
key = context or f"__unnamed__{len(rows)}"
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
rows.append({"context": context, "state": state})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_context_states(rows: list[dict[str, str]]) -> str:
|
||||||
|
"""Fail-closed aggregate: failure > pending > unknown-state > success."""
|
||||||
|
states = {row["state"] for row in rows}
|
||||||
|
if states & _CTX_FAILURE:
|
||||||
|
return CHECKS_FAILURE
|
||||||
|
if states & _CTX_PENDING:
|
||||||
|
return CHECKS_PENDING
|
||||||
|
unresolved = states - _CTX_SUCCESS - _CTX_SKIPPED
|
||||||
|
if unresolved:
|
||||||
|
# An unrecognized context state must never read as success.
|
||||||
|
return CHECKS_UNKNOWN
|
||||||
|
return CHECKS_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
def classify_commit_checks(
|
||||||
|
*,
|
||||||
|
combined_state: str | None = None,
|
||||||
|
statuses: Any = None,
|
||||||
|
checks_enabled: bool | None = None,
|
||||||
|
required_contexts: Any = None,
|
||||||
|
policy_determinable: bool = True,
|
||||||
|
status_determinable: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify head checks from live evidence (#751).
|
||||||
|
|
||||||
|
``combined_state`` is deliberately **not** authoritative: it is recorded for
|
||||||
|
observability but never used to infer that CI is executing. The context
|
||||||
|
collection and the live protection policy decide.
|
||||||
|
|
||||||
|
Returns ``checks_status`` (one of the ``CHECKS_*`` values), the derived
|
||||||
|
``checks_required`` flag, and structured ``reasons``.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
rows = _normalize_context_rows(statuses)
|
||||||
|
required = [
|
||||||
|
str(ctx).strip()
|
||||||
|
for ctx in (required_contexts or [])
|
||||||
|
if str(ctx or "").strip()
|
||||||
|
]
|
||||||
|
observed_combined = (combined_state or "").strip().lower() or None
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"checks_status": CHECKS_UNKNOWN,
|
||||||
|
"checks_required": True,
|
||||||
|
"combined_state": observed_combined,
|
||||||
|
"context_count": len(rows),
|
||||||
|
"observed_contexts": [row["context"] for row in rows],
|
||||||
|
"required_contexts": required,
|
||||||
|
"missing_required_contexts": [],
|
||||||
|
"policy_determinable": bool(policy_determinable),
|
||||||
|
"status_determinable": bool(status_determinable),
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Policy unreadable → never assume checks are optional.
|
||||||
|
if not policy_determinable:
|
||||||
|
reasons.append(
|
||||||
|
"branch-protection check policy could not be read; cannot prove "
|
||||||
|
"whether status checks are required (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if checks_enabled is False:
|
||||||
|
result["checks_required"] = False
|
||||||
|
result["checks_status"] = CHECKS_NOT_REQUIRED
|
||||||
|
reasons.append(
|
||||||
|
"live branch protection does not require status checks for the base "
|
||||||
|
"branch; head status contexts do not gate merge"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if checks_enabled is None:
|
||||||
|
reasons.append(
|
||||||
|
"branch-protection status-check requirement is indeterminate "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Checks are required from here on.
|
||||||
|
if not status_determinable:
|
||||||
|
reasons.append(
|
||||||
|
"head commit status collection could not be read while branch "
|
||||||
|
"protection requires status checks (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if required:
|
||||||
|
by_context = {row["context"]: row["state"] for row in rows if row["context"]}
|
||||||
|
missing = [ctx for ctx in required if ctx not in by_context]
|
||||||
|
if missing:
|
||||||
|
result["missing_required_contexts"] = missing
|
||||||
|
result["checks_status"] = CHECKS_MISSING_REQUIRED
|
||||||
|
reasons.append(
|
||||||
|
"branch protection requires status context(s) "
|
||||||
|
f"{', '.join(missing)} but no matching status result exists at "
|
||||||
|
"the head commit (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
matched = [
|
||||||
|
{"context": ctx, "state": by_context[ctx]} for ctx in required
|
||||||
|
]
|
||||||
|
result["checks_status"] = _aggregate_context_states(matched)
|
||||||
|
reasons.append(
|
||||||
|
f"evaluated {len(matched)} required status context(s) from live "
|
||||||
|
"branch protection; unrelated contexts were ignored"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Status checks enabled with no specific required contexts configured.
|
||||||
|
if not rows:
|
||||||
|
result["checks_status"] = CHECKS_NONE
|
||||||
|
reasons.append(
|
||||||
|
"branch protection enables status checks but no status context was "
|
||||||
|
"produced for the head commit"
|
||||||
|
)
|
||||||
|
if observed_combined in _CTX_PENDING:
|
||||||
|
reasons.append(
|
||||||
|
f"combined commit state '{observed_combined}' does not indicate "
|
||||||
|
"executing CI because the status-context collection is empty"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["checks_status"] = _aggregate_context_states(rows)
|
||||||
|
reasons.append(
|
||||||
|
f"aggregated {len(rows)} reported status context(s); branch protection "
|
||||||
|
"configures no explicit required-context list"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _normalize_sha(value: str | None) -> str | None:
|
def _normalize_sha(value: str | None) -> str | None:
|
||||||
text = (value or "").strip().lower()
|
text = (value or "").strip().lower()
|
||||||
@@ -120,6 +300,7 @@ def assess_pr_sync_status(
|
|||||||
"branch_protection_requires_current_base": requires_current,
|
"branch_protection_requires_current_base": requires_current,
|
||||||
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
||||||
"checks_status": checks,
|
"checks_status": checks,
|
||||||
|
"checks_required": bool(checks_required),
|
||||||
"active_locks_and_leases": {
|
"active_locks_and_leases": {
|
||||||
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
|
"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,
|
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
|
||||||
@@ -258,21 +439,41 @@ def assess_pr_sync_status(
|
|||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Checks gate for merge_now ────────────────────────────────────────
|
# ── Checks gate for merge_now (#751) ─────────────────────────────────
|
||||||
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
|
# ``checks_required`` is derived from the live branch-protection policy by
|
||||||
if checks in ("pending", "running", "queued"):
|
# the production caller. When protection does not require status checks,
|
||||||
|
# head contexts cannot gate the merge and this whole gate is skipped.
|
||||||
|
if not checks_required:
|
||||||
|
reasons.append(
|
||||||
|
"live branch protection does not require status checks; head check "
|
||||||
|
f"state ({checks}) does not gate merge"
|
||||||
|
)
|
||||||
|
elif checks not in _CHECKS_OK:
|
||||||
|
if checks in _CTX_PENDING:
|
||||||
reasons.append(f"required checks are not finished (status={checks})")
|
reasons.append(f"required checks are not finished (status={checks})")
|
||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
elif checks in _CTX_FAILURE:
|
||||||
return result
|
|
||||||
if checks in ("failure", "failed", "error", "cancelled"):
|
|
||||||
reasons.append(f"required checks failed (status={checks})")
|
reasons.append(f"required checks failed (status={checks})")
|
||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
elif checks == CHECKS_MISSING_REQUIRED:
|
||||||
return result
|
reasons.append(
|
||||||
# unknown — fail closed when checks_required
|
"branch protection configures required status context(s) but no "
|
||||||
if checks == "unknown":
|
"matching status result exists at the current head (fail closed)"
|
||||||
|
)
|
||||||
|
elif checks == CHECKS_NONE:
|
||||||
|
reasons.append(
|
||||||
|
"branch protection requires status checks but no status context "
|
||||||
|
"was produced for the current head (fail closed); an empty "
|
||||||
|
"status collection is not executing CI"
|
||||||
|
)
|
||||||
|
elif checks == CHECKS_UNKNOWN:
|
||||||
reasons.append("checks status unknown (fail closed)")
|
reasons.append("checks status unknown (fail closed)")
|
||||||
result["recommended_next_action"] = ACTION_BLOCKED
|
else:
|
||||||
return result
|
# Unrecognized vocabulary must never fall through to merge_now.
|
||||||
|
reasons.append(
|
||||||
|
f"unrecognized checks status '{checks}' cannot prove required "
|
||||||
|
"checks passed (fail closed)"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
# ── Ready to merge without update ────────────────────────────────────
|
# ── Ready to merge without update ────────────────────────────────────
|
||||||
# Includes: current with approval; outdated when update is NOT required.
|
# Includes: current with approval; outdated when update is NOT required.
|
||||||
|
|||||||
+2
-1
@@ -36,7 +36,8 @@ _TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
|
|||||||
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
||||||
|
|
||||||
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
||||||
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
|
# The reviewer/merger PR-lease TTL lives in reviewer_pr_lease.LEASE_TTL_MINUTES
|
||||||
|
# (#747). A second copy here had no readers and could only drift out of sync.
|
||||||
|
|
||||||
|
|
||||||
def _parse_timestamp(value: str | None) -> datetime | None:
|
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||||
|
|||||||
+46
-7
@@ -29,9 +29,19 @@ _ACTIVE_PHASES = frozenset({
|
|||||||
"adopted",
|
"adopted",
|
||||||
})
|
})
|
||||||
|
|
||||||
DEFAULT_LEASE_TTL_MINUTES = 120
|
# Reviewer and merger PR leases use a short *sliding* window (#747): a lease
|
||||||
STALE_WARNING_MINUTES = 30
|
# expires 10 minutes after its last heartbeat, and every heartbeat slides the
|
||||||
RECLAIMABLE_MINUTES = 60
|
# expiry forward. An actively heartbeating session is never evicted, while a
|
||||||
|
# dead session releases its hold in at most one TTL instead of the two hours
|
||||||
|
# the previous fixed 120-minute expiry allowed.
|
||||||
|
LEASE_TTL_MINUTES = 10
|
||||||
|
# Renewal is named separately from acquisition so the slide amount is tunable
|
||||||
|
# without silently re-defining how long a fresh lease lives.
|
||||||
|
LEASE_RENEWAL_MINUTES = 10
|
||||||
|
# Retained for callers that imported the pre-#747 name.
|
||||||
|
DEFAULT_LEASE_TTL_MINUTES = LEASE_TTL_MINUTES
|
||||||
|
# Warn at half the window, while the owner can still heartbeat and recover.
|
||||||
|
STALE_WARNING_MINUTES = 5
|
||||||
|
|
||||||
_SESSION_LEASE: dict[str, Any] | None = None
|
_SESSION_LEASE: dict[str, Any] | None = None
|
||||||
|
|
||||||
@@ -80,10 +90,19 @@ def format_lease_body(
|
|||||||
target_branch_sha: str | None,
|
target_branch_sha: str | None,
|
||||||
last_activity: datetime | None = None,
|
last_activity: datetime | None = None,
|
||||||
expires_at: datetime | None = None,
|
expires_at: datetime | None = None,
|
||||||
|
ttl_minutes: int = LEASE_TTL_MINUTES,
|
||||||
blocker: str = "none",
|
blocker: str = "none",
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""Serialize a lease marker.
|
||||||
|
|
||||||
|
Every write of this marker — acquisition, heartbeat, adoption — re-derives
|
||||||
|
``expires_at`` from the moment of the write, which is what makes the TTL
|
||||||
|
slide (#747). Callers renewing an existing lease pass
|
||||||
|
``ttl_minutes=LEASE_RENEWAL_MINUTES``; an explicit ``expires_at`` still
|
||||||
|
wins so a lease can be minted with a deliberate window.
|
||||||
|
"""
|
||||||
now = last_activity or datetime.now(timezone.utc)
|
now = last_activity or datetime.now(timezone.utc)
|
||||||
expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES))
|
expires = expires_at or (now + timedelta(minutes=ttl_minutes))
|
||||||
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
)
|
)
|
||||||
@@ -169,8 +188,30 @@ def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
|
|||||||
return (now - last).total_seconds() / 60.0
|
return (now - last).total_seconds() / 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def lease_seconds_remaining(lease: dict, *, now: datetime | None = None) -> int | None:
|
||||||
|
"""Seconds until *lease* expires, clamped at 0; ``None`` if unparsable.
|
||||||
|
|
||||||
|
Lets diagnostics distinguish "held and live" from "held and dying" (#747)
|
||||||
|
rather than only reporting that a lease exists.
|
||||||
|
"""
|
||||||
|
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||||
|
if not expires_at:
|
||||||
|
return None
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
return max(0, int((expires_at - now).total_seconds()))
|
||||||
|
|
||||||
|
|
||||||
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
|
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
|
||||||
"""Return active, stale_warning, reclaimable, expired, or terminal."""
|
"""Return active, stale_warning, expired, or terminal.
|
||||||
|
|
||||||
|
Expiry is the only takeover gate (#747). The pre-#747 ``reclaimable`` band
|
||||||
|
sat between "stale" and "expired" and made a dead lease wait out a second
|
||||||
|
timer before anyone could reclaim it. Under a sliding TTL that band is also
|
||||||
|
unreachable: a heartbeat stamps ``last_activity`` and ``expires_at``
|
||||||
|
together, so a lease idle for a full TTL is already expired. Foreign
|
||||||
|
expired leases are handled by the ``foreign_expired`` classification, which
|
||||||
|
carries the same sanctioned release next-action the old tier did.
|
||||||
|
"""
|
||||||
now = now or datetime.now(timezone.utc)
|
now = now or datetime.now(timezone.utc)
|
||||||
phase = (lease.get("phase") or "").strip().lower()
|
phase = (lease.get("phase") or "").strip().lower()
|
||||||
if phase in _TERMINAL_PHASES:
|
if phase in _TERMINAL_PHASES:
|
||||||
@@ -180,8 +221,6 @@ def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str
|
|||||||
minutes = _minutes_since_activity(lease, now=now)
|
minutes = _minutes_since_activity(lease, now=now)
|
||||||
if minutes is None:
|
if minutes is None:
|
||||||
return "active"
|
return "active"
|
||||||
if minutes >= RECLAIMABLE_MINUTES:
|
|
||||||
return "reclaimable"
|
|
||||||
if minutes >= STALE_WARNING_MINUTES:
|
if minutes >= STALE_WARNING_MINUTES:
|
||||||
return "stale_warning"
|
return "stale_warning"
|
||||||
return "active"
|
return "active"
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ RECONCILER_TASKS = frozenset({
|
|||||||
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
|
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
|
||||||
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
|
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
|
||||||
"delete_branch",
|
"delete_branch",
|
||||||
|
# #745: post-merge moot reviewer-lease cleanup is reconciler-owned; the
|
||||||
|
# apply path posts a terminal lease marker. Kept in step with
|
||||||
|
# task_capability_map so map and router cannot disagree (#723 defect A).
|
||||||
|
"cleanup_post_merge_moot_lease",
|
||||||
|
"gitea_cleanup_post_merge_moot_lease",
|
||||||
"reconcile_already_landed_pr",
|
"reconcile_already_landed_pr",
|
||||||
"reconcile_already_landed",
|
"reconcile_already_landed",
|
||||||
"reconcile-landed-pr",
|
"reconcile-landed-pr",
|
||||||
@@ -118,6 +123,9 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"reconcile_already_landed": "reconciler",
|
"reconcile_already_landed": "reconciler",
|
||||||
"reconcile-landed-pr": "reconciler",
|
"reconcile-landed-pr": "reconciler",
|
||||||
"cleanup_merged_pr_branch": "reconciler",
|
"cleanup_merged_pr_branch": "reconciler",
|
||||||
|
# #745: post-merge moot reviewer-lease cleanup (canonical task + tool alias).
|
||||||
|
"cleanup_post_merge_moot_lease": "reconciler",
|
||||||
|
"gitea_cleanup_post_merge_moot_lease": "reconciler",
|
||||||
# #309: reconciler tasks close already-landed PRs/issues only.
|
# #309: reconciler tasks close already-landed PRs/issues only.
|
||||||
"reconcile_close_landed_pr": "reconciler",
|
"reconcile_close_landed_pr": "reconciler",
|
||||||
"reconcile_close_landed_issue": "reconciler",
|
"reconcile_close_landed_issue": "reconciler",
|
||||||
|
|||||||
@@ -450,6 +450,35 @@ If any gate fails, do not create the issue.
|
|||||||
|
|
||||||
Produce a recovery handoff or duplicate report.
|
Produce a recovery handoff or duplicate report.
|
||||||
|
|
||||||
|
### 18a. Sanctioned first-mutation path (#749)
|
||||||
|
|
||||||
|
`gitea_create_issue` is a **pure remote mutation** (no local tree write). The
|
||||||
|
issue-first gate forbids creating `branches/issue-<N>-*` before the issue
|
||||||
|
number exists. Therefore the **only sanctioned first mutation** is:
|
||||||
|
|
||||||
|
1. Read-only identity + capability + duplicate search from the control checkout.
|
||||||
|
2. Ensure the **canonical control checkout** is:
|
||||||
|
* the configured repository root for the requested remote/org/repo;
|
||||||
|
* on an accepted base branch (`master` / `main` / `dev`);
|
||||||
|
* base-equivalent to live master;
|
||||||
|
* clean (no tracked local edits);
|
||||||
|
* in runtime/master parity.
|
||||||
|
3. Resolve exact task `create_issue`, then call `gitea_create_issue` **from that
|
||||||
|
clean control checkout** (no `worktree_path` required for this step alone).
|
||||||
|
4. After the issue number exists: create a **registered** worktree under
|
||||||
|
`branches/issue-<N>-*`, claim/lock, and perform every subsequent author
|
||||||
|
mutation from that worktree only.
|
||||||
|
|
||||||
|
**Forbidden improvisations (fail closed):**
|
||||||
|
|
||||||
|
* `mkdir` dummy directories under `branches/` (#713)
|
||||||
|
* borrowing an unrelated pre-existing worktree
|
||||||
|
* creating a pre-issue worktree in violation of issue-first
|
||||||
|
* running create_issue from a dirty, drifted, detached, or non-canonical root
|
||||||
|
|
||||||
|
Post-creation mutations (`lock_issue`, commit, push, `create_pr`, etc.) **never**
|
||||||
|
receive this bootstrap exemption.
|
||||||
|
|
||||||
## 19. Issue commenting gate
|
## 19. Issue commenting gate
|
||||||
|
|
||||||
Before commenting on an existing issue, verify:
|
Before commenting on an existing issue, verify:
|
||||||
|
|||||||
@@ -158,6 +158,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
# #745: post-merge moot reviewer-lease cleanup is reconciler-owned. The
|
||||||
|
# apply path posts an append-only terminal `phase: released` lease marker
|
||||||
|
# (gitea.pr.comment), so holding the comment permission alone must not
|
||||||
|
# authorize it — author, reviewer and merger fail closed on the role gate
|
||||||
|
# even though their profiles carry gitea.pr.comment. The read-only
|
||||||
|
# `apply=false` assessment deliberately stays reachable under gitea.read
|
||||||
|
# inside the tool (the same convention as cleanup_stale_review_decision_lock
|
||||||
|
# below), so any namespace can diagnose a stuck lease; only apply requires
|
||||||
|
# this task plus the reconciler role.
|
||||||
|
"cleanup_post_merge_moot_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_cleanup_post_merge_moot_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
"blind_pr_queue_review": {
|
"blind_pr_queue_review": {
|
||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
@@ -415,6 +432,36 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# A reviewer lease is the first mutation in the canonical ``review_pr``
|
||||||
|
# workflow, so the already-resolved review capability is valid for that one
|
||||||
|
# narrower transition. Keep this directed and explicit: lease acquisition
|
||||||
|
# does not authorize a review verdict, and reviewer proof never authorizes a
|
||||||
|
# merger lease (#763).
|
||||||
|
_PREFLIGHT_TASK_TRANSITIONS = frozenset({
|
||||||
|
("review_pr", "acquire_reviewer_pr_lease"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_preflight_task(task: str | None) -> str:
|
||||||
|
"""Normalize only declared ``gitea_`` aliases for preflight comparison."""
|
||||||
|
value = (task or "").strip()
|
||||||
|
if value.startswith("gitea_") and value[6:] in TASK_CAPABILITY_MAP:
|
||||||
|
return value[6:]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_task_matches(
|
||||||
|
resolved_task: str | None,
|
||||||
|
mutation_task: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether capability proof authorizes this mutation transition."""
|
||||||
|
resolved = _canonical_preflight_task(resolved_task)
|
||||||
|
mutation = _canonical_preflight_task(mutation_task)
|
||||||
|
if not resolved or not mutation:
|
||||||
|
return False
|
||||||
|
return resolved == mutation or (resolved, mutation) in _PREFLIGHT_TASK_TRANSITIONS
|
||||||
|
|
||||||
# Issue-mutating MCP tools and their resolver task keys.
|
# Issue-mutating MCP tools and their resolver task keys.
|
||||||
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
||||||
"gitea_create_issue": "create_issue",
|
"gitea_create_issue": "create_issue",
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""Dependency parsing/resolution and allocator completeness tests (#758).
|
||||||
|
|
||||||
|
Covers the two defects behind #758:
|
||||||
|
|
||||||
|
* Defect 1 — candidate truncation before ranking, which let a result-size
|
||||||
|
parameter change the winner.
|
||||||
|
* Defect 2 — dependency state inferred from body substrings, which emitted
|
||||||
|
canonical ``Depends:`` blocked issues as eligible.
|
||||||
|
|
||||||
|
No production behavior is special-cased for any issue number (#758 AC14), so
|
||||||
|
these tests use synthetic issue numbers throughout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import allocator_dependencies
|
||||||
|
from allocator_service import (
|
||||||
|
OUTCOME_PREVIEW,
|
||||||
|
SELECTION_POLICY,
|
||||||
|
WorkCandidate,
|
||||||
|
allocate_next_work,
|
||||||
|
classify_skip,
|
||||||
|
sort_candidates,
|
||||||
|
)
|
||||||
|
from control_plane_db import ControlPlaneDB
|
||||||
|
|
||||||
|
# The canonical linkage line this repository writes into issue bodies.
|
||||||
|
CANONICAL_BODY = (
|
||||||
|
"## Dependencies and linkage\n\n"
|
||||||
|
"* Parent: #900 · Depends: #901, #902 · Related: #903, #904\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParseDependencyRefsTest(unittest.TestCase):
|
||||||
|
def test_parses_canonical_depends_field(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
allocator_dependencies.parse_dependency_refs(CANONICAL_BODY),
|
||||||
|
(901, 902),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stops_at_sibling_field_and_ignores_related(self) -> None:
|
||||||
|
""""Related:" refs must never be treated as dependencies."""
|
||||||
|
refs = allocator_dependencies.parse_dependency_refs(CANONICAL_BODY)
|
||||||
|
self.assertNotIn(903, refs)
|
||||||
|
self.assertNotIn(904, refs)
|
||||||
|
self.assertNotIn(900, refs) # Parent is not a dependency
|
||||||
|
|
||||||
|
def test_single_reference(self) -> None:
|
||||||
|
body = "* Parent: #10 · Depends: #11 · Related: #12"
|
||||||
|
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (11,))
|
||||||
|
|
||||||
|
def test_depends_on_spelling_and_and_separator(self) -> None:
|
||||||
|
body = "Depends on #21 and #22\n"
|
||||||
|
self.assertEqual(
|
||||||
|
allocator_dependencies.parse_dependency_refs(body), (21, 22)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_newline_terminates_declaration(self) -> None:
|
||||||
|
body = "Depends: #31, #32\nRelated: #33\n"
|
||||||
|
self.assertEqual(
|
||||||
|
allocator_dependencies.parse_dependency_refs(body), (31, 32)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_blocked_on_marker_still_recognized(self) -> None:
|
||||||
|
body = "This work is blocked on #41 until that lands.\n"
|
||||||
|
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (41,))
|
||||||
|
|
||||||
|
def test_dependencies_heading_alone_is_not_a_declaration(self) -> None:
|
||||||
|
""""Dependencies and linkage" must not parse as "Depends"."""
|
||||||
|
body = "## Dependencies and linkage\n\n* Related: #51\n"
|
||||||
|
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())
|
||||||
|
|
||||||
|
def test_deduplicates_and_preserves_order(self) -> None:
|
||||||
|
body = "Depends: #61, #62, #61\n"
|
||||||
|
self.assertEqual(
|
||||||
|
allocator_dependencies.parse_dependency_refs(body), (61, 62)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_malformed_and_empty_inputs(self) -> None:
|
||||||
|
for body in ("", None, "Depends:", "Depends: none", "Depends: TBD\n"):
|
||||||
|
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveDependencyStateTest(unittest.TestCase):
|
||||||
|
def test_open_dependency_is_unmet(self) -> None:
|
||||||
|
result = allocator_dependencies.resolve_dependency_state(
|
||||||
|
(901, 902), lambda n: "open", subject="issue#644"
|
||||||
|
)
|
||||||
|
self.assertTrue(result["dependency_unmet"])
|
||||||
|
self.assertEqual(result["unmet"], (901, 902))
|
||||||
|
self.assertIn("#901", result["reason"])
|
||||||
|
|
||||||
|
def test_closed_dependencies_are_met(self) -> None:
|
||||||
|
result = allocator_dependencies.resolve_dependency_state(
|
||||||
|
(901, 902), lambda n: "closed"
|
||||||
|
)
|
||||||
|
self.assertFalse(result["dependency_unmet"])
|
||||||
|
self.assertEqual(result["met"], (901, 902))
|
||||||
|
self.assertIsNone(result["reason"])
|
||||||
|
|
||||||
|
def test_mixed_open_and_closed_is_unmet(self) -> None:
|
||||||
|
states = {901: "closed", 902: "open"}
|
||||||
|
result = allocator_dependencies.resolve_dependency_state(
|
||||||
|
(901, 902), states.get
|
||||||
|
)
|
||||||
|
self.assertTrue(result["dependency_unmet"])
|
||||||
|
self.assertEqual(result["unmet"], (902,))
|
||||||
|
self.assertEqual(result["met"], (901,))
|
||||||
|
|
||||||
|
def test_unavailable_evidence_fails_closed(self) -> None:
|
||||||
|
"""AC7: unknown state must block, never pass."""
|
||||||
|
result = allocator_dependencies.resolve_dependency_state(
|
||||||
|
(901,), lambda n: None
|
||||||
|
)
|
||||||
|
self.assertTrue(result["dependency_unmet"])
|
||||||
|
self.assertEqual(result["unavailable"], (901,))
|
||||||
|
self.assertIn("fail closed", result["reason"])
|
||||||
|
|
||||||
|
def test_raising_lookup_fails_closed(self) -> None:
|
||||||
|
def boom(_n: int) -> str:
|
||||||
|
raise RuntimeError("lookup exploded")
|
||||||
|
|
||||||
|
result = allocator_dependencies.resolve_dependency_state((901,), boom)
|
||||||
|
self.assertTrue(result["dependency_unmet"])
|
||||||
|
self.assertEqual(result["unavailable"], (901,))
|
||||||
|
|
||||||
|
def test_no_refs_is_eligible(self) -> None:
|
||||||
|
result = allocator_dependencies.resolve_dependency_state((), lambda n: None)
|
||||||
|
self.assertFalse(result["dependency_unmet"])
|
||||||
|
self.assertIsNone(result["reason"])
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyBlockedCandidateTest(unittest.TestCase):
|
||||||
|
"""A dependency-blocked candidate must be skipped, not selected."""
|
||||||
|
|
||||||
|
def test_classify_skip_rejects_unmet_dependency(self) -> None:
|
||||||
|
candidate = WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=644,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=20,
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason="issue#644 depends on unresolved issue(s) #633",
|
||||||
|
)
|
||||||
|
reason = classify_skip(candidate, role="author", terminal_pr=None)
|
||||||
|
self.assertIsNotNone(reason)
|
||||||
|
self.assertIn("#633", reason)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectionInvarianceTest(unittest.TestCase):
|
||||||
|
"""AC1/AC2/AC11: ranking sees everything; result bounds cannot move the winner."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ready_issue(number: int, **kw) -> WorkCandidate:
|
||||||
|
return WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=number,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=20,
|
||||||
|
title=f"issue {number}",
|
||||||
|
**kw,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _preview(self, candidates):
|
||||||
|
return allocate_next_work(
|
||||||
|
self.db,
|
||||||
|
session_id="s-758",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
candidates=candidates,
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_more_than_fifty_candidates_lowest_number_wins(self) -> None:
|
||||||
|
"""Winner is the oldest eligible issue across a >50 inventory."""
|
||||||
|
candidates = [self._ready_issue(n) for n in range(600, 700)] # 100 items
|
||||||
|
result = self._preview(candidates)
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(result["selected"]["number"], 600)
|
||||||
|
|
||||||
|
def test_selection_is_invariant_to_candidate_ordering(self) -> None:
|
||||||
|
"""Ranking must not depend on the order the inventory arrived in."""
|
||||||
|
forward = [self._ready_issue(n) for n in range(600, 700)]
|
||||||
|
reverse = list(reversed(forward))
|
||||||
|
self.assertEqual(
|
||||||
|
self._preview(forward)["selected"]["number"],
|
||||||
|
self._preview(reverse)["selected"]["number"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_truncating_inventory_changes_winner(self) -> None:
|
||||||
|
"""Regression guard: this is exactly what pre-ranking slicing did.
|
||||||
|
|
||||||
|
A 50-item slice of a 100-item inventory yields a different winner, so
|
||||||
|
any future reintroduction of pre-ranking truncation is detectable.
|
||||||
|
"""
|
||||||
|
full = [self._ready_issue(n) for n in range(600, 700)]
|
||||||
|
sliced = sorted(full, key=lambda c: -c.number)[:50]
|
||||||
|
self.assertNotEqual(
|
||||||
|
self._preview(full)["selected"]["number"],
|
||||||
|
self._preview(sliced)["selected"]["number"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_blocked_first_candidate_falls_through_to_next(self) -> None:
|
||||||
|
"""AC8: a blocked winner must not end the iteration."""
|
||||||
|
blocked = self._ready_issue(
|
||||||
|
600,
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason="issue#600 depends on unresolved issue(s) #599",
|
||||||
|
)
|
||||||
|
result = self._preview([blocked, self._ready_issue(601)])
|
||||||
|
self.assertEqual(result["selected"]["number"], 601)
|
||||||
|
skipped = {s["number"] for s in result["skipped"]}
|
||||||
|
self.assertIn(600, skipped)
|
||||||
|
|
||||||
|
def test_all_blocked_yields_no_safe_work(self) -> None:
|
||||||
|
candidates = [
|
||||||
|
self._ready_issue(
|
||||||
|
n, dependency_unmet=True, dependency_reason=f"issue#{n} blocked"
|
||||||
|
)
|
||||||
|
for n in range(600, 605)
|
||||||
|
]
|
||||||
|
result = self._preview(candidates)
|
||||||
|
self.assertIsNone(result["selected"])
|
||||||
|
self.assertEqual(len(result["skipped"]), 5)
|
||||||
|
|
||||||
|
def test_dry_run_and_apply_select_identically(self) -> None:
|
||||||
|
"""AC9: apply mode must not re-rank differently from preview."""
|
||||||
|
candidates = [self._ready_issue(n) for n in range(600, 700)]
|
||||||
|
preview = self._preview(candidates)
|
||||||
|
applied = allocate_next_work(
|
||||||
|
self.db,
|
||||||
|
session_id="s-758-apply",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
candidates=candidates,
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
preview["selected"]["number"], applied["selected"]["number"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sort_is_stable_and_documented(self) -> None:
|
||||||
|
ordered = sort_candidates(
|
||||||
|
[self._ready_issue(603), self._ready_issue(601), self._ready_issue(602)]
|
||||||
|
)
|
||||||
|
self.assertEqual([c.number for c in ordered], [601, 602, 603])
|
||||||
|
self.assertIn("never affect selection", SELECTION_POLICY)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""MCP-level allocator inventory and dependency regressions (#758).
|
||||||
|
|
||||||
|
Exercises ``_allocator_candidates_from_gitea`` and the ``gitea_allocate_next_work``
|
||||||
|
tool end to end against a faked Gitea API, proving:
|
||||||
|
|
||||||
|
* the complete open-issue inventory is ranked (no pre-ranking truncation);
|
||||||
|
* ``limit`` cannot change which candidate wins;
|
||||||
|
* canonical ``Depends:`` declarations are resolved from live issue state;
|
||||||
|
* unavailable dependency evidence fails closed;
|
||||||
|
* an incomplete listing fails closed instead of ranking a partial set.
|
||||||
|
|
||||||
|
Issue numbers here are synthetic; no production number is special-cased.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
from control_plane_db import ControlPlaneDB
|
||||||
|
|
||||||
|
FAKE_AUTH = "token REDACTED"
|
||||||
|
ORG = "Scaled-Tech-Consulting"
|
||||||
|
REPO = "Gitea-Tools"
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(number: int, *, body: str = "", labels=("status:ready",)) -> dict:
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"title": f"issue {number}",
|
||||||
|
"body": body,
|
||||||
|
"labels": [{"name": name} for name in labels],
|
||||||
|
"state": "open",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _depends_body(*refs: int) -> str:
|
||||||
|
joined = ", ".join(f"#{r}" for r in refs)
|
||||||
|
return f"## Dependencies and linkage\n\n* Parent: #999 · Depends: {joined}\n"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeGitea:
|
||||||
|
"""Minimal stand-in for the two Gitea list endpoints plus issue lookups."""
|
||||||
|
|
||||||
|
def __init__(self, issues, *, closed=(), unavailable=(), fail_issue_list=False):
|
||||||
|
self.issues = list(issues)
|
||||||
|
self.closed = set(closed)
|
||||||
|
self.unavailable = set(unavailable)
|
||||||
|
self.fail_issue_list = fail_issue_list
|
||||||
|
self.lookups: list[int] = []
|
||||||
|
|
||||||
|
def api_get_all(self, url, _auth, **_kw):
|
||||||
|
if "/pulls" in url:
|
||||||
|
return []
|
||||||
|
if self.fail_issue_list:
|
||||||
|
raise RuntimeError("issue listing failed")
|
||||||
|
return list(self.issues)
|
||||||
|
|
||||||
|
def api_request(self, _method, url, _auth, **_kw):
|
||||||
|
number = int(url.rsplit("/", 1)[-1])
|
||||||
|
self.lookups.append(number)
|
||||||
|
if number in self.unavailable:
|
||||||
|
raise RuntimeError("lookup failed")
|
||||||
|
if number in self.closed:
|
||||||
|
return {"number": number, "state": "closed"}
|
||||||
|
return {"number": number, "state": "open"}
|
||||||
|
|
||||||
|
|
||||||
|
class AllocatorInventoryTest(unittest.TestCase):
|
||||||
|
"""Direct tests of the candidate loader."""
|
||||||
|
|
||||||
|
def _load(self, fake, **kwargs):
|
||||||
|
with patch("gitea_mcp_server._resolve", return_value=("h", ORG, REPO)), patch(
|
||||||
|
"gitea_mcp_server._auth", return_value=FAKE_AUTH
|
||||||
|
), patch("gitea_mcp_server.api_get_all", side_effect=fake.api_get_all), patch(
|
||||||
|
"gitea_mcp_server.api_request", side_effect=fake.api_request
|
||||||
|
):
|
||||||
|
return srv._allocator_candidates_from_gitea(
|
||||||
|
remote="prgs", host=None, org=ORG, repo=REPO, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_inventory_above_fifty_is_ranked(self) -> None:
|
||||||
|
"""AC1: all 73 open issues become candidates, not the first 50."""
|
||||||
|
fake = _FakeGitea([_issue(n) for n in range(600, 673)])
|
||||||
|
candidates, _reasons, complete = self._load(fake)
|
||||||
|
self.assertTrue(complete)
|
||||||
|
self.assertEqual(len(candidates), 73)
|
||||||
|
self.assertEqual(min(c.number for c in candidates), 600)
|
||||||
|
self.assertEqual(max(c.number for c in candidates), 672)
|
||||||
|
|
||||||
|
def test_open_dependency_marks_candidate_unmet(self) -> None:
|
||||||
|
"""AC4/AC5/AC6: canonical Depends on an open issue blocks the candidate."""
|
||||||
|
fake = _FakeGitea(
|
||||||
|
[_issue(600, body=_depends_body(601, 602)), _issue(601), _issue(602)]
|
||||||
|
)
|
||||||
|
candidates, _reasons, _complete = self._load(fake)
|
||||||
|
blocked = next(c for c in candidates if c.number == 600)
|
||||||
|
self.assertTrue(blocked.dependency_unmet)
|
||||||
|
self.assertIn("#601", blocked.dependency_reason)
|
||||||
|
|
||||||
|
def test_closed_dependency_is_eligible(self) -> None:
|
||||||
|
"""A dependency absent from the open list is confirmed closed, not assumed."""
|
||||||
|
fake = _FakeGitea([_issue(600, body=_depends_body(500))], closed={500})
|
||||||
|
candidates, _reasons, _complete = self._load(fake)
|
||||||
|
candidate = next(c for c in candidates if c.number == 600)
|
||||||
|
self.assertFalse(candidate.dependency_unmet)
|
||||||
|
self.assertIn(500, fake.lookups) # proved live, not inferred
|
||||||
|
|
||||||
|
def test_unavailable_dependency_evidence_fails_closed(self) -> None:
|
||||||
|
"""AC7: an unreachable dependency must block, never pass."""
|
||||||
|
fake = _FakeGitea([_issue(600, body=_depends_body(500))], unavailable={500})
|
||||||
|
candidates, _reasons, _complete = self._load(fake)
|
||||||
|
candidate = next(c for c in candidates if c.number == 600)
|
||||||
|
self.assertTrue(candidate.dependency_unmet)
|
||||||
|
self.assertIn("fail closed", candidate.dependency_reason)
|
||||||
|
|
||||||
|
def test_dependency_state_lookups_are_cached(self) -> None:
|
||||||
|
"""Repeated references resolve with a single live lookup."""
|
||||||
|
fake = _FakeGitea(
|
||||||
|
[_issue(n, body=_depends_body(500)) for n in range(600, 610)],
|
||||||
|
closed={500},
|
||||||
|
)
|
||||||
|
self._load(fake)
|
||||||
|
self.assertEqual(fake.lookups.count(500), 1)
|
||||||
|
|
||||||
|
def test_open_dependency_needs_no_lookup(self) -> None:
|
||||||
|
"""The complete open listing already proves openness."""
|
||||||
|
fake = _FakeGitea([_issue(600, body=_depends_body(601)), _issue(601)])
|
||||||
|
self._load(fake)
|
||||||
|
self.assertNotIn(601, fake.lookups)
|
||||||
|
|
||||||
|
def test_failed_issue_listing_reports_incomplete(self) -> None:
|
||||||
|
"""AC3: a failed listing must not silently yield a short inventory."""
|
||||||
|
fake = _FakeGitea([], fail_issue_list=True)
|
||||||
|
_candidates, reasons, complete = self._load(fake)
|
||||||
|
self.assertFalse(complete)
|
||||||
|
self.assertTrue(any("failed to list open issues" in r for r in reasons))
|
||||||
|
|
||||||
|
|
||||||
|
class AllocateNextWorkToolTest(unittest.TestCase):
|
||||||
|
"""End-to-end tests of the gitea_allocate_next_work MCP tool."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _allocate(self, fake, **kwargs):
|
||||||
|
with patch("gitea_mcp_server._profile_operation_gate", return_value=None), patch(
|
||||||
|
"gitea_mcp_server._resolve", return_value=("h", ORG, REPO)
|
||||||
|
), patch("gitea_mcp_server._auth", return_value=FAKE_AUTH), patch(
|
||||||
|
"gitea_mcp_server.get_profile",
|
||||||
|
return_value={"profile_name": "prgs-author", "role": "author"},
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.api_get_all", side_effect=fake.api_get_all
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.api_request", side_effect=fake.api_request
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
|
||||||
|
):
|
||||||
|
return srv.gitea_allocate_next_work(
|
||||||
|
remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_limit_does_not_change_selection(self) -> None:
|
||||||
|
"""AC2/AC11: the winner is identical at limit=1 and limit=300."""
|
||||||
|
issues = [_issue(n) for n in range(600, 673)] # 73 candidates
|
||||||
|
low = self._allocate(_FakeGitea(issues), limit=1)
|
||||||
|
high = self._allocate(_FakeGitea(issues), limit=300)
|
||||||
|
self.assertEqual(low["selected"]["number"], high["selected"]["number"])
|
||||||
|
self.assertEqual(low["selected"]["number"], 600)
|
||||||
|
self.assertEqual(low["candidate_count"], 73)
|
||||||
|
self.assertEqual(high["candidate_count"], 73)
|
||||||
|
|
||||||
|
def test_dependency_blocked_winner_falls_through(self) -> None:
|
||||||
|
"""AC8: a blocked highest-ranked issue yields the next eligible one."""
|
||||||
|
issues = [
|
||||||
|
_issue(600, body=_depends_body(601)),
|
||||||
|
_issue(601),
|
||||||
|
_issue(602),
|
||||||
|
]
|
||||||
|
result = self._allocate(_FakeGitea(issues))
|
||||||
|
# 600 is blocked by open 601; 601 itself is a valid candidate.
|
||||||
|
self.assertEqual(result["selected"]["number"], 601)
|
||||||
|
skipped = {s["number"] for s in result["skipped"]}
|
||||||
|
self.assertIn(600, skipped)
|
||||||
|
|
||||||
|
def test_limit_truncates_only_the_reported_skip_list(self) -> None:
|
||||||
|
"""A shortened report is labelled, never presented as full coverage."""
|
||||||
|
issues = [_issue(n, body=_depends_body(999)) for n in range(600, 640)]
|
||||||
|
issues.append(_issue(999)) # open dependency blocks all of the above
|
||||||
|
issues.append(_issue(700)) # the one eligible candidate
|
||||||
|
result = self._allocate(_FakeGitea(issues), limit=5)
|
||||||
|
self.assertTrue(result["skipped_report_truncated"])
|
||||||
|
self.assertEqual(len(result["skipped"]), 5)
|
||||||
|
self.assertGreater(result["skipped_total"], 5)
|
||||||
|
self.assertEqual(result["limit_applies_to"], "reported_skip_list_only")
|
||||||
|
|
||||||
|
def test_incomplete_inventory_fails_closed(self) -> None:
|
||||||
|
"""AC3: no selection is made from a partial candidate set."""
|
||||||
|
result = self._allocate(_FakeGitea([], fail_issue_list=True))
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["inventory_complete"])
|
||||||
|
self.assertIsNone(result["assignment"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("fail closed" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selection_policy_is_reported(self) -> None:
|
||||||
|
"""AC10: tie-breaking is stated in the result, not left implicit."""
|
||||||
|
result = self._allocate(_FakeGitea([_issue(600)]))
|
||||||
|
self.assertIn("selection_policy", result)
|
||||||
|
self.assertIn("number asc", result["selection_policy"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"""Regression tests for create_issue bootstrap (#749).
|
||||||
|
|
||||||
|
TDD: these tests define the sanctioned first-mutation path for
|
||||||
|
``gitea_create_issue`` from a clean canonical control checkout, and prove
|
||||||
|
the exemption cannot widen to dirty roots, foreign clones, arbitrary
|
||||||
|
``branches/`` directories, or post-creation author mutations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import create_issue_bootstrap as cib # noqa: E402
|
||||||
|
import gitea_mcp_server as srv # noqa: E402
|
||||||
|
import workflow_scope_guard as wsg # noqa: E402
|
||||||
|
|
||||||
|
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||||
|
MASTER_SHA = "a" * 40
|
||||||
|
STALE_SHA = "b" * 40
|
||||||
|
|
||||||
|
current_file_path = Path(__file__).resolve()
|
||||||
|
if "branches" in current_file_path.parts:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||||
|
else:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateIssueBootstrapAssessor(unittest.TestCase):
|
||||||
|
ROOT = "/repo/Gitea-Tools"
|
||||||
|
|
||||||
|
def test_non_create_issue_task_not_applicable(self):
|
||||||
|
res = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=self.ROOT,
|
||||||
|
canonical_repo_root=self.ROOT,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=MASTER_SHA,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task="lock_issue",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["not_applicable"])
|
||||||
|
self.assertFalse(res["allowed"])
|
||||||
|
self.assertFalse(res["block"])
|
||||||
|
|
||||||
|
def test_branches_worktree_not_applicable(self):
|
||||||
|
res = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=f"{self.ROOT}/branches/issue-1-x",
|
||||||
|
canonical_repo_root=self.ROOT,
|
||||||
|
current_branch="fix/issue-1-x",
|
||||||
|
task="create_issue",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["not_applicable"])
|
||||||
|
self.assertFalse(res["allowed"])
|
||||||
|
|
||||||
|
def test_clean_control_checkout_allowed(self):
|
||||||
|
res = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=self.ROOT,
|
||||||
|
canonical_repo_root=self.ROOT,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=MASTER_SHA,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task="create_issue",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["not_applicable"])
|
||||||
|
self.assertTrue(res["allowed"])
|
||||||
|
self.assertFalse(res["block"])
|
||||||
|
self.assertEqual(res["bootstrap_path"], "clean_canonical_control_checkout")
|
||||||
|
# Post-create next action must name issue-backed worktree after N exists.
|
||||||
|
self.assertIn("branches/issue-<N>-*", res["exact_next_action"])
|
||||||
|
|
||||||
|
def test_tool_alias_gitea_create_issue_allowed(self):
|
||||||
|
res = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=self.ROOT,
|
||||||
|
canonical_repo_root=self.ROOT,
|
||||||
|
current_branch="main",
|
||||||
|
head_sha=MASTER_SHA,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task="gitea_create_issue",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["allowed"])
|
||||||
|
|
||||||
|
def test_dirty_control_checkout_blocked(self):
|
||||||
|
res = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=self.ROOT,
|
||||||
|
canonical_repo_root=self.ROOT,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=MASTER_SHA,
|
||||||
|
porcelain_status=" M gitea_mcp_server.py\n",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task="create_issue",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["block"])
|
||||||
|
self.assertFalse(res["allowed"])
|
||||||
|
self.assertTrue(any("tracked local edits" in r for r in res["reasons"]))
|
||||||
|
# Pre-issue phase: next action must be satisfiable without inventing <N>.
|
||||||
|
next_a = res["exact_next_action"] or ""
|
||||||
|
self.assertIn("clean accepted base branch", next_a)
|
||||||
|
self.assertIn("before the issue exists", next_a)
|
||||||
|
# Must not prescribe "bind branches/issue-<N>" as the recovery step.
|
||||||
|
self.assertNotIn("Bind an issue-backed worktree", next_a)
|
||||||
|
|
||||||
|
def test_stale_base_blocked(self):
|
||||||
|
res = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=self.ROOT,
|
||||||
|
canonical_repo_root=self.ROOT,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=STALE_SHA,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task="create_issue",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["block"])
|
||||||
|
self.assertTrue(any("live master" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
def test_non_base_branch_blocked(self):
|
||||||
|
res = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=self.ROOT,
|
||||||
|
canonical_repo_root=self.ROOT,
|
||||||
|
current_branch="feat/something",
|
||||||
|
head_sha=MASTER_SHA,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task="create_issue",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["block"])
|
||||||
|
|
||||||
|
def test_detached_head_blocked(self):
|
||||||
|
res = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=self.ROOT,
|
||||||
|
canonical_repo_root=self.ROOT,
|
||||||
|
current_branch="",
|
||||||
|
head_sha=MASTER_SHA,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task="create_issue",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["block"])
|
||||||
|
self.assertTrue(any("detached" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
def test_foreign_workspace_blocked(self):
|
||||||
|
res = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path="/other/clone",
|
||||||
|
canonical_repo_root=self.ROOT,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=MASTER_SHA,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task="create_issue",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["block"])
|
||||||
|
self.assertTrue(any("canonical control checkout" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateIssueBootstrapIntegration(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
srv._preflight_whoami_called = True
|
||||||
|
srv._preflight_capability_called = True
|
||||||
|
srv._preflight_resolved_role = "author"
|
||||||
|
srv._preflight_resolved_task = "create_issue"
|
||||||
|
srv._preflight_whoami_violation = False
|
||||||
|
srv._preflight_capability_violation = False
|
||||||
|
self._orig_in_test = srv._preflight_in_test_mode
|
||||||
|
srv._preflight_in_test_mode = lambda: False
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
srv._preflight_in_test_mode = self._orig_in_test
|
||||||
|
srv._preflight_resolved_task = None
|
||||||
|
|
||||||
|
def _git_state(self, branch="master", head=MASTER_SHA, porcelain=""):
|
||||||
|
return {
|
||||||
|
"current_branch": branch,
|
||||||
|
"head_sha": head,
|
||||||
|
"porcelain_status": porcelain,
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
|
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||||
|
@patch(
|
||||||
|
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
|
return_value=(True, []),
|
||||||
|
)
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||||
|
@patch(
|
||||||
|
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
|
||||||
|
return_value=MASTER_SHA,
|
||||||
|
)
|
||||||
|
def test_clean_control_checkout_create_issue_succeeds(
|
||||||
|
self, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth
|
||||||
|
):
|
||||||
|
mock_api.return_value = {
|
||||||
|
"number": 99,
|
||||||
|
"html_url": "https://gitea.example.com/issues/99",
|
||||||
|
}
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
|
return_value=self._git_state(),
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server._get_workspace_porcelain", return_value=""
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server._enforce_root_checkout_guard"
|
||||||
|
):
|
||||||
|
# Anti-stomp / master parity: keep gates green.
|
||||||
|
with patch.object(
|
||||||
|
srv,
|
||||||
|
"_run_anti_stomp_preflight",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
res = srv.gitea_create_issue(
|
||||||
|
title="Bootstrap issue from clean control",
|
||||||
|
body="Body text for content gate.",
|
||||||
|
)
|
||||||
|
self.assertEqual(res.get("number"), 99)
|
||||||
|
mock_api.assert_called_once()
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
|
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||||
|
@patch(
|
||||||
|
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
|
return_value=(True, []),
|
||||||
|
)
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||||
|
def test_dirty_control_checkout_create_issue_fails_closed(
|
||||||
|
self, _get_all, mock_api, _role, _ns, _prof, _auth
|
||||||
|
):
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
|
return_value=self._git_state(
|
||||||
|
porcelain=" M author_mutation_worktree.py\n"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server._get_workspace_porcelain",
|
||||||
|
return_value=" M author_mutation_worktree.py\n",
|
||||||
|
):
|
||||||
|
res = srv.gitea_create_issue(
|
||||||
|
title="Should fail on dirty root",
|
||||||
|
body="Body text for content gate.",
|
||||||
|
)
|
||||||
|
self.assertFalse(res.get("success", True) and res.get("number"))
|
||||||
|
if isinstance(res, dict) and res.get("success") is False:
|
||||||
|
blob = " ".join(res.get("reasons") or [])
|
||||||
|
self.assertTrue(
|
||||||
|
"tracked local edits" in blob
|
||||||
|
or "dirty" in blob.lower()
|
||||||
|
or "control checkout" in blob.lower()
|
||||||
|
or res.get("blocker_kind")
|
||||||
|
)
|
||||||
|
# Pre-issue phase must not demand issue-<N> worktree.
|
||||||
|
next_a = res.get("exact_next_action") or ""
|
||||||
|
if next_a:
|
||||||
|
self.assertNotIn("issue-<N>-*", next_a)
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
|
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||||
|
@patch(
|
||||||
|
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
|
return_value=(True, []),
|
||||||
|
)
|
||||||
|
def test_lock_issue_still_requires_branches_worktree(self, _role, _ns, _prof, _auth):
|
||||||
|
"""Existing issue-backed mutations receive no exemption (#749 AC3/AC7)."""
|
||||||
|
srv._preflight_resolved_task = "lock_issue"
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
|
return_value=self._git_state(),
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server._get_workspace_porcelain", return_value=""
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
|
||||||
|
return_value=MASTER_SHA,
|
||||||
|
):
|
||||||
|
with patch.object(
|
||||||
|
srv, "_enforce_root_checkout_guard"
|
||||||
|
):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
srv.verify_preflight_purity(
|
||||||
|
remote="prgs",
|
||||||
|
task="lock_issue",
|
||||||
|
)
|
||||||
|
msg = str(ctx.exception)
|
||||||
|
self.assertTrue(
|
||||||
|
"Branches-only mutation guard" in msg
|
||||||
|
or "stable control checkout" in msg,
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
self.assertIn("control checkout", msg)
|
||||||
|
|
||||||
|
def test_workflow_scope_skips_missing_worktree_for_create_issue_clean_root(self):
|
||||||
|
"""#683 root assessor must not block clean-root create_issue bootstrap."""
|
||||||
|
res = wsg.assess_root_source_mutation(
|
||||||
|
workspace_path=CONTROL_CHECKOUT_ROOT,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
porcelain_status="",
|
||||||
|
role_kind="author",
|
||||||
|
mutation_task="create_issue",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["block"], res)
|
||||||
|
self.assertTrue(res.get("create_issue_bootstrap") or res["proven"])
|
||||||
|
|
||||||
|
def test_workflow_scope_still_blocks_clean_root_for_lock_issue(self):
|
||||||
|
res = wsg.assess_root_source_mutation(
|
||||||
|
workspace_path=CONTROL_CHECKOUT_ROOT,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
porcelain_status="",
|
||||||
|
role_kind="author",
|
||||||
|
mutation_task="lock_issue",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["block"])
|
||||||
|
self.assertEqual(res["blocker_kind"], wsg.BLOCKER_MISSING_WORKTREE)
|
||||||
|
|
||||||
|
def test_arbitrary_branches_directory_not_bootstrap(self):
|
||||||
|
"""#713: mkdir fake under branches/ is not the bootstrap path."""
|
||||||
|
fake = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "fake-mkdir-only")
|
||||||
|
res = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=fake,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=MASTER_SHA,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task="create_issue",
|
||||||
|
)
|
||||||
|
# Under branches/ → not bootstrap; ordinary membership/registration applies.
|
||||||
|
self.assertTrue(res["not_applicable"])
|
||||||
|
self.assertFalse(res["allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateIssueCapabilityAgreement(unittest.TestCase):
|
||||||
|
def test_map_and_alias_agree_on_create_issue(self):
|
||||||
|
import task_capability_map as tcm
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tcm.required_permission("create_issue"),
|
||||||
|
"gitea.issue.create",
|
||||||
|
)
|
||||||
|
# Tool alias must resolve to the same task contract.
|
||||||
|
alias = getattr(tcm, "TOOL_TASK_ALIASES", None) or getattr(
|
||||||
|
tcm, "TASK_ALIASES", None
|
||||||
|
)
|
||||||
|
if alias is not None:
|
||||||
|
mapped = alias.get("gitea_create_issue")
|
||||||
|
if mapped is not None:
|
||||||
|
self.assertEqual(mapped, "create_issue")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -51,29 +51,62 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
|||||||
"porcelain_status": "",
|
"porcelain_status": "",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def test_create_issue_stable_checkout_rejected(
|
def test_create_issue_stable_checkout_bootstrap_allowed_when_clean(
|
||||||
self, _git, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth,
|
self, _git, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth,
|
||||||
):
|
):
|
||||||
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
|
# #749: clean canonical control checkout is the sanctioned create_issue path.
|
||||||
# path is the stable control checkout (not under branches/), mutation must fail.
|
mock_api.return_value = {
|
||||||
|
"number": 77,
|
||||||
|
"html_url": "https://gitea.example.com/issues/77",
|
||||||
|
}
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
try:
|
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||||
res = srv.gitea_create_issue(title="Test issue", body="body text")
|
with patch.object(srv, "_run_anti_stomp_preflight", return_value=None):
|
||||||
except RuntimeError as exc:
|
with patch.object(srv, "_enforce_root_checkout_guard"):
|
||||||
self.assertIn("stable control checkout", str(exc))
|
res = srv.gitea_create_issue(
|
||||||
else:
|
title="Test issue", body="body text for gate"
|
||||||
# #683: production guards return typed blockers at entrypoints
|
)
|
||||||
self.assertFalse(res.get("success"))
|
self.assertEqual(res.get("number"), 77)
|
||||||
self.assertFalse(res.get("performed"))
|
mock_api.assert_called_once()
|
||||||
blob = " ".join(res.get("reasons") or []) + " " + str(
|
|
||||||
res.get("blocker_kind") or ""
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
)
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
self.assertTrue(
|
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||||
"stable control checkout" in blob
|
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
|
||||||
or "missing_issue_worktree" in blob
|
@patch("gitea_mcp_server.api_request")
|
||||||
or "control checkout" in blob.lower()
|
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||||
)
|
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||||
self.assertTrue(res.get("exact_next_action"))
|
@patch(
|
||||||
|
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
|
return_value={
|
||||||
|
"current_branch": "master",
|
||||||
|
"head_sha": "a" * 40,
|
||||||
|
"porcelain_status": " M dirty.py\n",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_create_issue_dirty_control_checkout_rejected(
|
||||||
|
self, _git, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth,
|
||||||
|
):
|
||||||
|
# #749: dirty control checkout still fails closed (no bootstrap).
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server._get_workspace_porcelain",
|
||||||
|
return_value=" M dirty.py\n",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
res = srv.gitea_create_issue(title="Test issue", body="body text")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
self.assertTrue(
|
||||||
|
"tracked local edits" in str(exc)
|
||||||
|
or "dirty" in str(exc).lower()
|
||||||
|
or "bootstrap" in str(exc).lower()
|
||||||
|
or "control checkout" in str(exc).lower()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertFalse(res.get("success", True) and res.get("number"))
|
||||||
|
blob = " ".join(res.get("reasons") or [])
|
||||||
|
self.assertTrue(blob or res.get("blocker_kind"))
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
|
|||||||
@@ -0,0 +1,704 @@
|
|||||||
|
"""Complete canonical-root consumption for cross-repository MCP namespaces.
|
||||||
|
|
||||||
|
#706 introduced an immutable, configured ``canonical_repository_root`` and
|
||||||
|
routed the #274 filesystem guards and the session repository *slug* through it.
|
||||||
|
Three consumption paths were left behind, and this module drives each one
|
||||||
|
through its production entry point:
|
||||||
|
|
||||||
|
* **A — remote initialization.** ``gitea_get_runtime_context`` never normalized
|
||||||
|
its ``remote`` argument, while ``gitea_whoami`` did. A fresh process whose
|
||||||
|
first native call is the runtime-context path therefore pinned the
|
||||||
|
``dadeschools`` argument default instead of the profile's configured remote,
|
||||||
|
and — because first-bind is first-write-wins — no later correct call could
|
||||||
|
repair it.
|
||||||
|
|
||||||
|
* **C — reconciler branch deletion.** The delete-branch repository-binding
|
||||||
|
guard derived its expected slug from ``_workspace_repository_slug``, which
|
||||||
|
reads the git remote of the *installation* checkout (always Gitea-Tools),
|
||||||
|
rather than from the session's configured canonical root.
|
||||||
|
|
||||||
|
* **D — parity evidence.** ``gitea_assess_master_parity`` proves Gitea-Tools
|
||||||
|
*server implementation* parity only, which is intentional. It carried no
|
||||||
|
target-repository dimension at all, so a cross-repository namespace had no
|
||||||
|
evidence that its target checkout was current. The existing
|
||||||
|
``startup_head``/``current_head`` semantics are preserved unchanged and the
|
||||||
|
target-repository assessment is reported under separately labelled fields.
|
||||||
|
|
||||||
|
Groups B and E assert *intentional* behaviour (the #274 guards already consume
|
||||||
|
the canonical root; the configuration surface already validates a
|
||||||
|
repository-specific namespace) so that a regression in either is caught.
|
||||||
|
|
||||||
|
Real git repositories are used throughout; no network calls are made.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import canonical_repository_root as crr # noqa: E402
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
import gitea_mcp_server as srv # noqa: E402
|
||||||
|
import master_parity_gate # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import namespace_workspace_binding as nwb # noqa: E402
|
||||||
|
import session_context_binding as session_ctx # noqa: E402
|
||||||
|
|
||||||
|
INSTALL_ORG = "Scaled-Tech-Consulting"
|
||||||
|
INSTALL_REPO = "Gitea-Tools"
|
||||||
|
INSTALL_SLUG = f"{INSTALL_ORG}/{INSTALL_REPO}"
|
||||||
|
INSTALL_URL = f"https://gitea.prgs.cc/{INSTALL_SLUG}.git"
|
||||||
|
|
||||||
|
TARGET_ORG = "Scaled-Tech-Consulting"
|
||||||
|
TARGET_REPO = "mcp-control-plane"
|
||||||
|
TARGET_SLUG = f"{TARGET_ORG}/{TARGET_REPO}"
|
||||||
|
TARGET_URL = f"https://gitea.prgs.cc/{TARGET_SLUG}.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd: str, *args: str) -> str:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
|
||||||
|
)
|
||||||
|
return res.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
_git(str(path), "init", "-q")
|
||||||
|
_git(str(path), "config", "user.email", "[email protected]")
|
||||||
|
_git(str(path), "config", "user.name", "Test")
|
||||||
|
_git(str(path), "remote", "add", remote_name, remote_url)
|
||||||
|
(path / "README.md").write_text("seed\n")
|
||||||
|
_git(str(path), "add", "README.md")
|
||||||
|
_git(str(path), "commit", "-q", "-m", "seed")
|
||||||
|
return os.path.realpath(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared profile/config construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_AUTHOR_OPS = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
_AUTHOR_FORBIDDEN = [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
]
|
||||||
|
# A profile that may approve or merge must forbid authoring (gitea_config's
|
||||||
|
# reviewer-identity deadlock rule).
|
||||||
|
_REVIEWER_OPS = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
]
|
||||||
|
_REVIEWER_FORBIDDEN = ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"]
|
||||||
|
_MERGER_OPS = ["gitea.read", "gitea.pr.merge", "gitea.pr.comment"]
|
||||||
|
_MERGER_FORBIDDEN = [
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
]
|
||||||
|
_RECONCILER_OPS = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.close",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
]
|
||||||
|
_RECONCILER_FORBIDDEN = [
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
]
|
||||||
|
|
||||||
|
_ROLE_MATRIX = {
|
||||||
|
"author": (_AUTHOR_OPS, _AUTHOR_FORBIDDEN),
|
||||||
|
"reviewer": (_REVIEWER_OPS, _REVIEWER_FORBIDDEN),
|
||||||
|
"merger": (_MERGER_OPS, _MERGER_FORBIDDEN),
|
||||||
|
"reconciler": (_RECONCILER_OPS, _RECONCILER_FORBIDDEN),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(
|
||||||
|
role: str,
|
||||||
|
*,
|
||||||
|
canonical_root: str | None = None,
|
||||||
|
allowed_repositories: list[str] | None = None,
|
||||||
|
username: str = "jcwalker3",
|
||||||
|
) -> dict:
|
||||||
|
allowed, forbidden = _ROLE_MATRIX[role]
|
||||||
|
profile = {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "prgs",
|
||||||
|
"role": role,
|
||||||
|
"username": username,
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "env", "name": f"GITEA_TOKEN_PRGS_{role.upper()}"},
|
||||||
|
"allowed_operations": list(allowed),
|
||||||
|
"forbidden_operations": list(forbidden),
|
||||||
|
"execution_profile": f"prgs-{role}",
|
||||||
|
}
|
||||||
|
if canonical_root is not None:
|
||||||
|
profile["canonical_repository_root"] = canonical_root
|
||||||
|
if allowed_repositories is not None:
|
||||||
|
profile["allowed_repositories"] = list(allowed_repositories)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _config(profiles: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"rules": {"allow_runtime_switching": True},
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {
|
||||||
|
"enabled": True,
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"profiles": profiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _ServerHarness(unittest.TestCase):
|
||||||
|
"""Temp profiles.json + pinned install remote + no network."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = self._dir.name
|
||||||
|
self.config_path = os.path.join(self.tmp, "profiles.json")
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
srv._MUTATION_AUTHORITY = None
|
||||||
|
# The install checkout's remote is Gitea-Tools regardless of the
|
||||||
|
# developer's layout; pin it so "install-derived" is deterministic.
|
||||||
|
self._remote_url = patch.object(
|
||||||
|
srv, "_local_git_remote_url", side_effect=self._install_remote_url
|
||||||
|
)
|
||||||
|
self._remote_url.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._remote_url.stop()
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
srv._MUTATION_AUTHORITY = None
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _install_remote_url(self, remote_name):
|
||||||
|
return INSTALL_URL if remote_name in ("prgs", "origin") else None
|
||||||
|
|
||||||
|
def _write_config(self, profiles: dict) -> None:
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(_config(profiles)))
|
||||||
|
|
||||||
|
def _env(self, profile_name: str, **extra) -> dict:
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": profile_name,
|
||||||
|
"GITEA_TOKEN_PRGS_AUTHOR": "t",
|
||||||
|
"GITEA_TOKEN_PRGS_REVIEWER": "t",
|
||||||
|
"GITEA_TOKEN_PRGS_MERGER": "t",
|
||||||
|
"GITEA_TOKEN_PRGS_RECONCILER": "t",
|
||||||
|
}
|
||||||
|
env.update(extra)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def _api(self, method, url, header):
|
||||||
|
return {
|
||||||
|
"login": "jcwalker3",
|
||||||
|
"full_name": "Test",
|
||||||
|
"id": 1,
|
||||||
|
"email": "[email protected]",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _live(self):
|
||||||
|
return patch("gitea_mcp_server.api_request", side_effect=self._api)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# A. Remote initialization
|
||||||
|
# ===========================================================================
|
||||||
|
class TestRemoteInitializationFirstCall(_ServerHarness):
|
||||||
|
"""A prgs namespace must never pin the dadeschools argument default."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self._write_config(
|
||||||
|
{"prgs-author": _profile("author", allowed_repositories=[INSTALL_SLUG])}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_runtime_context_first_call_does_not_bind_default_remote(self):
|
||||||
|
"""Runtime-context as the FIRST native call, relying on the default."""
|
||||||
|
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||||
|
srv.gitea_get_runtime_context()
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertIsNotNone(ctx, "first call must establish a session binding")
|
||||||
|
self.assertEqual(ctx["remote"], "prgs")
|
||||||
|
self.assertEqual(ctx["host"], "gitea.prgs.cc")
|
||||||
|
self.assertEqual(ctx["org"], INSTALL_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], INSTALL_REPO)
|
||||||
|
|
||||||
|
def test_runtime_context_first_call_reports_effective_remote(self):
|
||||||
|
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||||
|
result = srv.gitea_get_runtime_context()
|
||||||
|
self.assertEqual(result["remote"], "prgs")
|
||||||
|
|
||||||
|
def test_whoami_first_call_remains_correct(self):
|
||||||
|
"""Control: the already-normalizing path is unchanged."""
|
||||||
|
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||||
|
srv.gitea_whoami()
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(ctx["remote"], "prgs")
|
||||||
|
self.assertEqual(ctx["host"], "gitea.prgs.cc")
|
||||||
|
|
||||||
|
def test_explicit_remote_argument_still_honoured(self):
|
||||||
|
"""Normalization only fills the default; explicit values are untouched."""
|
||||||
|
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||||
|
result = srv.gitea_get_runtime_context(remote="prgs")
|
||||||
|
self.assertEqual(result["remote"], "prgs")
|
||||||
|
|
||||||
|
def test_mdcps_profile_default_is_not_rewritten_to_prgs(self):
|
||||||
|
"""A dadeschools-hosted profile keeps the dadeschools remote."""
|
||||||
|
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
|
||||||
|
profile["context"] = "mdcps"
|
||||||
|
profile["base_url"] = "https://gitea.dadeschools.net"
|
||||||
|
self._write_config({"prgs-author": profile})
|
||||||
|
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||||
|
result = srv.gitea_get_runtime_context()
|
||||||
|
self.assertEqual(result["remote"], "dadeschools")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# B. Remote/repository guard (intentional behaviour — regression fence)
|
||||||
|
# ===========================================================================
|
||||||
|
class TestCanonicalRootGuardBinding(_ServerHarness):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||||
|
self.install_root = _init_repo(Path(self.tmp) / "install", INSTALL_URL)
|
||||||
|
|
||||||
|
def test_canonical_target_root_resolves_to_target_slug(self):
|
||||||
|
got = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="profile.canonical_repository_root",
|
||||||
|
expected_slug=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(got.get("block"), got.get("reasons"))
|
||||||
|
self.assertEqual(got["resolved_slug"], TARGET_SLUG)
|
||||||
|
|
||||||
|
def test_install_root_identity_remains_gitea_tools(self):
|
||||||
|
got = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=None,
|
||||||
|
source=None,
|
||||||
|
expected_slug=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
os.path.realpath(got["canonical_repo_root"]), self.install_root
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_guards_validate_against_canonical_target_root(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="author",
|
||||||
|
worktree_path=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
os.path.realpath(ctx["canonical_repo_root"]), self.target_root
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_coordinates_cannot_override_canonical_binding(self):
|
||||||
|
"""Explicit org/repo may confirm, never authorize, a binding."""
|
||||||
|
confirm = session_ctx.assess_repository_override(
|
||||||
|
requested_org=TARGET_ORG,
|
||||||
|
requested_repo=TARGET_REPO,
|
||||||
|
bound_org=TARGET_ORG,
|
||||||
|
bound_repo=TARGET_REPO,
|
||||||
|
)
|
||||||
|
self.assertFalse(confirm.get("block"))
|
||||||
|
override = session_ctx.assess_repository_override(
|
||||||
|
requested_org=TARGET_ORG,
|
||||||
|
requested_repo=TARGET_REPO,
|
||||||
|
bound_org=INSTALL_ORG,
|
||||||
|
bound_repo=INSTALL_REPO,
|
||||||
|
)
|
||||||
|
self.assertTrue(override.get("block"))
|
||||||
|
|
||||||
|
def test_gitea_tools_rooted_namespace_cannot_reach_target_repository(self):
|
||||||
|
"""No canonical root configured → session stays pinned to Gitea-Tools."""
|
||||||
|
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
|
||||||
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
|
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
|
||||||
|
resolved = srv._trusted_session_repository(
|
||||||
|
profile, "prgs", for_mutation=True
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved["repository"], INSTALL_REPO)
|
||||||
|
self.assertNotEqual(resolved["repository"], TARGET_REPO)
|
||||||
|
|
||||||
|
def test_target_rooted_namespace_binds_target_repository(self):
|
||||||
|
profile = _profile(
|
||||||
|
"author",
|
||||||
|
canonical_root=self.target_root,
|
||||||
|
allowed_repositories=[TARGET_SLUG],
|
||||||
|
)
|
||||||
|
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
|
||||||
|
self.assertEqual(resolved["org"], TARGET_ORG)
|
||||||
|
self.assertEqual(resolved["repository"], TARGET_REPO)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# C. Reconciler branch deletion
|
||||||
|
# ===========================================================================
|
||||||
|
class TestDeleteBranchRepositoryBinding(_ServerHarness):
|
||||||
|
"""The binding guard must consult the canonical root, not PROJECT_ROOT.
|
||||||
|
|
||||||
|
No branch is ever deleted here: only the pre-deletion binding guard is
|
||||||
|
exercised.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||||
|
self._write_config(
|
||||||
|
{
|
||||||
|
"prgs-reconciler": _profile(
|
||||||
|
"reconciler",
|
||||||
|
canonical_root=self.target_root,
|
||||||
|
allowed_repositories=[TARGET_SLUG],
|
||||||
|
),
|
||||||
|
"gt-reconciler": _profile(
|
||||||
|
"reconciler", allowed_repositories=[INSTALL_SLUG]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_target_rooted_reconciler_validates_target_deletion(self):
|
||||||
|
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
|
||||||
|
block = srv._delete_branch_repository_binding_block(
|
||||||
|
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||||
|
)
|
||||||
|
self.assertIsNone(block, block)
|
||||||
|
|
||||||
|
def test_target_rooted_reconciler_fails_closed_for_install_repository(self):
|
||||||
|
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
|
||||||
|
block = srv._delete_branch_repository_binding_block(
|
||||||
|
"prgs", org=INSTALL_ORG, repo=INSTALL_REPO
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(block)
|
||||||
|
self.assertEqual(block["blocker_kind"], "repository_binding")
|
||||||
|
self.assertFalse(block["performed"])
|
||||||
|
|
||||||
|
def test_install_rooted_reconciler_fails_closed_for_target_repository(self):
|
||||||
|
with patch.dict(os.environ, self._env("gt-reconciler"), clear=False):
|
||||||
|
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
|
||||||
|
block = srv._delete_branch_repository_binding_block(
|
||||||
|
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(block)
|
||||||
|
self.assertEqual(block["blocker_kind"], "repository_binding")
|
||||||
|
|
||||||
|
def test_env_configured_canonical_root_is_honoured(self):
|
||||||
|
env = self._env("gt-reconciler")
|
||||||
|
env[crr.CANONICAL_ROOT_ENV] = self.target_root
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
allowed = srv._delete_branch_repository_binding_block(
|
||||||
|
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||||
|
)
|
||||||
|
blocked = srv._delete_branch_repository_binding_block(
|
||||||
|
"prgs", org=INSTALL_ORG, repo=INSTALL_REPO
|
||||||
|
)
|
||||||
|
self.assertIsNone(allowed, allowed)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
def test_unresolvable_canonical_root_fails_closed(self):
|
||||||
|
env = self._env("gt-reconciler")
|
||||||
|
env[crr.CANONICAL_ROOT_ENV] = os.path.join(self.tmp, "does-not-exist")
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
block = srv._delete_branch_repository_binding_block(
|
||||||
|
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(block)
|
||||||
|
self.assertEqual(block["blocker_kind"], "repository_binding")
|
||||||
|
|
||||||
|
def test_no_request_parameter_can_override_canonical_identity(self):
|
||||||
|
"""Every explicit coordinate that is not the canonical target is
|
||||||
|
refused; none of them can *establish* the binding."""
|
||||||
|
# Both repositories share an org, so a wrong *org* case must name a
|
||||||
|
# genuinely different owner to be meaningful.
|
||||||
|
cases = [
|
||||||
|
(INSTALL_ORG, INSTALL_REPO),
|
||||||
|
(TARGET_ORG, INSTALL_REPO),
|
||||||
|
(TARGET_ORG, "some-other-repo"),
|
||||||
|
("someone-else", TARGET_REPO),
|
||||||
|
]
|
||||||
|
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
|
||||||
|
for org, repo in cases:
|
||||||
|
with self.subTest(org=org, repo=repo):
|
||||||
|
block = srv._delete_branch_repository_binding_block(
|
||||||
|
"prgs", org=org, repo=repo
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(block, f"{org}/{repo} must fail closed")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# D. Parity semantics
|
||||||
|
# ===========================================================================
|
||||||
|
class TestTargetRepositoryParityAssessment(unittest.TestCase):
|
||||||
|
"""Server-implementation parity is preserved; target parity is additive."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = self._dir.name
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _target(self) -> str:
|
||||||
|
return _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||||
|
|
||||||
|
def test_unconfigured_target_is_reported_not_stale(self):
|
||||||
|
got = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=None, source=None
|
||||||
|
)
|
||||||
|
self.assertFalse(got["configured"])
|
||||||
|
self.assertFalse(got["stale"])
|
||||||
|
self.assertIsNone(got["canonical_repository_root"])
|
||||||
|
|
||||||
|
def test_configured_target_reports_checkout_head_and_slug(self):
|
||||||
|
root = self._target()
|
||||||
|
head = _git(root, "rev-parse", "HEAD")
|
||||||
|
got = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=root, source="profile.canonical_repository_root"
|
||||||
|
)
|
||||||
|
self.assertTrue(got["configured"])
|
||||||
|
self.assertEqual(got["canonical_repository_root"], root)
|
||||||
|
self.assertEqual(got["checkout_head"], head)
|
||||||
|
self.assertEqual(got["repository_slug"], TARGET_SLUG)
|
||||||
|
|
||||||
|
def test_target_stale_when_remote_tracking_ref_is_ahead(self):
|
||||||
|
root = self._target()
|
||||||
|
head = _git(root, "rev-parse", "HEAD")
|
||||||
|
# Simulate a fetched remote-tracking ref that has advanced.
|
||||||
|
_git(root, "checkout", "-q", "-b", "advanced")
|
||||||
|
(Path(root) / "next.md").write_text("next\n")
|
||||||
|
_git(root, "add", "next.md")
|
||||||
|
_git(root, "commit", "-q", "-m", "advance")
|
||||||
|
advanced = _git(root, "rev-parse", "HEAD")
|
||||||
|
_git(root, "update-ref", "refs/remotes/origin/master", advanced)
|
||||||
|
_git(root, "checkout", "-q", "--detach", head)
|
||||||
|
got = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=root, source="profile.canonical_repository_root"
|
||||||
|
)
|
||||||
|
self.assertEqual(got["checkout_head"], head)
|
||||||
|
self.assertEqual(got["remote_tracking_head"], advanced)
|
||||||
|
self.assertTrue(got["stale"])
|
||||||
|
|
||||||
|
def test_missing_root_is_not_determinable_and_fails_closed(self):
|
||||||
|
got = master_parity_gate.assess_target_repository_parity(
|
||||||
|
canonical_root=os.path.join(self.tmp, "absent"),
|
||||||
|
source="profile.canonical_repository_root",
|
||||||
|
)
|
||||||
|
self.assertTrue(got["configured"])
|
||||||
|
self.assertFalse(got["determinable"])
|
||||||
|
self.assertTrue(got["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestParityToolEvidenceDimensions(_ServerHarness):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||||
|
self._write_config(
|
||||||
|
{
|
||||||
|
"prgs-author": _profile(
|
||||||
|
"author",
|
||||||
|
canonical_root=self.target_root,
|
||||||
|
allowed_repositories=[TARGET_SLUG],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_existing_server_parity_semantics_are_unchanged(self):
|
||||||
|
with patch.dict(os.environ, self._env("prgs-author"), clear=False):
|
||||||
|
result = srv.gitea_assess_master_parity(remote="prgs")
|
||||||
|
# startup_head/current_head keep their Gitea-Tools implementation
|
||||||
|
# meaning and must not be redefined to the target repository.
|
||||||
|
self.assertEqual(result["process_root"], srv.PROJECT_ROOT)
|
||||||
|
self.assertEqual(
|
||||||
|
result["startup_head"], srv._STARTUP_PARITY.get("startup_head")
|
||||||
|
)
|
||||||
|
self.assertIn("in_parity", result)
|
||||||
|
|
||||||
|
def test_evidence_distinguishes_server_and_target_dimensions(self):
|
||||||
|
with patch.dict(os.environ, self._env("prgs-author"), clear=False):
|
||||||
|
result = srv.gitea_assess_master_parity(remote="prgs")
|
||||||
|
server = result["server_implementation"]
|
||||||
|
self.assertEqual(server["installation_root"], srv.PROJECT_ROOT)
|
||||||
|
self.assertEqual(server["current_head"], result["current_head"])
|
||||||
|
self.assertIn("stale", server)
|
||||||
|
|
||||||
|
target = result["target_repository"]
|
||||||
|
self.assertTrue(target["configured"])
|
||||||
|
self.assertEqual(target["canonical_repository_root"], self.target_root)
|
||||||
|
self.assertEqual(target["repository_slug"], TARGET_SLUG)
|
||||||
|
self.assertEqual(
|
||||||
|
target["checkout_head"], _git(self.target_root, "rev-parse", "HEAD")
|
||||||
|
)
|
||||||
|
self.assertIn("stale", target)
|
||||||
|
|
||||||
|
def test_unconfigured_namespace_reports_target_as_unconfigured(self):
|
||||||
|
self._write_config(
|
||||||
|
{"prgs-author": _profile("author", allowed_repositories=[INSTALL_SLUG])}
|
||||||
|
)
|
||||||
|
env = self._env("prgs-author")
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
|
||||||
|
result = srv.gitea_assess_master_parity(remote="prgs")
|
||||||
|
self.assertFalse(result["target_repository"]["configured"])
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# E. Startup / configuration validation for a candidate namespace set
|
||||||
|
# ===========================================================================
|
||||||
|
class TestCandidateNamespaceConfiguration(_ServerHarness):
|
||||||
|
"""Four repository-specific profiles for the target repository."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||||
|
self.profiles = {
|
||||||
|
f"mcpcp-{role}": _profile(
|
||||||
|
role,
|
||||||
|
canonical_root=self.target_root,
|
||||||
|
allowed_repositories=[TARGET_SLUG],
|
||||||
|
)
|
||||||
|
for role in ("author", "reviewer", "merger", "reconciler")
|
||||||
|
}
|
||||||
|
self._write_config(self.profiles)
|
||||||
|
|
||||||
|
def test_configuration_audit_succeeds(self):
|
||||||
|
with patch.dict(os.environ, self._env("mcpcp-author"), clear=False):
|
||||||
|
audit = srv.gitea_audit_config()
|
||||||
|
self.assertTrue(audit.get("configured"))
|
||||||
|
names = {row["name"] for row in audit["profiles"]}
|
||||||
|
self.assertEqual(names, set(self.profiles))
|
||||||
|
|
||||||
|
def test_no_inline_credentials_are_present(self):
|
||||||
|
raw = json.loads(Path(self.config_path).read_text())
|
||||||
|
for name, profile in raw["profiles"].items():
|
||||||
|
with self.subTest(profile=name):
|
||||||
|
self.assertNotIn("token", profile)
|
||||||
|
self.assertNotIn("password", profile)
|
||||||
|
self.assertNotIn("secret", profile)
|
||||||
|
self.assertIn(profile["auth"]["type"], ("env", "keychain"))
|
||||||
|
|
||||||
|
def test_bind_time_validation_succeeds_for_target_repository(self):
|
||||||
|
for name, profile in self.profiles.items():
|
||||||
|
with self.subTest(profile=name):
|
||||||
|
resolved = srv._trusted_session_repository(
|
||||||
|
profile, "prgs", for_mutation=True
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved["reasons"], [])
|
||||||
|
self.assertEqual(resolved["org"], TARGET_ORG)
|
||||||
|
self.assertEqual(resolved["repository"], TARGET_REPO)
|
||||||
|
|
||||||
|
def test_wrong_repository_root_fails_closed(self):
|
||||||
|
wrong_root = _init_repo(Path(self.tmp) / "wrong", INSTALL_URL)
|
||||||
|
profile = _profile(
|
||||||
|
"author", canonical_root=wrong_root, allowed_repositories=[TARGET_SLUG]
|
||||||
|
)
|
||||||
|
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
|
||||||
|
self.assertIsNone(resolved["repository"])
|
||||||
|
self.assertTrue(resolved["reasons"])
|
||||||
|
|
||||||
|
def test_existing_install_profiles_are_unchanged_in_behaviour(self):
|
||||||
|
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
|
||||||
|
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
|
||||||
|
self.assertEqual(resolved["org"], INSTALL_ORG)
|
||||||
|
self.assertEqual(resolved["repository"], INSTALL_REPO)
|
||||||
|
|
||||||
|
def _denied(self, profile: dict, operation: str) -> bool:
|
||||||
|
ok, _ = gitea_config.check_operation(
|
||||||
|
operation,
|
||||||
|
profile["allowed_operations"],
|
||||||
|
profile["forbidden_operations"],
|
||||||
|
)
|
||||||
|
return not ok
|
||||||
|
|
||||||
|
def test_role_separation_is_enforced(self):
|
||||||
|
expectations = {
|
||||||
|
"author": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
"reviewer": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||||
|
"merger": [
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
"reconciler": [
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for role, denied_ops in expectations.items():
|
||||||
|
profile = self.profiles[f"mcpcp-{role}"]
|
||||||
|
for op in denied_ops:
|
||||||
|
with self.subTest(role=role, operation=op):
|
||||||
|
self.assertTrue(
|
||||||
|
self._denied(profile, op),
|
||||||
|
f"{role} must not be permitted {op}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_each_role_retains_its_own_capability(self):
|
||||||
|
permitted = {
|
||||||
|
"author": "gitea.pr.create",
|
||||||
|
"reviewer": "gitea.pr.approve",
|
||||||
|
"merger": "gitea.pr.merge",
|
||||||
|
"reconciler": "gitea.branch.delete",
|
||||||
|
}
|
||||||
|
for role, op in permitted.items():
|
||||||
|
with self.subTest(role=role, operation=op):
|
||||||
|
self.assertFalse(self._denied(self.profiles[f"mcpcp-{role}"], op))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,768 @@
|
|||||||
|
"""Complete PROJECT_ROOT elimination in cross-repository MCP operations (#741).
|
||||||
|
|
||||||
|
#706 introduced the immutable ``canonical_repository_root``; #739/#740 routed
|
||||||
|
three consumption paths through it. This module covers the paths #740 left
|
||||||
|
behind, whose shape is uniform: the *filesystem* guards were migrated to the
|
||||||
|
canonical root, but *repository identity* still bottomed out in
|
||||||
|
``_local_git_remote_url``'s hardcoded ``cwd=PROJECT_ROOT`` — always the
|
||||||
|
Gitea-Tools installation checkout.
|
||||||
|
|
||||||
|
The consequences asserted here:
|
||||||
|
|
||||||
|
* **Identity inversion.** ``_resolve``, the #530 remote/repo guard and the
|
||||||
|
anti-stomp org/repo fill all derived the *target* repository from the
|
||||||
|
*install* checkout, so a cross-repository namespace resolved Gitea-Tools
|
||||||
|
coordinates while its branch/parity facts came from the target repo.
|
||||||
|
* **Guard disagreement.** ``_verify_role_mutation_workspace`` omitted
|
||||||
|
``configured_canonical_root``, so the #274 branches-only / worktree-membership
|
||||||
|
guards validated ``Gitea-Tools/branches/`` rather than the bound target.
|
||||||
|
* **Explicit-coordinate override.** Both-explicit ``org``/``repo`` short-circuit
|
||||||
|
``assess_remote_repo_match``, so caller coordinates bypassed validation
|
||||||
|
entirely rather than merely *confirming* the binding.
|
||||||
|
* **Configuration fail-open.** ``_flatten_identity`` silently dropped
|
||||||
|
``canonical_repository_root``, so a v2-``environments`` namespace fell back to
|
||||||
|
the install root; the v1 path never validated the field at all.
|
||||||
|
|
||||||
|
A role-by-operation matrix drives author, reviewer, merger and reconciler
|
||||||
|
against: correct cross-repository target, wrong repository, wrong worktree,
|
||||||
|
explicit matching coordinates, explicit mismatched coordinates, missing
|
||||||
|
canonical root, invalid canonical root, immutable binding after first bind, and
|
||||||
|
request-override attempts.
|
||||||
|
|
||||||
|
Real git repositories are used throughout. No network calls are made, no branch
|
||||||
|
is deleted, and no merge is performed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import canonical_repository_root as crr # noqa: E402
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
import gitea_mcp_server as srv # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import session_context_binding as session_ctx # noqa: E402
|
||||||
|
|
||||||
|
INSTALL_ORG = "Scaled-Tech-Consulting"
|
||||||
|
INSTALL_REPO = "Gitea-Tools"
|
||||||
|
INSTALL_SLUG = f"{INSTALL_ORG}/{INSTALL_REPO}"
|
||||||
|
INSTALL_URL = f"https://gitea.prgs.cc/{INSTALL_SLUG}.git"
|
||||||
|
|
||||||
|
TARGET_ORG = "Scaled-Tech-Consulting"
|
||||||
|
TARGET_REPO = "mcp-control-plane"
|
||||||
|
TARGET_SLUG = f"{TARGET_ORG}/{TARGET_REPO}"
|
||||||
|
TARGET_URL = f"https://gitea.prgs.cc/{TARGET_SLUG}.git"
|
||||||
|
|
||||||
|
THIRD_REPO = "Timesheet"
|
||||||
|
THIRD_SLUG = f"{INSTALL_ORG}/{THIRD_REPO}"
|
||||||
|
|
||||||
|
# tests/conftest.py installs an autouse fixture
|
||||||
|
# (mutation_profile_fixture.install_deterministic_remote_urls) that *permanently
|
||||||
|
# reassigns* srv._local_git_remote_url to a stub mapping remote names to fixed
|
||||||
|
# URLs. That stub deliberately ignores the working directory, which is exactly
|
||||||
|
# the behaviour this module must verify — so these tests would silently assert
|
||||||
|
# against the stub rather than production code in a full-suite run. Capture the
|
||||||
|
# genuine implementation at import time (before any fixture executes) and
|
||||||
|
# reinstall it per test.
|
||||||
|
_REAL_LOCAL_GIT_REMOTE_URL = srv._local_git_remote_url
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd: str, *args: str) -> str:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
|
||||||
|
)
|
||||||
|
return res.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
_git(str(path), "init", "-q")
|
||||||
|
_git(str(path), "config", "user.email", "[email protected]")
|
||||||
|
_git(str(path), "config", "user.name", "Test")
|
||||||
|
_git(str(path), "remote", "add", remote_name, remote_url)
|
||||||
|
# Distinct content per repo: identical seed content, author and timestamp
|
||||||
|
# otherwise produce byte-identical commits and therefore an identical SHA,
|
||||||
|
# which would make the parity-dimension assertions vacuously true.
|
||||||
|
(path / "README.md").write_text(f"seed {remote_url}\n")
|
||||||
|
_git(str(path), "add", "README.md")
|
||||||
|
_git(str(path), "commit", "-q", "-m", f"seed {remote_name}")
|
||||||
|
return os.path.realpath(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
_ROLE_OPS = {
|
||||||
|
"author": (
|
||||||
|
[
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
],
|
||||||
|
["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes"],
|
||||||
|
),
|
||||||
|
"reviewer": (
|
||||||
|
[
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
],
|
||||||
|
["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||||
|
),
|
||||||
|
"merger": (
|
||||||
|
["gitea.read", "gitea.pr.merge", "gitea.pr.comment"],
|
||||||
|
[
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"reconciler": (
|
||||||
|
["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.branch.delete"],
|
||||||
|
[
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
ROLES = tuple(_ROLE_OPS)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(role: str, *, canonical_root: str | None = None) -> dict:
|
||||||
|
allowed, forbidden = _ROLE_OPS[role]
|
||||||
|
profile = {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "prgs",
|
||||||
|
"role": role,
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "env", "name": f"GITEA_TOKEN_PRGS_{role.upper()}"},
|
||||||
|
"allowed_operations": list(allowed),
|
||||||
|
"forbidden_operations": list(forbidden),
|
||||||
|
"execution_profile": f"prgs-{role}",
|
||||||
|
"allowed_repositories": [TARGET_SLUG],
|
||||||
|
}
|
||||||
|
if canonical_root is not None:
|
||||||
|
profile["canonical_repository_root"] = canonical_root
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _config(profiles: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"rules": {"allow_runtime_switching": True},
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": profiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _CrossRepoHarness(unittest.TestCase):
|
||||||
|
"""Real install + target git repos, temp profiles.json, no network."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = self._dir.name
|
||||||
|
self.config_path = os.path.join(self.tmp, "profiles.json")
|
||||||
|
|
||||||
|
# The real install checkout carries a remote literally named `prgs`;
|
||||||
|
# a freshly cloned target repository normally names its remote `origin`.
|
||||||
|
# Reproducing that asymmetry is the point: identity derivation must not
|
||||||
|
# depend on the remote happening to share the `remote=` argument's name.
|
||||||
|
self.install_root = _init_repo(
|
||||||
|
Path(self.tmp) / "install", INSTALL_URL, remote_name="prgs"
|
||||||
|
)
|
||||||
|
self.target_root = _init_repo(
|
||||||
|
Path(self.tmp) / "target", TARGET_URL, remote_name="origin"
|
||||||
|
)
|
||||||
|
self.third_root = _init_repo(
|
||||||
|
Path(self.tmp) / "third", f"https://gitea.prgs.cc/{THIRD_SLUG}.git"
|
||||||
|
)
|
||||||
|
self.not_a_repo = os.path.join(self.tmp, "plain-dir")
|
||||||
|
os.makedirs(self.not_a_repo, exist_ok=True)
|
||||||
|
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
srv._MUTATION_AUTHORITY = None
|
||||||
|
|
||||||
|
# PROJECT_ROOT is wherever the server file physically lives; pin it to a
|
||||||
|
# real Gitea-Tools-identified checkout so "install-derived" is
|
||||||
|
# deterministic regardless of the developer's layout.
|
||||||
|
self._project_root = patch.object(srv, "PROJECT_ROOT", self.install_root)
|
||||||
|
self._project_root.start()
|
||||||
|
# Undo the autouse deterministic-remote stub for this module only.
|
||||||
|
self._real_remote_url = patch.object(
|
||||||
|
srv, "_local_git_remote_url", _REAL_LOCAL_GIT_REMOTE_URL
|
||||||
|
)
|
||||||
|
self._real_remote_url.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._real_remote_url.stop()
|
||||||
|
self._project_root.stop()
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
srv._MUTATION_AUTHORITY = None
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _write_config(self, profiles: dict) -> None:
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(_config(profiles)))
|
||||||
|
|
||||||
|
def _env(self, role: str, **extra) -> dict:
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": f"prgs-{role}",
|
||||||
|
"GITEA_TOKEN_PRGS_AUTHOR": "t",
|
||||||
|
"GITEA_TOKEN_PRGS_REVIEWER": "t",
|
||||||
|
"GITEA_TOKEN_PRGS_MERGER": "t",
|
||||||
|
"GITEA_TOKEN_PRGS_RECONCILER": "t",
|
||||||
|
}
|
||||||
|
env.update(extra)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def _bind(self, role: str, canonical_root: str | None):
|
||||||
|
"""Activate *role* with *canonical_root* and return an env patch ctx."""
|
||||||
|
self._write_config(
|
||||||
|
{f"prgs-{role}": _profile(role, canonical_root=canonical_root)}
|
||||||
|
)
|
||||||
|
return patch.dict(os.environ, self._env(role), clear=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1. The central helper (single source of root resolution)
|
||||||
|
# ===========================================================================
|
||||||
|
class TestCanonicalLocalGitRoot(_CrossRepoHarness):
|
||||||
|
"""_canonical_local_git_root is the one place a target root is derived."""
|
||||||
|
|
||||||
|
def test_unconfigured_namespace_uses_installation_root(self):
|
||||||
|
with self._bind("author", None):
|
||||||
|
self.assertEqual(srv._canonical_local_git_root(), self.install_root)
|
||||||
|
|
||||||
|
def test_configured_namespace_uses_target_root(self):
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
|
||||||
|
|
||||||
|
def test_configured_root_resolves_to_git_toplevel(self):
|
||||||
|
nested = os.path.join(self.target_root, "branches", "wt")
|
||||||
|
os.makedirs(nested, exist_ok=True)
|
||||||
|
with self._bind("author", nested):
|
||||||
|
# A subdirectory of the target repo still resolves to its toplevel.
|
||||||
|
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
|
||||||
|
|
||||||
|
def test_invalid_root_never_silently_becomes_installation_root(self):
|
||||||
|
"""A configured-but-broken root must not fall back to Gitea-Tools."""
|
||||||
|
with self._bind("author", self.not_a_repo):
|
||||||
|
resolved = srv._canonical_local_git_root()
|
||||||
|
self.assertNotEqual(resolved, self.install_root)
|
||||||
|
self.assertEqual(resolved, os.path.realpath(self.not_a_repo))
|
||||||
|
|
||||||
|
def test_env_override_beats_profile_binding(self):
|
||||||
|
self._write_config(
|
||||||
|
{"prgs-author": _profile("author", canonical_root=self.third_root)}
|
||||||
|
)
|
||||||
|
env = self._env("author", GITEA_CANONICAL_REPOSITORY_ROOT=self.target_root)
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2. Repository identity — the upstream inversion
|
||||||
|
# ===========================================================================
|
||||||
|
class TestRepositoryIdentityDerivation(_CrossRepoHarness):
|
||||||
|
"""_local_git_remote_url and its consumers must read the target repo."""
|
||||||
|
|
||||||
|
def test_remote_url_reads_target_not_installation(self):
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
self.assertEqual(srv._local_git_remote_url("origin"), TARGET_URL)
|
||||||
|
|
||||||
|
def test_remote_url_unconfigured_still_reads_installation(self):
|
||||||
|
with self._bind("author", None):
|
||||||
|
# The install checkout's remote is named `prgs`, matching production.
|
||||||
|
self.assertEqual(srv._local_git_remote_url("prgs"), INSTALL_URL)
|
||||||
|
|
||||||
|
def test_workspace_slug_follows_canonical_root(self):
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
self.assertEqual(srv._workspace_repository_slug("origin"), TARGET_SLUG)
|
||||||
|
|
||||||
|
def test_workspace_slug_unconfigured_is_installation(self):
|
||||||
|
with self._bind("author", None):
|
||||||
|
self.assertEqual(srv._workspace_repository_slug("prgs"), INSTALL_SLUG)
|
||||||
|
|
||||||
|
def test_every_role_derives_the_same_target_identity(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
self.assertEqual(
|
||||||
|
srv._workspace_repository_slug("origin"), TARGET_SLUG
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3. _resolve — omitted and explicit coordinates
|
||||||
|
# ===========================================================================
|
||||||
|
class TestResolveTargetCoordinates(_CrossRepoHarness):
|
||||||
|
"""Omitted coordinates follow the binding; explicit ones may only confirm."""
|
||||||
|
|
||||||
|
def test_omitted_coordinates_resolve_to_target_repository(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
_host, org, repo = srv._resolve("prgs", None, None, None)
|
||||||
|
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
|
||||||
|
|
||||||
|
def test_omitted_coordinates_unconfigured_resolve_to_installation(self):
|
||||||
|
with self._bind("author", None):
|
||||||
|
_host, org, repo = srv._resolve("prgs", None, None, None)
|
||||||
|
self.assertEqual((org, repo), (INSTALL_ORG, INSTALL_REPO))
|
||||||
|
|
||||||
|
def test_explicit_matching_coordinates_confirm_the_binding(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
_host, org, repo = srv._resolve(
|
||||||
|
"prgs", None, TARGET_ORG, TARGET_REPO
|
||||||
|
)
|
||||||
|
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
|
||||||
|
|
||||||
|
def test_explicit_mismatched_repository_cannot_override_binding(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
srv._resolve("prgs", None, TARGET_ORG, INSTALL_REPO)
|
||||||
|
msg = str(ctx.exception)
|
||||||
|
self.assertIn("#741", msg)
|
||||||
|
self.assertIn("fail closed", msg)
|
||||||
|
|
||||||
|
def test_explicit_mismatched_organization_cannot_override_binding(self):
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
srv._resolve("prgs", None, "SomeoneElse", TARGET_REPO)
|
||||||
|
|
||||||
|
def test_gitea_tools_rooted_namespace_cannot_target_control_plane(self):
|
||||||
|
"""Direction 1: an install-rooted namespace must not reach the target."""
|
||||||
|
with self._bind("author", self.install_root):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
srv._resolve("prgs", None, TARGET_ORG, TARGET_REPO)
|
||||||
|
|
||||||
|
def test_control_plane_rooted_namespace_cannot_target_gitea_tools(self):
|
||||||
|
"""Direction 2: a target-rooted namespace must not reach Gitea-Tools."""
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
|
||||||
|
|
||||||
|
def test_unconfigured_namespace_keeps_existing_explicit_behaviour(self):
|
||||||
|
"""Same-repository behaviour is unchanged (no new fail-closed path)."""
|
||||||
|
with self._bind("author", None):
|
||||||
|
_host, org, repo = srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
|
||||||
|
self.assertEqual((org, repo), (INSTALL_ORG, INSTALL_REPO))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 4. #274 workspace guard — the two guard paths must agree
|
||||||
|
# ===========================================================================
|
||||||
|
class TestMutationWorkspaceGuardBinding(_CrossRepoHarness):
|
||||||
|
"""Both guard paths must agree on which repository they protect."""
|
||||||
|
|
||||||
|
def _contexts(self, worktree: str | None = None):
|
||||||
|
return srv._resolve_namespace_mutation_context(worktree)
|
||||||
|
|
||||||
|
def test_mutation_context_uses_target_root(self):
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
self.assertEqual(self._contexts()["canonical_repo_root"], self.target_root)
|
||||||
|
|
||||||
|
def test_mutation_context_unconfigured_uses_installation_root(self):
|
||||||
|
with self._bind("author", None):
|
||||||
|
self.assertEqual(self._contexts()["canonical_repo_root"], self.install_root)
|
||||||
|
|
||||||
|
def test_role_mutation_workspace_guard_agrees_with_mutation_context(self):
|
||||||
|
"""The omitted configured_canonical_root made these two disagree."""
|
||||||
|
import namespace_workspace_binding as nwb
|
||||||
|
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
configured, _src = srv._configured_canonical_root()
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind=role,
|
||||||
|
worktree_path=None,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=srv.PROJECT_ROOT,
|
||||||
|
profile_name=f"prgs-{role}",
|
||||||
|
configured_canonical_root=configured,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
assessment["canonical_repo_root"],
|
||||||
|
self._contexts()["canonical_repo_root"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
assessment["canonical_repo_root"], self.target_root
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wrong_worktree_is_rejected_against_target_root(self):
|
||||||
|
"""A worktree belonging to the install repo is not in the target repo."""
|
||||||
|
import author_mutation_worktree as amw
|
||||||
|
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
foreign = os.path.join(self.install_root, "branches", "wt")
|
||||||
|
os.makedirs(foreign, exist_ok=True)
|
||||||
|
membership = amw.assess_workspace_repo_membership(
|
||||||
|
workspace_path=foreign,
|
||||||
|
canonical_repo_root=self.target_root,
|
||||||
|
)
|
||||||
|
self.assertTrue(membership["block"])
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 5. Canonical-root validation — missing / invalid / mismatched
|
||||||
|
# ===========================================================================
|
||||||
|
class TestCanonicalRootFailClosed(_CrossRepoHarness):
|
||||||
|
"""Missing, invalid, ambiguous and mismatched roots fail closed."""
|
||||||
|
|
||||||
|
def _assess(self, value, *, expected=TARGET_SLUG, require=True):
|
||||||
|
return crr.assess_canonical_repository_root(
|
||||||
|
configured_value=value,
|
||||||
|
source="test",
|
||||||
|
expected_slug=expected,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="origin",
|
||||||
|
require_binding=require,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_root_fails_closed_when_binding_required(self):
|
||||||
|
result = self._assess(None)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertFalse(result["configured"])
|
||||||
|
|
||||||
|
def test_missing_root_is_the_single_repo_default_when_not_required(self):
|
||||||
|
result = self._assess(None, expected=None, require=False)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertEqual(result["canonical_repo_root"], self.install_root)
|
||||||
|
|
||||||
|
def test_nonexistent_root_fails_closed(self):
|
||||||
|
result = self._assess(os.path.join(self.tmp, "nope"))
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertIn("does not exist", " ".join(result["reasons"]))
|
||||||
|
|
||||||
|
def test_non_git_directory_fails_closed(self):
|
||||||
|
result = self._assess(self.not_a_repo)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertIn("not a git repository", " ".join(result["reasons"]))
|
||||||
|
|
||||||
|
def test_mismatched_repository_identity_fails_closed(self):
|
||||||
|
result = self._assess(self.third_root)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertIn("mismatch", " ".join(result["reasons"]))
|
||||||
|
|
||||||
|
def test_matching_repository_identity_is_proven(self):
|
||||||
|
result = self._assess(self.target_root)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertEqual(result["resolved_slug"], TARGET_SLUG)
|
||||||
|
self.assertEqual(result["canonical_repo_root"], self.target_root)
|
||||||
|
|
||||||
|
def test_unresolvable_root_yields_fail_closed_reasons_not_fallback(self):
|
||||||
|
with self._bind("author", self.not_a_repo):
|
||||||
|
slug, reasons = srv._canonical_repository_slug(srv.get_profile(), "origin")
|
||||||
|
self.assertIsNone(slug)
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 6. Immutability — first bind wins, requests cannot replace the root
|
||||||
|
# ===========================================================================
|
||||||
|
class TestCanonicalRootImmutability(_CrossRepoHarness):
|
||||||
|
"""No request-supplied value can establish or swap the pinned root."""
|
||||||
|
|
||||||
|
def test_first_bind_pins_target_root(self):
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
with patch(
|
||||||
|
"gitea_mcp_server.api_request",
|
||||||
|
side_effect=lambda *a, **k: {
|
||||||
|
"login": "jcwalker3",
|
||||||
|
"full_name": "T",
|
||||||
|
"id": 1,
|
||||||
|
"email": "[email protected]",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
bound = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(bound["canonical_repository_root"], self.target_root)
|
||||||
|
|
||||||
|
def test_binding_is_first_write_wins(self):
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
org=TARGET_ORG,
|
||||||
|
repository=TARGET_REPO,
|
||||||
|
role_kind="author",
|
||||||
|
source="test",
|
||||||
|
canonical_repository_root=self.target_root,
|
||||||
|
)
|
||||||
|
# A second, contradictory seed must not replace the pin.
|
||||||
|
session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
org=INSTALL_ORG,
|
||||||
|
repository=INSTALL_REPO,
|
||||||
|
role_kind="author",
|
||||||
|
source="test-2",
|
||||||
|
canonical_repository_root=self.install_root,
|
||||||
|
)
|
||||||
|
bound = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(bound["canonical_repository_root"], self.target_root)
|
||||||
|
self.assertEqual(bound["repository"], TARGET_REPO)
|
||||||
|
|
||||||
|
def test_request_supplied_worktree_cannot_replace_the_root(self):
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
ctx = srv._resolve_namespace_mutation_context(self.install_root)
|
||||||
|
# The workspace argument may be demoted/inspected, but the canonical
|
||||||
|
# repository root stays the configured target.
|
||||||
|
self.assertEqual(ctx["canonical_repo_root"], self.target_root)
|
||||||
|
|
||||||
|
def test_request_supplied_coordinates_cannot_replace_the_root(self):
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
|
||||||
|
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 7. Parity dimensions stay separately labelled (#739 F3 preserved)
|
||||||
|
# ===========================================================================
|
||||||
|
class TestParityDimensionSeparation(_CrossRepoHarness):
|
||||||
|
"""Server-implementation parity stays anchored to the install checkout."""
|
||||||
|
|
||||||
|
def test_server_dimension_is_installation_not_target(self):
|
||||||
|
import master_parity_gate
|
||||||
|
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
head = master_parity_gate.read_git_head(srv.PROJECT_ROOT)
|
||||||
|
self.assertEqual(head, _git(self.install_root, "rev-parse", "HEAD"))
|
||||||
|
self.assertNotEqual(head, _git(self.target_root, "rev-parse", "HEAD"))
|
||||||
|
|
||||||
|
def test_target_dimension_reads_the_target_checkout(self):
|
||||||
|
with self._bind("author", self.target_root):
|
||||||
|
self.assertEqual(
|
||||||
|
_git(srv._canonical_local_git_root(), "rev-parse", "HEAD"),
|
||||||
|
_git(self.target_root, "rev-parse", "HEAD"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 8. Configuration loaders validate the binding consistently (AC13)
|
||||||
|
# ===========================================================================
|
||||||
|
class TestConfigurationLoaderValidation(unittest.TestCase):
|
||||||
|
"""Every supported loader treats canonical_repository_root identically."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = self._dir.name
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _write(self, data: dict) -> str:
|
||||||
|
path = os.path.join(self.tmp, "profiles.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(data))
|
||||||
|
return path
|
||||||
|
|
||||||
|
# --- v1 ---------------------------------------------------------------
|
||||||
|
def _v1(self, root):
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN"},
|
||||||
|
"canonical_repository_root": root,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_v1_rejects_relative_canonical_root(self):
|
||||||
|
path = self._write(self._v1("relative/path"))
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
gitea_config.load_config(path)
|
||||||
|
self.assertIn("absolute", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_v1_rejects_blank_canonical_root(self):
|
||||||
|
path = self._write(self._v1(" "))
|
||||||
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
gitea_config.load_config(path)
|
||||||
|
|
||||||
|
def test_v1_accepts_absolute_canonical_root(self):
|
||||||
|
path = self._write(self._v1("/abs/target"))
|
||||||
|
loaded = gitea_config.load_config(path)
|
||||||
|
self.assertEqual(
|
||||||
|
loaded["profiles"]["prgs-author"]["canonical_repository_root"],
|
||||||
|
"/abs/target",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_v1_without_the_field_is_unchanged(self):
|
||||||
|
data = self._v1("/abs/target")
|
||||||
|
del data["profiles"]["prgs-author"]["canonical_repository_root"]
|
||||||
|
loaded = gitea_config.load_config(self._write(data))
|
||||||
|
self.assertNotIn("canonical_repository_root", loaded["profiles"]["prgs-author"])
|
||||||
|
|
||||||
|
# --- v2 environments --------------------------------------------------
|
||||||
|
def _v2_env(self, root):
|
||||||
|
ident = {
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN"},
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"allowed_operations": ["gitea.read"],
|
||||||
|
}
|
||||||
|
if root is not None:
|
||||||
|
ident["canonical_repository_root"] = root
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"environments": {
|
||||||
|
"prgs": {"services": {"gitea": {"identities": {"author": ident}}}}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_v2_environments_propagates_canonical_root(self):
|
||||||
|
"""Regression: the field was silently dropped during flattening."""
|
||||||
|
loaded = gitea_config.load_config(self._write(self._v2_env("/abs/target")))
|
||||||
|
profile = loaded["profiles"]["prgs.gitea.author"]
|
||||||
|
self.assertEqual(profile["canonical_repository_root"], "/abs/target")
|
||||||
|
|
||||||
|
def test_v2_environments_rejects_relative_canonical_root(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
gitea_config.load_config(self._write(self._v2_env("relative/path")))
|
||||||
|
self.assertIn("absolute", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_v2_environments_without_the_field_is_unchanged(self):
|
||||||
|
loaded = gitea_config.load_config(self._write(self._v2_env(None)))
|
||||||
|
self.assertNotIn(
|
||||||
|
"canonical_repository_root", loaded["profiles"]["prgs.gitea.author"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- v2 contexts (already validated; guard against regression) ---------
|
||||||
|
def test_v2_contexts_still_rejects_relative_canonical_root(self):
|
||||||
|
data = {
|
||||||
|
"version": 2,
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "prgs",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN"},
|
||||||
|
"allowed_operations": ["gitea.read"],
|
||||||
|
"canonical_repository_root": "relative/path",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
gitea_config.load_config(self._write(data))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 9. Role-by-operation matrix
|
||||||
|
# ===========================================================================
|
||||||
|
class TestRoleOperationMatrix(_CrossRepoHarness):
|
||||||
|
"""Each role, each cross-repository condition, one assertion per cell."""
|
||||||
|
|
||||||
|
def test_correct_target_resolves_for_every_role(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role, case="correct-target"):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
_h, org, repo = srv._resolve("prgs", None, None, None)
|
||||||
|
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
|
||||||
|
|
||||||
|
def test_wrong_repository_is_rejected_for_every_role(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role, case="wrong-repo"):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
|
||||||
|
|
||||||
|
def test_explicit_matching_coordinates_pass_for_every_role(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role, case="explicit-match"):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
_h, org, repo = srv._resolve("prgs", None, TARGET_ORG, TARGET_REPO)
|
||||||
|
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
|
||||||
|
|
||||||
|
def test_explicit_mismatch_fails_closed_for_every_role(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role, case="explicit-mismatch"):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
srv._resolve("prgs", None, INSTALL_ORG, THIRD_REPO)
|
||||||
|
|
||||||
|
def test_missing_canonical_root_preserves_single_repo_default(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role, case="missing-root"):
|
||||||
|
with self._bind(role, None):
|
||||||
|
self.assertEqual(srv._canonical_local_git_root(), self.install_root)
|
||||||
|
|
||||||
|
def test_invalid_canonical_root_never_becomes_install_root(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role, case="invalid-root"):
|
||||||
|
with self._bind(role, self.not_a_repo):
|
||||||
|
self.assertNotEqual(
|
||||||
|
srv._canonical_local_git_root(), self.install_root
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wrong_worktree_rejected_for_every_role(self):
|
||||||
|
import author_mutation_worktree as amw
|
||||||
|
|
||||||
|
foreign = os.path.join(self.install_root, "branches", "foreign")
|
||||||
|
os.makedirs(foreign, exist_ok=True)
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role, case="wrong-worktree"):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
membership = amw.assess_workspace_repo_membership(
|
||||||
|
workspace_path=foreign,
|
||||||
|
canonical_repo_root=srv._canonical_local_git_root(),
|
||||||
|
)
|
||||||
|
self.assertTrue(membership["block"])
|
||||||
|
|
||||||
|
def test_request_override_attempt_rejected_for_every_role(self):
|
||||||
|
for role in ROLES:
|
||||||
|
with self.subTest(role=role, case="request-override"):
|
||||||
|
with self._bind(role, self.target_root):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
srv._resolve("prgs", None, "Attacker", "evil-repo")
|
||||||
|
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
"""Reconciler role binding for post-merge moot-lease cleanup (#745).
|
||||||
|
|
||||||
|
``gitea_cleanup_post_merge_moot_lease`` (#515) posts a terminal ``phase:
|
||||||
|
released`` lease marker but had no capability-map entry and no role gate: entry
|
||||||
|
required only ``gitea.read``, apply required only ``gitea.pr.comment``, so any
|
||||||
|
profile holding the comment permission reached the mutation while the
|
||||||
|
reconciler could not satisfy resolve-exact-task -> mutation.
|
||||||
|
|
||||||
|
These tests pin the fixed contract:
|
||||||
|
|
||||||
|
- the canonical task and its tool-name alias resolve identically
|
||||||
|
(``gitea.pr.comment`` + ``reconciler``);
|
||||||
|
- only a reconciler profile satisfies permission AND role;
|
||||||
|
- ``apply=false`` assessment stays reachable under ``gitea.read`` for any role
|
||||||
|
and mutates nothing (documented, deliberate divergence from the apply path);
|
||||||
|
- ``apply=true`` requires the exact resolved task, the reconciler role, the
|
||||||
|
comment permission, a validated repository binding and matching dry-run
|
||||||
|
evidence;
|
||||||
|
- live/non-moot/superseded/mismatched/malformed leases fail closed;
|
||||||
|
- the dry-run ledger is append-only and cleanup stays idempotent.
|
||||||
|
|
||||||
|
Every fixture is synthetic. No production PR, lease session or marker is used
|
||||||
|
anywhere in this module (see ``TestNoProductionLeaseTouched``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import os # noqa: E402
|
||||||
|
import unittest # noqa: E402
|
||||||
|
from datetime import datetime, timezone # noqa: E402
|
||||||
|
from unittest.mock import patch # noqa: E402
|
||||||
|
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import post_merge_moot_lease_gate as gate # noqa: E402
|
||||||
|
import reviewer_pr_lease as leases # noqa: E402
|
||||||
|
from mcp_server import gitea_cleanup_post_merge_moot_lease # noqa: E402
|
||||||
|
from role_session_router import RECONCILER_TASKS, TASK_REQUIRED_ROLE # noqa: E402
|
||||||
|
from task_capability_map import required_permission, required_role # noqa: E402
|
||||||
|
|
||||||
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
PR = 487
|
||||||
|
ISSUE = 485
|
||||||
|
SESSION = "97274-676d20a825c4"
|
||||||
|
HEAD_A = "a" * 40
|
||||||
|
HEAD_B = "d" * 40
|
||||||
|
LEASE_COMMENT_ID = 6603
|
||||||
|
|
||||||
|
CANONICAL_TASK = "cleanup_post_merge_moot_lease"
|
||||||
|
TOOL_ALIAS = "gitea_cleanup_post_merge_moot_lease"
|
||||||
|
|
||||||
|
_BASE_OPS = "gitea.read,gitea.pr.comment"
|
||||||
|
RECONCILER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reconciler",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.close,gitea.branch.delete",
|
||||||
|
}
|
||||||
|
AUTHOR_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-author",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.create,gitea.issue.create",
|
||||||
|
}
|
||||||
|
REVIEWER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.review,gitea.pr.approve",
|
||||||
|
}
|
||||||
|
MERGER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-merger",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": _BASE_OPS + ",gitea.pr.merge",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Permission shape of the configured role profiles (mirrors
|
||||||
|
# tests/test_task_capability_role_invariants.py CANONICAL_ROLE_PROFILES).
|
||||||
|
ROLE_PROFILE_PERMISSIONS = {
|
||||||
|
"author": {"gitea.read", "gitea.pr.comment", "gitea.pr.create",
|
||||||
|
"gitea.issue.create", "gitea.issue.comment", "gitea.issue.close",
|
||||||
|
"gitea.branch.create", "gitea.branch.push", "gitea.repo.commit"},
|
||||||
|
"reviewer": {"gitea.read", "gitea.pr.comment", "gitea.pr.review",
|
||||||
|
"gitea.pr.approve", "gitea.pr.request_changes",
|
||||||
|
"gitea.issue.comment"},
|
||||||
|
"merger": {"gitea.read", "gitea.pr.comment", "gitea.pr.merge",
|
||||||
|
"gitea.issue.comment"},
|
||||||
|
"reconciler": {"gitea.read", "gitea.pr.comment", "gitea.pr.close",
|
||||||
|
"gitea.issue.close", "gitea.branch.delete"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_comment(pr_number=PR, session_id=SESSION, *, phase="claimed",
|
||||||
|
candidate_head=HEAD_A, comment_id=LEASE_COMMENT_ID):
|
||||||
|
body = leases.format_lease_body(
|
||||||
|
repo=SLUG,
|
||||||
|
pr_number=pr_number,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
reviewer_identity="sysadmin",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id=session_id,
|
||||||
|
worktree="branches/review-pr487",
|
||||||
|
phase=phase,
|
||||||
|
candidate_head=candidate_head,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
last_activity=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
return {"id": comment_id, "body": body, "user": {"login": "sysadmin"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _api_side_effect(*, pr_state, pr_merged, comments, posted_id=9999):
|
||||||
|
"""api_request side effect keyed on method + url; records POSTs."""
|
||||||
|
calls = {"post": []}
|
||||||
|
|
||||||
|
def _side(method, url, auth=None, payload=None, *a, **k):
|
||||||
|
if (method or "").upper() == "POST":
|
||||||
|
calls["post"].append({"url": url, "payload": payload})
|
||||||
|
return {"id": posted_id}
|
||||||
|
if "/comments" in url:
|
||||||
|
return list(comments)
|
||||||
|
if "/pulls/" in url:
|
||||||
|
pr = {"state": pr_state, "number": PR, "merge_commit_sha": "c" * 40}
|
||||||
|
if pr_merged:
|
||||||
|
pr["merged"] = True
|
||||||
|
pr["merged_at"] = "2026-07-08T07:46:04Z"
|
||||||
|
return pr
|
||||||
|
if "/issues/" in url:
|
||||||
|
return {"state": "closed" if pr_merged else "open", "number": ISSUE}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return _side, calls
|
||||||
|
|
||||||
|
|
||||||
|
def _assessment(comments, *, pr_merged=True, pr_state="closed"):
|
||||||
|
return leases.assess_post_merge_moot_lease(
|
||||||
|
comments, pr_number=PR, pr_merged=pr_merged, pr_state=pr_state,
|
||||||
|
merge_commit_sha="c" * 40,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 1-5. Capability map / router contract
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class TestCleanupTaskContract(unittest.TestCase):
|
||||||
|
def test_canonical_task_is_reconciler_owned(self):
|
||||||
|
"""1. The reconciler is the role that can resolve the cleanup task."""
|
||||||
|
self.assertEqual(required_permission(CANONICAL_TASK), "gitea.pr.comment")
|
||||||
|
self.assertEqual(required_role(CANONICAL_TASK), "reconciler")
|
||||||
|
|
||||||
|
def test_tool_alias_resolves_to_identical_contract(self):
|
||||||
|
"""5. Alias and canonical task must not diverge."""
|
||||||
|
self.assertEqual(
|
||||||
|
(required_permission(TOOL_ALIAS), required_role(TOOL_ALIAS)),
|
||||||
|
(required_permission(CANONICAL_TASK), required_role(CANONICAL_TASK)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_author_reviewer_merger_cannot_resolve_the_task(self):
|
||||||
|
"""2-4. No non-reconciler role satisfies permission AND role."""
|
||||||
|
for role in ("author", "reviewer", "merger"):
|
||||||
|
with self.subTest(role=role):
|
||||||
|
self.assertNotEqual(required_role(CANONICAL_TASK), role)
|
||||||
|
# They hold the permission — which is exactly why the role gate
|
||||||
|
# is required rather than optional.
|
||||||
|
self.assertIn(
|
||||||
|
"gitea.pr.comment", ROLE_PROFILE_PERMISSIONS[role],
|
||||||
|
"test premise: non-reconciler roles do hold pr.comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconciler_profile_satisfies_permission_and_role(self):
|
||||||
|
self.assertIn(
|
||||||
|
required_permission(CANONICAL_TASK),
|
||||||
|
ROLE_PROFILE_PERMISSIONS[required_role(CANONICAL_TASK)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_router_agrees_with_capability_map(self):
|
||||||
|
for task in (CANONICAL_TASK, TOOL_ALIAS):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertIn(task, RECONCILER_TASKS)
|
||||||
|
self.assertEqual(TASK_REQUIRED_ROLE[task], required_role(task))
|
||||||
|
|
||||||
|
def test_unknown_alias_still_rejected(self):
|
||||||
|
for bogus in ("cleanup_post_merge_moot_leases", "cleanup_moot_lease", ""):
|
||||||
|
with self.subTest(task=bogus):
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
required_role(bogus)
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
required_permission(bogus)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Authorization gate unit tests
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class TestApplyAuthorizationGate(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
gate._reset_for_testing()
|
||||||
|
self.addCleanup(gate._reset_for_testing)
|
||||||
|
self.assessment = _assessment([_lease_comment()])
|
||||||
|
|
||||||
|
def _evidence(self, **over):
|
||||||
|
base = dict(
|
||||||
|
pr_number=PR, repository_slug=SLUG, lease_moot=True,
|
||||||
|
cleanup_allowed=True, session_id=SESSION, candidate_head=HEAD_A,
|
||||||
|
lease_comment_id=LEASE_COMMENT_ID,
|
||||||
|
)
|
||||||
|
base.update(over)
|
||||||
|
return gate.record_dry_run(**base)
|
||||||
|
|
||||||
|
def _assess(self, **over):
|
||||||
|
kwargs = dict(
|
||||||
|
pr_number=PR, repository_slug=SLUG, resolved_task=CANONICAL_TASK,
|
||||||
|
active_role_kind="reconciler", assessment=self.assessment,
|
||||||
|
evidence=self._evidence(),
|
||||||
|
)
|
||||||
|
kwargs.update(over)
|
||||||
|
return gate.assess_apply_authorization(**kwargs)
|
||||||
|
|
||||||
|
def test_reconciler_with_matching_evidence_is_authorized(self):
|
||||||
|
result = self._assess()
|
||||||
|
self.assertTrue(result["allowed"], result["reasons"])
|
||||||
|
self.assertTrue(result["evidence_matched"])
|
||||||
|
|
||||||
|
def test_apply_without_exact_task_resolution_fails(self):
|
||||||
|
"""8. Apply without exact task resolution fails preflight."""
|
||||||
|
result = self._assess(resolved_task=None)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "unresolved_cleanup_task")
|
||||||
|
|
||||||
|
def test_resolving_another_task_does_not_authorize_cleanup(self):
|
||||||
|
"""9. A sibling reconciler task is not a substitute."""
|
||||||
|
for other in ("delete_branch", "reconcile_already_landed_pr",
|
||||||
|
"cleanup_merged_pr_branch", "comment_pr"):
|
||||||
|
with self.subTest(task=other):
|
||||||
|
result = self._assess(resolved_task=other)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["blocker_kind"], "unresolved_cleanup_task")
|
||||||
|
|
||||||
|
def test_non_reconciler_roles_fail_closed(self):
|
||||||
|
"""10. Author/reviewer/merger cannot apply despite pr.comment."""
|
||||||
|
for role in ("author", "reviewer", "merger", None, ""):
|
||||||
|
with self.subTest(role=role):
|
||||||
|
result = self._assess(active_role_kind=role)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "wrong_role")
|
||||||
|
|
||||||
|
def test_missing_repository_identity_fails_closed(self):
|
||||||
|
result = self._assess(repository_slug=None)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "repository_binding")
|
||||||
|
|
||||||
|
def test_missing_dry_run_evidence_fails_closed(self):
|
||||||
|
result = self._assess(evidence=None)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "missing_dry_run_evidence")
|
||||||
|
|
||||||
|
def test_dry_run_that_disallowed_cleanup_fails_closed(self):
|
||||||
|
result = self._assess(evidence=self._evidence(cleanup_allowed=False))
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "dry_run_not_allowed")
|
||||||
|
|
||||||
|
def test_dry_run_for_another_pr_or_repo_fails_closed(self):
|
||||||
|
"""11. Wrong PR / repository fails closed."""
|
||||||
|
for over in ({"pr_number": PR + 1}, {"repository_slug": "Other/Repo"}):
|
||||||
|
with self.subTest(**over):
|
||||||
|
result = self._assess(evidence=self._evidence(**over))
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "dry_run_mismatch")
|
||||||
|
|
||||||
|
def test_superseded_lease_fails_closed(self):
|
||||||
|
"""12. Head/session/marker drift since the dry run fails closed."""
|
||||||
|
for over in ({"session_id": "other-session"},
|
||||||
|
{"candidate_head": HEAD_B},
|
||||||
|
{"lease_comment_id": 7777}):
|
||||||
|
with self.subTest(**over):
|
||||||
|
result = self._assess(evidence=self._evidence(**over))
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "superseded_lease")
|
||||||
|
|
||||||
|
def test_expectation_mismatch_fails_closed(self):
|
||||||
|
"""11. Wrong session / head / marker expectations fail closed."""
|
||||||
|
for over in ({"expected_session_id": "nope"},
|
||||||
|
{"expected_candidate_head": HEAD_B},
|
||||||
|
{"expected_lease_comment_id": 7777}):
|
||||||
|
with self.subTest(**over):
|
||||||
|
result = self._assess(**over)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "lease_mismatch")
|
||||||
|
|
||||||
|
def test_matching_expectations_are_authorized(self):
|
||||||
|
result = self._assess(
|
||||||
|
expected_session_id=SESSION,
|
||||||
|
expected_candidate_head=HEAD_A,
|
||||||
|
expected_lease_comment_id=LEASE_COMMENT_ID,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["allowed"], result["reasons"])
|
||||||
|
|
||||||
|
def test_non_moot_lease_fails_closed(self):
|
||||||
|
"""12. A live lease on an open PR is never cleanable."""
|
||||||
|
open_pr = _assessment(
|
||||||
|
[_lease_comment()], pr_merged=False, pr_state="open")
|
||||||
|
result = self._assess(assessment=open_pr)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertIn(
|
||||||
|
result["blocker_kind"], ("lease_not_moot", "malformed_lease"))
|
||||||
|
|
||||||
|
def test_malformed_lease_fails_closed(self):
|
||||||
|
malformed = dict(self.assessment)
|
||||||
|
malformed["active_lease"] = {
|
||||||
|
"session_id": "", "candidate_head": None, "comment_id": None}
|
||||||
|
result = self._assess(assessment=malformed)
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "malformed_lease")
|
||||||
|
|
||||||
|
def test_ledger_is_append_only(self):
|
||||||
|
"""14. Recording never rewrites or drops prior entries."""
|
||||||
|
first = self._evidence()
|
||||||
|
second = self._evidence(candidate_head=HEAD_B, lease_comment_id=7777)
|
||||||
|
history = gate.dry_run_history()
|
||||||
|
self.assertEqual(len(history), 2)
|
||||||
|
self.assertEqual(history[0]["candidate_head"], first["candidate_head"])
|
||||||
|
self.assertEqual(history[1]["candidate_head"], HEAD_B)
|
||||||
|
# Newest-wins for lookup, but the older entry survives in history.
|
||||||
|
latest = gate.latest_dry_run(pr_number=PR, repository_slug=SLUG)
|
||||||
|
self.assertEqual(latest["lease_comment_id"], second["lease_comment_id"])
|
||||||
|
self.assertEqual(gate.dry_run_history()[0]["lease_comment_id"],
|
||||||
|
LEASE_COMMENT_ID)
|
||||||
|
|
||||||
|
def test_history_view_cannot_mutate_the_ledger(self):
|
||||||
|
self._evidence()
|
||||||
|
snapshot = gate.dry_run_history()
|
||||||
|
snapshot[0]["pr_number"] = 999999
|
||||||
|
self.assertEqual(
|
||||||
|
gate.dry_run_history()[0]["pr_number"], PR,
|
||||||
|
"dry_run_history must hand out copies, not live rows")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Tool-level behavior
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class _ToolCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
gate._reset_for_testing()
|
||||||
|
self.addCleanup(gate._reset_for_testing)
|
||||||
|
|
||||||
|
def _run(self, env, *, apply, comments, pr_state="closed", pr_merged=True,
|
||||||
|
resolved_task=CANONICAL_TASK, slug=SLUG, **kwargs):
|
||||||
|
side, calls = _api_side_effect(
|
||||||
|
pr_state=pr_state, pr_merged=pr_merged, comments=comments)
|
||||||
|
with patch("mcp_server.api_request", side_effect=side), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server._bound_repository_slug", return_value=slug), \
|
||||||
|
patch("mcp_server._repository_binding_block", return_value=None), \
|
||||||
|
patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
resolved_task), \
|
||||||
|
patch.dict(os.environ, env, clear=True):
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=apply, remote="prgs", **kwargs)
|
||||||
|
return result, calls
|
||||||
|
|
||||||
|
|
||||||
|
class TestDryRunOpenToEveryRole(_ToolCase):
|
||||||
|
"""7. Dry run performs no mutation and stays under the read capability."""
|
||||||
|
|
||||||
|
def test_dry_run_reports_moot_and_mutates_nothing_for_every_role(self):
|
||||||
|
for name, env in (("reconciler", RECONCILER_ENV), ("author", AUTHOR_ENV),
|
||||||
|
("reviewer", REVIEWER_ENV), ("merger", MERGER_ENV)):
|
||||||
|
with self.subTest(role=name):
|
||||||
|
gate._reset_for_testing()
|
||||||
|
result, calls = self._run(
|
||||||
|
env, apply=False, comments=[_lease_comment()],
|
||||||
|
resolved_task=None)
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertTrue(result["lease_moot"])
|
||||||
|
self.assertTrue(result["cleanup_allowed"])
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertEqual(result["mode"], "read_only")
|
||||||
|
self.assertEqual(calls["post"], [], "dry run must not mutate")
|
||||||
|
|
||||||
|
def test_dry_run_records_evidence(self):
|
||||||
|
result, _ = self._run(
|
||||||
|
RECONCILER_ENV, apply=False, comments=[_lease_comment()])
|
||||||
|
evidence = result["dry_run_evidence"]
|
||||||
|
self.assertEqual(evidence["pr_number"], PR)
|
||||||
|
self.assertEqual(evidence["repository_slug"], SLUG)
|
||||||
|
self.assertEqual(evidence["session_id"], SESSION)
|
||||||
|
self.assertEqual(evidence["candidate_head"], HEAD_A)
|
||||||
|
self.assertEqual(evidence["lease_comment_id"], LEASE_COMMENT_ID)
|
||||||
|
self.assertTrue(evidence["lease_moot"])
|
||||||
|
self.assertTrue(evidence["cleanup_allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyRequiresReconciler(_ToolCase):
|
||||||
|
def test_reconciler_apply_succeeds_after_matching_dry_run(self):
|
||||||
|
"""7 (apply). Allowed dry run then apply posts exactly one marker."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
dry, dry_calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
self.assertTrue(dry["cleanup_allowed"])
|
||||||
|
self.assertEqual(dry_calls["post"], [])
|
||||||
|
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=comments)
|
||||||
|
self.assertTrue(result["success"], result.get("reasons"))
|
||||||
|
self.assertTrue(result["cleanup_performed"])
|
||||||
|
self.assertEqual(result["released_comment_id"], 9999)
|
||||||
|
self.assertEqual(len(calls["post"]), 1)
|
||||||
|
body = calls["post"][0]["payload"]["body"]
|
||||||
|
self.assertIn("phase: released", body)
|
||||||
|
self.assertIn("post-merge-moot", body)
|
||||||
|
|
||||||
|
def test_apply_without_dry_run_fails_closed(self):
|
||||||
|
"""6. Apply must be preceded by a matching dry run."""
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=[_lease_comment()])
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "missing_dry_run_evidence")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_apply_without_exact_task_resolution_fails_closed(self):
|
||||||
|
"""8. No resolved cleanup task -> no mutation."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=comments, resolved_task=None)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "unresolved_cleanup_task")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_resolving_a_different_task_does_not_authorize_apply(self):
|
||||||
|
"""9. Another resolved task is not a substitute."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=comments,
|
||||||
|
resolved_task="delete_branch")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "unresolved_cleanup_task")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_author_reviewer_merger_cannot_apply(self):
|
||||||
|
"""10. Permission-only roles are refused at the role gate."""
|
||||||
|
for name, env in (("author", AUTHOR_ENV), ("reviewer", REVIEWER_ENV),
|
||||||
|
("merger", MERGER_ENV)):
|
||||||
|
with self.subTest(role=name):
|
||||||
|
gate._reset_for_testing()
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
self._run(env, apply=False, comments=comments)
|
||||||
|
result, calls = self._run(env, apply=True, comments=comments)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "wrong_role")
|
||||||
|
self.assertEqual(
|
||||||
|
calls["post"], [],
|
||||||
|
f"{name} must not post a terminal lease marker")
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyFailsClosedOnLeaseState(_ToolCase):
|
||||||
|
def test_open_pr_lease_is_never_force_cleaned(self):
|
||||||
|
"""12. Non-moot: an active lease on an open PR stays untouched."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=comments,
|
||||||
|
pr_state="open", pr_merged=False)
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertFalse(result["pr_merged_or_closed"])
|
||||||
|
self.assertEqual(calls["post"], [], "never force-clean an open PR lease")
|
||||||
|
self.assertTrue(any(
|
||||||
|
"still open" in r for r in result.get("cleanup_skipped_reason", [])))
|
||||||
|
|
||||||
|
def test_superseded_lease_between_dry_run_and_apply_fails_closed(self):
|
||||||
|
"""12. The lease moved on after the dry run -> refuse."""
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=[_lease_comment()])
|
||||||
|
moved = [_lease_comment(session_id="fresh-session",
|
||||||
|
candidate_head=HEAD_B, comment_id=7777)]
|
||||||
|
result, calls = self._run(RECONCILER_ENV, apply=True, comments=moved)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "superseded_lease")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_expectation_mismatch_fails_closed(self):
|
||||||
|
"""11. Wrong session / head / marker expectations refuse the apply."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
for kwargs in ({"expected_session_id": "wrong-session"},
|
||||||
|
{"expected_candidate_head": HEAD_B},
|
||||||
|
{"expected_lease_comment_id": 7777}):
|
||||||
|
with self.subTest(**kwargs):
|
||||||
|
gate._reset_for_testing()
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
result, calls = self._run(
|
||||||
|
RECONCILER_ENV, apply=True, comments=comments, **kwargs)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "lease_mismatch")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_already_terminal_cleanup_is_idempotent(self):
|
||||||
|
"""13. A released lease reports nothing to clean and posts nothing."""
|
||||||
|
first = _assessment([_lease_comment()])
|
||||||
|
released = {"id": 7000, "body": first["release_body"],
|
||||||
|
"user": {"login": "sysadmin"}}
|
||||||
|
comments = [_lease_comment(), released]
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
result, calls = self._run(RECONCILER_ENV, apply=True, comments=comments)
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertFalse(result["lease_moot"])
|
||||||
|
self.assertEqual(calls["post"], [], "no second terminal marker")
|
||||||
|
self.assertTrue(any(
|
||||||
|
"already released/terminal" in r
|
||||||
|
for r in result.get("cleanup_skipped_reason", [])))
|
||||||
|
|
||||||
|
def test_apply_is_append_only_never_deletes(self):
|
||||||
|
"""14. The only write is a POST; nothing is edited or deleted."""
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
side, _calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=comments)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def _recording(method, url, auth=None, payload=None, *a, **k):
|
||||||
|
seen.append((method or "").upper())
|
||||||
|
return side(method, url, auth, payload, *a, **k)
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=_recording), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server._bound_repository_slug", return_value=SLUG), \
|
||||||
|
patch("mcp_server._repository_binding_block", return_value=None), \
|
||||||
|
patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CANONICAL_TASK), \
|
||||||
|
patch.dict(os.environ, RECONCILER_ENV, clear=True):
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=True, remote="prgs")
|
||||||
|
self.assertTrue(result["cleanup_performed"])
|
||||||
|
self.assertNotIn("DELETE", seen)
|
||||||
|
self.assertNotIn("PATCH", seen)
|
||||||
|
self.assertNotIn("PUT", seen)
|
||||||
|
self.assertEqual(seen.count("POST"), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryBinding(_ToolCase):
|
||||||
|
"""11. Foreign-repository targets fail closed before any mutation."""
|
||||||
|
|
||||||
|
def test_explicit_foreign_repository_is_rejected(self):
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
side, calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=comments)
|
||||||
|
with patch("mcp_server.api_request", side_effect=side), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server._canonical_repository_slug",
|
||||||
|
return_value=(None, [])), \
|
||||||
|
patch("mcp_server._workspace_repository_slug",
|
||||||
|
return_value=SLUG), \
|
||||||
|
patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CANONICAL_TASK), \
|
||||||
|
patch.dict(os.environ, RECONCILER_ENV, clear=True):
|
||||||
|
gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=False, remote="prgs")
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=True, remote="prgs",
|
||||||
|
org="Some-Other-Org", repo="Some-Other-Repo")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "repository_binding")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
def test_unresolvable_canonical_root_fails_closed(self):
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
side, calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=comments)
|
||||||
|
with patch("mcp_server.api_request", side_effect=side), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None), \
|
||||||
|
patch("mcp_server._canonical_repository_slug",
|
||||||
|
return_value=(None, ["canonical root unresolvable"])), \
|
||||||
|
patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CANONICAL_TASK), \
|
||||||
|
patch.dict(os.environ, RECONCILER_ENV, clear=True):
|
||||||
|
gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=False, remote="prgs")
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=True, remote="prgs")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "repository_binding")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreflightBinding(_ToolCase):
|
||||||
|
"""4. The apply path binds the shared preflight to the exact task."""
|
||||||
|
|
||||||
|
def test_apply_forwards_task_and_target_to_preflight(self):
|
||||||
|
comments = [_lease_comment()]
|
||||||
|
self._run(RECONCILER_ENV, apply=False, comments=comments)
|
||||||
|
side, _calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=comments)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def _purity(remote=None, worktree_path=None, task=None, **kw):
|
||||||
|
seen.update({"remote": remote, "worktree_path": worktree_path,
|
||||||
|
"task": task, **kw})
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=side), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", _purity), \
|
||||||
|
patch("mcp_server._bound_repository_slug", return_value=SLUG), \
|
||||||
|
patch("mcp_server._repository_binding_block", return_value=None), \
|
||||||
|
patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CANONICAL_TASK), \
|
||||||
|
patch.dict(os.environ, RECONCILER_ENV, clear=True):
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=True, remote="prgs",
|
||||||
|
worktree_path="/tmp/branches/reconciler-745",
|
||||||
|
org="Scaled-Tech-Consulting", repo="Gitea-Tools")
|
||||||
|
self.assertTrue(result["cleanup_performed"])
|
||||||
|
self.assertEqual(seen["task"], CANONICAL_TASK)
|
||||||
|
self.assertEqual(seen["worktree_path"], "/tmp/branches/reconciler-745")
|
||||||
|
self.assertEqual(seen["org"], "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(seen["repo"], "Gitea-Tools")
|
||||||
|
|
||||||
|
def test_dry_run_does_not_require_preflight(self):
|
||||||
|
def _boom(*a, **k):
|
||||||
|
raise AssertionError("dry run must not run mutation preflight")
|
||||||
|
|
||||||
|
side, calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
||||||
|
with patch("mcp_server.api_request", side_effect=side), \
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.verify_preflight_purity", _boom), \
|
||||||
|
patch("mcp_server._bound_repository_slug", return_value=SLUG), \
|
||||||
|
patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=False, remote="prgs")
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoProductionLeaseTouched(unittest.TestCase):
|
||||||
|
"""16. No real production lease or PR is referenced by these tests."""
|
||||||
|
|
||||||
|
PRODUCTION_PR = 744
|
||||||
|
PRODUCTION_SESSION = "33673-1d54887a0415"
|
||||||
|
PRODUCTION_MARKER = 12452
|
||||||
|
|
||||||
|
def test_fixtures_are_synthetic(self):
|
||||||
|
self.assertNotEqual(PR, self.PRODUCTION_PR)
|
||||||
|
self.assertNotEqual(SESSION, self.PRODUCTION_SESSION)
|
||||||
|
self.assertNotEqual(LEASE_COMMENT_ID, self.PRODUCTION_MARKER)
|
||||||
|
|
||||||
|
def test_module_source_never_names_the_production_lease(self):
|
||||||
|
source = _Path(__file__).read_text()
|
||||||
|
for token in (self.PRODUCTION_SESSION, str(self.PRODUCTION_MARKER)):
|
||||||
|
self.assertEqual(
|
||||||
|
source.count(token), 1,
|
||||||
|
f"{token!r} must appear only in this guard's own constants",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Tests for the 10-minute sliding TTL on reviewer and merger PR leases (#747).
|
||||||
|
|
||||||
|
The lease ledger previously minted a fixed 120-minute expiry and derived
|
||||||
|
staleness from separate 30/60-minute activity bands. A dead session therefore
|
||||||
|
held a PR for up to two hours. These tests pin the sliding-window contract:
|
||||||
|
acquisition mints a 10-minute expiry, every heartbeat slides it forward, and an
|
||||||
|
expired lease is immediately reclaimable with no intermediate waiting tier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import reviewer_pr_lease as leases
|
||||||
|
|
||||||
|
|
||||||
|
def _body(
|
||||||
|
*,
|
||||||
|
session_id: str = "session-a",
|
||||||
|
pr_number: int = 747,
|
||||||
|
phase: str = "claimed",
|
||||||
|
last_activity: datetime | None = None,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
ttl_minutes: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
kwargs = {}
|
||||||
|
if ttl_minutes is not None:
|
||||||
|
kwargs["ttl_minutes"] = ttl_minutes
|
||||||
|
return leases.format_lease_body(
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
pr_number=pr_number,
|
||||||
|
issue_number=747,
|
||||||
|
reviewer_identity="rev1",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id=session_id,
|
||||||
|
worktree="branches/review-pr747",
|
||||||
|
phase=phase,
|
||||||
|
candidate_head="a" * 40,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
last_activity=last_activity,
|
||||||
|
expires_at=expires_at,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _comment(**kwargs) -> dict:
|
||||||
|
return {"id": 1, "body": _body(**kwargs), "user": {"login": "rev1"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _minutes_ago(minutes: int) -> datetime:
|
||||||
|
return datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlidingTTLConstant(unittest.TestCase):
|
||||||
|
"""AC6: one named constant per lease kind, no duplicated literals."""
|
||||||
|
|
||||||
|
def test_ttl_is_ten_minutes(self):
|
||||||
|
self.assertEqual(leases.LEASE_TTL_MINUTES, 10)
|
||||||
|
|
||||||
|
def test_renewal_window_is_separately_named(self):
|
||||||
|
self.assertEqual(leases.LEASE_RENEWAL_MINUTES, 10)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAcquisitionTTL(unittest.TestCase):
|
||||||
|
"""AC1 / AC2: reviewer and merger acquisition both mint now + 10 minutes."""
|
||||||
|
|
||||||
|
def test_acquire_mints_ten_minute_expiry(self):
|
||||||
|
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=now))
|
||||||
|
expires = leases._parse_timestamp(lease["expires_at"])
|
||||||
|
self.assertEqual(expires, now + timedelta(minutes=10))
|
||||||
|
|
||||||
|
def test_merger_acquisition_shares_the_same_window(self):
|
||||||
|
# Merger acquisition funnels through the same lease-body formatter, so
|
||||||
|
# the reviewer TTL is the merger TTL by construction.
|
||||||
|
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
lease = leases.parse_lease_comment(_body(phase="merging", last_activity=now))
|
||||||
|
expires = leases._parse_timestamp(lease["expires_at"])
|
||||||
|
self.assertEqual(expires, now + timedelta(minutes=10))
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeartbeatSlides(unittest.TestCase):
|
||||||
|
"""AC3: a heartbeat slides expires_at to now + 10 minutes."""
|
||||||
|
|
||||||
|
def test_heartbeat_slides_expiry_forward(self):
|
||||||
|
acquired = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
beat = acquired + timedelta(minutes=7)
|
||||||
|
first = leases.parse_lease_comment(_body(last_activity=acquired))
|
||||||
|
renewed = leases.parse_lease_comment(_body(last_activity=beat))
|
||||||
|
|
||||||
|
first_expiry = leases._parse_timestamp(first["expires_at"])
|
||||||
|
renewed_expiry = leases._parse_timestamp(renewed["expires_at"])
|
||||||
|
|
||||||
|
self.assertEqual(renewed_expiry, beat + timedelta(minutes=10))
|
||||||
|
self.assertGreater(renewed_expiry, first_expiry)
|
||||||
|
|
||||||
|
def test_renewal_window_is_independently_tunable(self):
|
||||||
|
# The renewal amount must not be hardwired to the acquisition TTL;
|
||||||
|
# format_lease_body accepts an explicit window.
|
||||||
|
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=now, ttl_minutes=3))
|
||||||
|
expires = leases._parse_timestamp(lease["expires_at"])
|
||||||
|
self.assertEqual(expires, now + timedelta(minutes=3))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFreshnessBands(unittest.TestCase):
|
||||||
|
"""AC5: expiry is the only gate; no intermediate reclaim tier."""
|
||||||
|
|
||||||
|
def test_fresh_lease_is_active(self):
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(1)))
|
||||||
|
self.assertEqual(leases.classify_lease_freshness(lease), "active")
|
||||||
|
|
||||||
|
def test_idle_past_half_ttl_warns_before_expiry(self):
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(6)))
|
||||||
|
self.assertEqual(leases.classify_lease_freshness(lease), "stale_warning")
|
||||||
|
|
||||||
|
def test_lease_expires_after_ten_idle_minutes(self):
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(11)))
|
||||||
|
self.assertEqual(leases.classify_lease_freshness(lease), "expired")
|
||||||
|
|
||||||
|
def test_no_separate_reclaimable_tier_remains(self):
|
||||||
|
# The old 60-minute reclaim band sat between "stale" and "expired" and
|
||||||
|
# blocked acquisition. Under a sliding TTL an idle lease is already
|
||||||
|
# expired, so the tier must not reappear at any idle duration.
|
||||||
|
for minutes in (11, 30, 65, 121, 600):
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=_minutes_ago(minutes)))
|
||||||
|
self.assertEqual(
|
||||||
|
leases.classify_lease_freshness(lease),
|
||||||
|
"expired",
|
||||||
|
f"idle {minutes}m should be expired, not a waiting tier",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpiredLeaseIsImmediatelyReclaimable(unittest.TestCase):
|
||||||
|
"""AC5: another session takes over an expired lease with no extra wait."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def _acquire_against(self, comments: list[dict]) -> dict:
|
||||||
|
return leases.assess_acquire_lease(
|
||||||
|
comments,
|
||||||
|
pr_number=747,
|
||||||
|
reviewer_identity="rev2",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="session-b",
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
issue_number=747,
|
||||||
|
worktree="branches/review-pr747-b",
|
||||||
|
candidate_head="c" * 40,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="d" * 40,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_expired_foreign_lease_does_not_block_acquisition(self):
|
||||||
|
comments = [_comment(session_id="dead-session", last_activity=_minutes_ago(11))]
|
||||||
|
result = self._acquire_against(comments)
|
||||||
|
self.assertTrue(result["acquire_allowed"], result["reasons"])
|
||||||
|
|
||||||
|
def test_live_foreign_lease_still_blocks_acquisition(self):
|
||||||
|
comments = [_comment(session_id="live-session", last_activity=_minutes_ago(2))]
|
||||||
|
result = self._acquire_against(comments)
|
||||||
|
self.assertFalse(result["acquire_allowed"])
|
||||||
|
self.assertTrue(any("already has active" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemainingTimeReporting(unittest.TestCase):
|
||||||
|
"""AC7: diagnostics can distinguish 'held and live' from 'held and dying'."""
|
||||||
|
|
||||||
|
def test_seconds_remaining_on_live_lease(self):
|
||||||
|
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=now))
|
||||||
|
remaining = leases.lease_seconds_remaining(lease, now=now + timedelta(minutes=4))
|
||||||
|
self.assertEqual(remaining, 360)
|
||||||
|
|
||||||
|
def test_seconds_remaining_is_zero_when_expired(self):
|
||||||
|
now = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
lease = leases.parse_lease_comment(_body(last_activity=now))
|
||||||
|
remaining = leases.lease_seconds_remaining(lease, now=now + timedelta(minutes=30))
|
||||||
|
self.assertEqual(remaining, 0)
|
||||||
|
|
||||||
|
def test_seconds_remaining_is_none_without_parsable_expiry(self):
|
||||||
|
self.assertIsNone(leases.lease_seconds_remaining({"expires_at": "not-a-time"}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestLegacyLeaseRows(unittest.TestCase):
|
||||||
|
"""AC9: leases minted under the old 120-minute TTL still evaluate."""
|
||||||
|
|
||||||
|
def test_legacy_two_hour_expiry_is_honoured_until_it_passes(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
legacy = leases.parse_lease_comment(
|
||||||
|
_body(last_activity=now - timedelta(minutes=90), expires_at=now + timedelta(minutes=30))
|
||||||
|
)
|
||||||
|
# Still inside its originally minted window: not expired, but idle long
|
||||||
|
# enough to warn.
|
||||||
|
self.assertEqual(leases.classify_lease_freshness(legacy), "stale_warning")
|
||||||
|
|
||||||
|
def test_legacy_row_past_its_own_expiry_is_expired(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
legacy = leases.parse_lease_comment(
|
||||||
|
_body(last_activity=now - timedelta(minutes=180), expires_at=now - timedelta(minutes=60))
|
||||||
|
)
|
||||||
|
self.assertEqual(leases.classify_lease_freshness(legacy), "expired")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
"""Regression coverage for the PR checks assessor defect (#751).
|
||||||
|
|
||||||
|
Gitea's *combined* commit status reports ``state: pending`` both when a check is
|
||||||
|
executing and when the status-context collection is empty. The assessor read
|
||||||
|
``state`` alone and defaulted ``checks_required`` to ``True``, so a PR whose head
|
||||||
|
had no status contexts — and never would — was routed to ``blocked`` forever.
|
||||||
|
|
||||||
|
These tests pin the corrected semantics end to end: live branch protection
|
||||||
|
decides whether checks are required, and the actual context collection decides
|
||||||
|
what the checks say.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import pr_sync_status # noqa: E402
|
||||||
|
from pr_sync_status import ( # noqa: E402
|
||||||
|
ACTION_BLOCKED,
|
||||||
|
ACTION_MERGE_NOW,
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||||
|
CHECKS_FAILURE,
|
||||||
|
CHECKS_MISSING_REQUIRED,
|
||||||
|
CHECKS_NONE,
|
||||||
|
CHECKS_NOT_REQUIRED,
|
||||||
|
CHECKS_PENDING,
|
||||||
|
CHECKS_SUCCESS,
|
||||||
|
CHECKS_UNKNOWN,
|
||||||
|
assess_pr_sync_status,
|
||||||
|
classify_commit_checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(prefix: str) -> str:
|
||||||
|
return (prefix + "0" * 40)[:40]
|
||||||
|
|
||||||
|
|
||||||
|
PR_HEAD = _sha("aaaaaaaa")
|
||||||
|
BASE_HEAD = _sha("bbbbbbbb")
|
||||||
|
|
||||||
|
|
||||||
|
def _base_kwargs(**overrides):
|
||||||
|
data = {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"pr_number": 751,
|
||||||
|
"pr_state": "open",
|
||||||
|
"source_branch": "fix/issue-751-checks-assessor",
|
||||||
|
"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
|
||||||
|
|
||||||
|
|
||||||
|
def _reasons(result) -> str:
|
||||||
|
return " | ".join(result["reasons"]).lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyCommitChecks(unittest.TestCase):
|
||||||
|
"""Pure classification from live evidence."""
|
||||||
|
|
||||||
|
def test_empty_collection_with_combined_pending_is_not_executing_ci(self):
|
||||||
|
# The exact PR #750 failure mode at the classification layer.
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=[],
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result["checks_status"], CHECKS_PENDING)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_NONE)
|
||||||
|
self.assertEqual(result["context_count"], 0)
|
||||||
|
joined = " ".join(result["reasons"]).lower()
|
||||||
|
self.assertIn("empty", joined)
|
||||||
|
self.assertIn("does not indicate", joined)
|
||||||
|
|
||||||
|
def test_protection_disables_status_checks(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[],
|
||||||
|
checks_enabled=False,
|
||||||
|
required_contexts=[],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_NOT_REQUIRED)
|
||||||
|
|
||||||
|
def test_no_required_contexts_aggregates_reported_contexts(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "success"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=[],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
|
||||||
|
def test_required_checks_pending(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[{"context": "build", "status": "pending"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_PENDING)
|
||||||
|
|
||||||
|
def test_required_checks_failed(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="failure",
|
||||||
|
statuses=[{"context": "build", "status": "failure"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_FAILURE)
|
||||||
|
|
||||||
|
def test_required_checks_successful(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "success"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
|
||||||
|
def test_required_context_configured_with_no_matching_result(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "lint", "status": "success"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_MISSING_REQUIRED)
|
||||||
|
self.assertEqual(result["missing_required_contexts"], ["build"])
|
||||||
|
|
||||||
|
def test_mixed_required_and_unrelated_contexts_ignores_unrelated(self):
|
||||||
|
# An unrelated failing context must not fail a satisfied required set.
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="failure",
|
||||||
|
statuses=[
|
||||||
|
{"context": "build", "status": "success"},
|
||||||
|
{"context": "optional-scan", "status": "failure"},
|
||||||
|
],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
# ...and a failing *required* context still fails despite passing extras.
|
||||||
|
failing = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[
|
||||||
|
{"context": "build", "status": "failure"},
|
||||||
|
{"context": "optional-scan", "status": "success"},
|
||||||
|
],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(failing["checks_status"], CHECKS_FAILURE)
|
||||||
|
|
||||||
|
def test_policy_unreadable_fails_closed(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state=None,
|
||||||
|
statuses=[],
|
||||||
|
checks_enabled=None,
|
||||||
|
required_contexts=[],
|
||||||
|
policy_determinable=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
self.assertIn("fail closed", " ".join(result["reasons"]).lower())
|
||||||
|
|
||||||
|
def test_malformed_policy_indeterminate_flag_fails_closed(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "success"}],
|
||||||
|
checks_enabled=None,
|
||||||
|
required_contexts=[],
|
||||||
|
policy_determinable=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
|
||||||
|
def test_status_collection_unreadable_fails_closed_when_required(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
status_determinable=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["checks_required"])
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
|
||||||
|
def test_unrecognized_context_state_never_reads_as_success(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[{"context": "build", "status": "banana"}],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
|
||||||
|
|
||||||
|
def test_newest_wins_per_context(self):
|
||||||
|
# Gitea returns newest-first; the stale failure must not win.
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="success",
|
||||||
|
statuses=[
|
||||||
|
{"context": "build", "status": "success"},
|
||||||
|
{"context": "build", "status": "failure"},
|
||||||
|
],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
|
||||||
|
|
||||||
|
def test_malformed_status_rows_are_discarded_not_treated_as_passing(self):
|
||||||
|
result = classify_commit_checks(
|
||||||
|
combined_state="pending",
|
||||||
|
statuses=[{"context": "build"}, "not-a-dict", None],
|
||||||
|
checks_enabled=True,
|
||||||
|
required_contexts=["build"],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["checks_status"], CHECKS_MISSING_REQUIRED)
|
||||||
|
|
||||||
|
|
||||||
|
class TestChecksGateSemantics(unittest.TestCase):
|
||||||
|
"""``assess_pr_sync_status`` routing and blocker reasons."""
|
||||||
|
|
||||||
|
def test_not_required_allows_merge_now_with_empty_checks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_NOT_REQUIRED),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
self.assertTrue(result["approval_valid_for_merge"])
|
||||||
|
self.assertFalse(result["checks_required"])
|
||||||
|
self.assertIn("does not require status checks", _reasons(result))
|
||||||
|
|
||||||
|
def test_none_blocks_when_checks_required(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_NONE),
|
||||||
|
checks_required=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("no status context", _reasons(result))
|
||||||
|
self.assertIn("not executing ci", _reasons(result))
|
||||||
|
|
||||||
|
def test_missing_required_blocks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_MISSING_REQUIRED),
|
||||||
|
checks_required=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("no matching status result", _reasons(result))
|
||||||
|
|
||||||
|
def test_required_pending_blocks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_PENDING), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("not finished", _reasons(result))
|
||||||
|
|
||||||
|
def test_required_failure_blocks(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_FAILURE), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("failed", _reasons(result))
|
||||||
|
|
||||||
|
def test_required_success_merges(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_SUCCESS), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
|
||||||
|
def test_unknown_blocks_when_required(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_UNKNOWN), checks_required=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("unknown", _reasons(result))
|
||||||
|
|
||||||
|
def test_unrecognized_status_does_not_fall_through_to_merge(self):
|
||||||
|
# Regression: the previous gate only handled a fixed vocabulary and let
|
||||||
|
# anything else reach merge_now.
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status="totally-unexpected"),
|
||||||
|
checks_required=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertIn("unrecognized checks status", _reasons(result))
|
||||||
|
|
||||||
|
def test_checks_gate_does_not_bypass_approval_requirement(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
checks_status=CHECKS_NOT_REQUIRED, approval_at_current_head=False
|
||||||
|
),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
|
||||||
|
def test_checks_gate_does_not_bypass_current_base_protection(self):
|
||||||
|
# Existing current-base behavior stays intact (#727).
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
checks_status=CHECKS_NOT_REQUIRED,
|
||||||
|
commits_behind=3,
|
||||||
|
branch_protection_requires_current_base=True,
|
||||||
|
),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_checks_gate_does_not_bypass_conflicts(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(checks_status=CHECKS_NOT_REQUIRED, mergeable=False),
|
||||||
|
checks_required=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBranchProtectionPolicy(unittest.TestCase):
|
||||||
|
"""Live protection reader derives the status-check requirement."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as gms
|
||||||
|
|
||||||
|
self.gms = gms
|
||||||
|
|
||||||
|
def _policy(self, responses):
|
||||||
|
def fake_api_request(method, url, auth, *args, **kwargs):
|
||||||
|
for fragment, payload in responses.items():
|
||||||
|
if fragment in url:
|
||||||
|
if isinstance(payload, Exception):
|
||||||
|
raise payload
|
||||||
|
return payload
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(self.gms, "api_request", side_effect=fake_api_request):
|
||||||
|
return self.gms._branch_protection_policy(
|
||||||
|
"https://example/api/v1/repos/o/r", {"h": "1"}, base_branch="master"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_checks_enabled_with_contexts(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": [{
|
||||||
|
"branch_name": "master",
|
||||||
|
"block_on_outdated_branch": True,
|
||||||
|
"enable_status_check": True,
|
||||||
|
"status_check_contexts": ["ci/build", " "],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertTrue(policy["checks_enabled"])
|
||||||
|
self.assertTrue(policy["requires_current_base"])
|
||||||
|
self.assertEqual(policy["required_contexts"], ["ci/build"])
|
||||||
|
|
||||||
|
def test_status_checks_disabled(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": [{
|
||||||
|
"branch_name": "master",
|
||||||
|
"enable_status_check": False,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertFalse(policy["checks_enabled"])
|
||||||
|
|
||||||
|
def test_no_protection_rule_means_checks_not_required(self):
|
||||||
|
# PR #750's repository shape: protection list readable but empty.
|
||||||
|
policy = self._policy({"branch_protections": [], "branches/master": {}})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertFalse(policy["protection_found"])
|
||||||
|
self.assertFalse(policy["checks_enabled"])
|
||||||
|
self.assertIsNone(policy["requires_current_base"])
|
||||||
|
|
||||||
|
def test_protection_without_status_field_means_not_enabled(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": [{
|
||||||
|
"branch_name": "master",
|
||||||
|
"block_on_outdated_branch": True,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertTrue(policy["determinable"])
|
||||||
|
self.assertFalse(policy["checks_enabled"])
|
||||||
|
self.assertTrue(policy["requires_current_base"])
|
||||||
|
|
||||||
|
def test_api_failure_is_not_determinable(self):
|
||||||
|
policy = self._policy({
|
||||||
|
"branch_protections": RuntimeError("boom"),
|
||||||
|
})
|
||||||
|
self.assertFalse(policy["determinable"])
|
||||||
|
self.assertIsNone(policy["checks_enabled"])
|
||||||
|
|
||||||
|
def test_missing_branch_is_not_determinable(self):
|
||||||
|
with patch.object(self.gms, "api_request", return_value=[]):
|
||||||
|
policy = self.gms._branch_protection_policy(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, base_branch=" "
|
||||||
|
)
|
||||||
|
self.assertFalse(policy["determinable"])
|
||||||
|
|
||||||
|
def test_requires_current_base_accessor_preserved(self):
|
||||||
|
def fake(method, url, auth, *a, **k):
|
||||||
|
if "branch_protections" in url:
|
||||||
|
return [{"branch_name": "master",
|
||||||
|
"block_on_outdated_branch": True}]
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(self.gms, "api_request", side_effect=fake):
|
||||||
|
value = self.gms._branch_protection_requires_current_base(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, base_branch="master"
|
||||||
|
)
|
||||||
|
self.assertTrue(value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommitChecksSnapshot(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as gms
|
||||||
|
|
||||||
|
self.gms = gms
|
||||||
|
|
||||||
|
def test_reads_state_and_context_collection(self):
|
||||||
|
payload = {"state": "pending", "statuses": [{"context": "b",
|
||||||
|
"status": "pending"}]}
|
||||||
|
with patch.object(self.gms, "api_request", return_value=payload):
|
||||||
|
snap = self.gms._commit_checks_snapshot(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
|
||||||
|
)
|
||||||
|
self.assertTrue(snap["determinable"])
|
||||||
|
self.assertEqual(snap["combined_state"], "pending")
|
||||||
|
self.assertEqual(len(snap["statuses"]), 1)
|
||||||
|
|
||||||
|
def test_empty_collection_recorded_as_determinable(self):
|
||||||
|
with patch.object(
|
||||||
|
self.gms, "api_request", return_value={"state": "pending", "statuses": []}
|
||||||
|
):
|
||||||
|
snap = self.gms._commit_checks_snapshot(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
|
||||||
|
)
|
||||||
|
self.assertTrue(snap["determinable"])
|
||||||
|
self.assertEqual(snap["statuses"], [])
|
||||||
|
|
||||||
|
def test_api_failure_is_not_determinable(self):
|
||||||
|
with patch.object(self.gms, "api_request", side_effect=RuntimeError("x")):
|
||||||
|
snap = self.gms._commit_checks_snapshot(
|
||||||
|
"https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
|
||||||
|
)
|
||||||
|
self.assertFalse(snap["determinable"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpWrapperForwarding(unittest.TestCase):
|
||||||
|
"""The production MCP path must reach the derived ``checks_required``."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as gms
|
||||||
|
|
||||||
|
self.gms = gms
|
||||||
|
self.base = (
|
||||||
|
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _patches(self, fake_api_request, spy):
|
||||||
|
return [
|
||||||
|
patch.object(self.gms, "_profile_operation_gate", return_value=None),
|
||||||
|
patch.object(self.gms, "_resolve", return_value=(
|
||||||
|
"gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")),
|
||||||
|
patch.object(self.gms, "_auth", return_value={"Authorization": "x"}),
|
||||||
|
patch.object(self.gms, "repo_api_url", return_value=self.base),
|
||||||
|
patch.object(self.gms, "api_request", side_effect=fake_api_request),
|
||||||
|
patch.object(self.gms, "gitea_get_pr_review_feedback", return_value={
|
||||||
|
"success": True, "approval_at_current_head": True}),
|
||||||
|
patch.object(self.gms, "_prove_author_ownership_for_pr", return_value={
|
||||||
|
"has_author_lock": True}),
|
||||||
|
patch.object(pr_sync_status, "assess_pr_sync_status", side_effect=spy),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _run(self, *, protections, status_payload, pr_number=750,
|
||||||
|
caller_checks_status=None):
|
||||||
|
captured = {}
|
||||||
|
real_assess = pr_sync_status.assess_pr_sync_status
|
||||||
|
|
||||||
|
def spy(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return real_assess(**kwargs)
|
||||||
|
|
||||||
|
def fake_api_request(method, url, auth, *args, **kwargs):
|
||||||
|
if f"/pulls/{pr_number}" in url:
|
||||||
|
return {
|
||||||
|
"number": pr_number,
|
||||||
|
"state": "open",
|
||||||
|
"title": "t",
|
||||||
|
"body": "b",
|
||||||
|
"mergeable": True,
|
||||||
|
"head": {"sha": PR_HEAD, "ref": "fix/x"},
|
||||||
|
"base": {"sha": BASE_HEAD, "ref": "master"},
|
||||||
|
}
|
||||||
|
if "/branch_protections" in url:
|
||||||
|
if isinstance(protections, Exception):
|
||||||
|
raise protections
|
||||||
|
return protections
|
||||||
|
if "/branches/master" in url:
|
||||||
|
return {"commit": {"id": BASE_HEAD}}
|
||||||
|
if "/status" in url:
|
||||||
|
if isinstance(status_payload, Exception):
|
||||||
|
raise status_payload
|
||||||
|
return status_payload
|
||||||
|
if "/compare/" in url:
|
||||||
|
return {"total_commits": 0}
|
||||||
|
if "/comments" in url:
|
||||||
|
return []
|
||||||
|
return None
|
||||||
|
|
||||||
|
stack = self._patches(fake_api_request, spy)
|
||||||
|
for p in stack:
|
||||||
|
p.start()
|
||||||
|
try:
|
||||||
|
result = self.gms.gitea_assess_pr_sync_status(
|
||||||
|
pr_number=pr_number, remote="prgs",
|
||||||
|
checks_status=caller_checks_status,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for p in reversed(stack):
|
||||||
|
p.stop()
|
||||||
|
return result, captured
|
||||||
|
|
||||||
|
def test_derived_checks_required_is_forwarded(self):
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=[{"branch_name": "master", "enable_status_check": True,
|
||||||
|
"status_check_contexts": ["ci/build"]}],
|
||||||
|
status_payload={"state": "pending", "statuses": [
|
||||||
|
{"context": "ci/build", "status": "pending"}]},
|
||||||
|
)
|
||||||
|
self.assertIn("checks_required", captured)
|
||||||
|
self.assertTrue(captured["checks_required"])
|
||||||
|
self.assertEqual(captured["checks_status"], CHECKS_PENDING)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
def test_pr750_empty_status_with_no_protection_is_merge_ready(self):
|
||||||
|
# The exact reported failure: combined pending, zero contexts, and no
|
||||||
|
# branch protection requiring checks.
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=[],
|
||||||
|
status_payload={"state": "pending", "statuses": []},
|
||||||
|
)
|
||||||
|
self.assertFalse(captured["checks_required"])
|
||||||
|
self.assertEqual(captured["checks_status"], CHECKS_NOT_REQUIRED)
|
||||||
|
self.assertNotEqual(result["checks_status"], CHECKS_PENDING)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
|
||||||
|
def test_protection_read_failure_blocks(self):
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=RuntimeError("protection unavailable"),
|
||||||
|
status_payload={"state": "success", "statuses": []},
|
||||||
|
)
|
||||||
|
self.assertTrue(captured["checks_required"])
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
def test_caller_supplied_status_cannot_mask_live_required_failure(self):
|
||||||
|
# A caller-declared "success" must not override live evidence.
|
||||||
|
result, captured = self._run(
|
||||||
|
protections=[{"branch_name": "master", "enable_status_check": True,
|
||||||
|
"status_check_contexts": ["ci/build"]}],
|
||||||
|
status_payload={"state": "failure", "statuses": [
|
||||||
|
{"context": "ci/build", "status": "failure"}]},
|
||||||
|
caller_checks_status="success",
|
||||||
|
)
|
||||||
|
self.assertEqual(captured["checks_status"], CHECKS_FAILURE)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertEqual(
|
||||||
|
result["checks_evidence"]["caller_supplied_checks_status"], "success"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_checks_evidence_is_reported_without_secrets(self):
|
||||||
|
result, _ = self._run(
|
||||||
|
protections=[],
|
||||||
|
status_payload={"state": "pending", "statuses": []},
|
||||||
|
)
|
||||||
|
evidence = result["checks_evidence"]
|
||||||
|
self.assertEqual(evidence["context_count"], 0)
|
||||||
|
self.assertEqual(evidence["combined_state"], "pending")
|
||||||
|
self.assertFalse(evidence["protection_found"])
|
||||||
|
blob = repr(result).lower()
|
||||||
|
self.assertNotIn("authorization", blob)
|
||||||
|
self.assertNotIn("token", blob)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Dead-session author issue-lock recovery (#753).
|
||||||
|
|
||||||
|
Covers the narrow recovery path that lets an author re-acquire a durable lock
|
||||||
|
after the MCP session that recorded it exits, plus every rejection condition
|
||||||
|
that must keep failing closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_lock_recovery # noqa: E402
|
||||||
|
import issue_lock_store # noqa: E402
|
||||||
|
import issue_lock_worktree # noqa: E402
|
||||||
|
|
||||||
|
ISSUE = 4242
|
||||||
|
BRANCH = f"fix/issue-{ISSUE}-demo"
|
||||||
|
WORKTREE = "/scratch/wt"
|
||||||
|
HEAD = "a" * 40
|
||||||
|
OTHER_SHA = "b" * 40
|
||||||
|
IDENTITY = "example-user"
|
||||||
|
PROFILE = "example-author"
|
||||||
|
|
||||||
|
|
||||||
|
def dead_pid() -> int:
|
||||||
|
"""A PID that has certainly exited (spawned, then reaped)."""
|
||||||
|
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||||
|
proc.wait()
|
||||||
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
|
def future_ts(hours: int = 4) -> str:
|
||||||
|
return (
|
||||||
|
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_lock(**overrides):
|
||||||
|
lock = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "ExampleOrg",
|
||||||
|
"repo": "ExampleRepo",
|
||||||
|
"session_pid": dead_pid(),
|
||||||
|
"work_lease": {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"claimant": {"username": IDENTITY, "profile": PROFILE},
|
||||||
|
"expires_at": future_ts(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
lock.update(overrides)
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def assess(lock=None, **overrides):
|
||||||
|
kwargs = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "ExampleOrg",
|
||||||
|
"repo": "ExampleRepo",
|
||||||
|
"identity": IDENTITY,
|
||||||
|
"profile": PROFILE,
|
||||||
|
"current_branch": BRANCH,
|
||||||
|
"porcelain_status": "",
|
||||||
|
"head_sha": HEAD,
|
||||||
|
"remote_head_sha": HEAD,
|
||||||
|
"pr_head_sha": HEAD,
|
||||||
|
"pr_number": 99,
|
||||||
|
"competing_live_locks": [],
|
||||||
|
"candidate_branches": [BRANCH],
|
||||||
|
"current_pid": os.getpid(),
|
||||||
|
}
|
||||||
|
kwargs.update(overrides)
|
||||||
|
return issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||||
|
make_lock() if lock is None else lock, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeadSessionRecoveryGranted(unittest.TestCase):
|
||||||
|
def test_dead_pid_with_exact_evidence_recovers(self):
|
||||||
|
result = assess()
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
self.assertEqual(result["outcome"], issue_lock_recovery.RECOVERY_SANCTIONED)
|
||||||
|
|
||||||
|
def test_recovery_still_granted_when_no_open_pr_exists(self):
|
||||||
|
# A locked branch need not have a PR yet; absence must not block.
|
||||||
|
result = assess(pr_head_sha=None, pr_number=None)
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
|
||||||
|
def test_lease_expiry_is_not_required_for_recovery(self):
|
||||||
|
# The defining condition is PID death, not TTL expiry (the #601 gap).
|
||||||
|
lock = make_lock()
|
||||||
|
self.assertFalse(issue_lock_store.is_lease_expired(lock))
|
||||||
|
self.assertFalse(issue_lock_store.assess_lock_freshness(lock)["live"])
|
||||||
|
self.assertTrue(assess(lock)["recovery_sanctioned"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeadSessionRecoveryRefused(unittest.TestCase):
|
||||||
|
def assert_refused(self, result, needle):
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertEqual(result["outcome"], issue_lock_recovery.REFUSED)
|
||||||
|
self.assertTrue(
|
||||||
|
any(needle in reason for reason in result["reasons"]),
|
||||||
|
f"expected {needle!r} in {result['reasons']}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_live_prior_pid_refused(self):
|
||||||
|
lock = make_lock(session_pid=os.getpid(), pid=os.getpid())
|
||||||
|
# Distinct current pid so the refusal is attributable to liveness.
|
||||||
|
self.assert_refused(assess(lock, current_pid=os.getpid() + 1), "still alive")
|
||||||
|
|
||||||
|
def test_different_author_identity_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(identity="someone-else"), "does not match active identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_different_profile_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(profile="other-profile"), "does not match active profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_different_branch_refused(self):
|
||||||
|
lock = make_lock(branch_name=f"fix/issue-{ISSUE}-other")
|
||||||
|
self.assert_refused(assess(lock), "does not match requested")
|
||||||
|
|
||||||
|
def test_worktree_parked_on_another_branch_refused(self):
|
||||||
|
self.assert_refused(assess(current_branch="master"), "not the locked branch")
|
||||||
|
|
||||||
|
def test_detached_head_worktree_refused(self):
|
||||||
|
self.assert_refused(assess(current_branch=None), "detached HEAD")
|
||||||
|
|
||||||
|
def test_different_worktree_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(worktree_path="/scratch/elsewhere"), "does not match declared"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dirty_worktree_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(porcelain_status=" M gitea_mcp_server.py\n"), "requires a clean"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_head_differing_from_remote_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(remote_head_sha=OTHER_SHA), "does not match remote branch head"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pr_head_differing_refused(self):
|
||||||
|
self.assert_refused(assess(pr_head_sha=OTHER_SHA), "does not match local head")
|
||||||
|
|
||||||
|
def test_missing_remote_head_refused(self):
|
||||||
|
self.assert_refused(assess(remote_head_sha=None), "remote head")
|
||||||
|
|
||||||
|
def test_competing_live_lock_refused(self):
|
||||||
|
competing = [
|
||||||
|
{
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": "/scratch/other-wt",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.assert_refused(
|
||||||
|
assess(competing_live_locks=competing), "competing live lock"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unrelated_live_lock_does_not_block(self):
|
||||||
|
unrelated = [
|
||||||
|
{
|
||||||
|
"issue_number": 999,
|
||||||
|
"branch_name": "fix/issue-999-unrelated",
|
||||||
|
"worktree_path": "/scratch/unrelated",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.assertTrue(assess(competing_live_locks=unrelated)["recovery_sanctioned"])
|
||||||
|
|
||||||
|
def test_multiple_candidate_branches_refused(self):
|
||||||
|
self.assert_refused(
|
||||||
|
assess(candidate_branches=[BRANCH, f"feat/issue-{ISSUE}-rival"]),
|
||||||
|
"multiple branches claim this issue",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repository_scope_mismatch_refused(self):
|
||||||
|
self.assert_refused(assess(repo="OtherRepo"), "does not match requested")
|
||||||
|
|
||||||
|
def test_malformed_lock_missing_worktree_refused(self):
|
||||||
|
lock = make_lock()
|
||||||
|
lock.pop("worktree_path")
|
||||||
|
self.assert_refused(assess(lock), "incomplete")
|
||||||
|
|
||||||
|
def test_malformed_lock_missing_pid_refused(self):
|
||||||
|
lock = make_lock()
|
||||||
|
lock.pop("session_pid", None)
|
||||||
|
lock.pop("pid", None)
|
||||||
|
self.assert_refused(assess(lock), "incomplete")
|
||||||
|
|
||||||
|
def test_lock_without_claimant_refused(self):
|
||||||
|
lock = make_lock()
|
||||||
|
lock["work_lease"] = dict(lock["work_lease"])
|
||||||
|
lock["work_lease"].pop("claimant")
|
||||||
|
self.assert_refused(assess(lock), "claimant identity/profile")
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotACandidate(unittest.TestCase):
|
||||||
|
def test_absent_lock_is_not_a_candidate(self):
|
||||||
|
result = issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||||
|
None,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
remote="prgs",
|
||||||
|
org="ExampleOrg",
|
||||||
|
repo="ExampleRepo",
|
||||||
|
identity=IDENTITY,
|
||||||
|
profile=PROFILE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status="",
|
||||||
|
head_sha=HEAD,
|
||||||
|
remote_head_sha=HEAD,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["outcome"], issue_lock_recovery.NO_CANDIDATE)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertFalse(result["is_candidate"])
|
||||||
|
|
||||||
|
def test_lock_for_a_different_issue_is_not_a_candidate(self):
|
||||||
|
result = assess(make_lock(issue_number=7777))
|
||||||
|
self.assertEqual(result["outcome"], issue_lock_recovery.NO_CANDIDATE)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorktreeGateWaiver(unittest.TestCase):
|
||||||
|
def test_new_issue_claim_still_requires_base_equivalence(self):
|
||||||
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status="",
|
||||||
|
base_equivalent=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertFalse(result["base_equivalence_waived"])
|
||||||
|
|
||||||
|
def test_sanctioned_recovery_waives_base_equivalence(self):
|
||||||
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status="",
|
||||||
|
base_equivalent=False,
|
||||||
|
recovery_sanctioned=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result["reasons"])
|
||||||
|
self.assertTrue(result["base_equivalence_waived"])
|
||||||
|
|
||||||
|
def test_recovery_never_waives_cleanliness(self):
|
||||||
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status=" M gitea_mcp_server.py\n",
|
||||||
|
base_equivalent=False,
|
||||||
|
recovery_sanctioned=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("tracked file edits" in reason for reason in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unproven_base_equivalence_still_blocks_without_recovery(self):
|
||||||
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status="",
|
||||||
|
base_equivalent=None,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecoveryRecordAndDownstream(unittest.TestCase):
|
||||||
|
def test_recovery_record_preserves_truthful_provenance(self):
|
||||||
|
assessment = assess()
|
||||||
|
prior = assessment["evidence"]["prior_session_pid"]
|
||||||
|
record = issue_lock_recovery.build_recovery_record(
|
||||||
|
assessment, recovered_at="2026-07-18T23:21:40Z"
|
||||||
|
)
|
||||||
|
self.assertTrue(record["recovered"])
|
||||||
|
self.assertEqual(record["prior_session_pid"], prior)
|
||||||
|
self.assertEqual(record["replacement_session_pid"], os.getpid())
|
||||||
|
self.assertNotEqual(
|
||||||
|
record["prior_session_pid"], record["replacement_session_pid"]
|
||||||
|
)
|
||||||
|
self.assertFalse(record["prior_pid_alive"])
|
||||||
|
self.assertEqual(record["recovered_at"], "2026-07-18T23:21:40Z")
|
||||||
|
self.assertEqual(record["branch_name"], BRANCH)
|
||||||
|
self.assertEqual(record["local_head"], HEAD)
|
||||||
|
self.assertEqual(record["identity"], IDENTITY)
|
||||||
|
self.assertTrue(record["proof"])
|
||||||
|
|
||||||
|
def test_recovery_record_carries_no_secret_material(self):
|
||||||
|
record = issue_lock_recovery.build_recovery_record(
|
||||||
|
assess(), recovered_at="2026-07-18T23:21:40Z"
|
||||||
|
)
|
||||||
|
blob = repr(record).lower()
|
||||||
|
for banned in ("token", "password", "authorization", "secret", "api_key"):
|
||||||
|
self.assertNotIn(banned, blob)
|
||||||
|
|
||||||
|
def test_recovered_lock_satisfies_update_by_merge_ownership(self):
|
||||||
|
# After recovery the lock is rebound to the live session, so the
|
||||||
|
# ownership re-check used by gitea_update_pr_branch_by_merge passes.
|
||||||
|
assessment = assess()
|
||||||
|
recovered_lock = make_lock(session_pid=os.getpid(), pid=os.getpid())
|
||||||
|
recovered_lock["dead_session_recovery"] = (
|
||||||
|
issue_lock_recovery.build_recovery_record(
|
||||||
|
assessment, recovered_at="2026-07-18T23:21:40Z"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
freshness = issue_lock_store.assess_lock_freshness(recovered_lock)
|
||||||
|
self.assertTrue(freshness["live"], freshness)
|
||||||
|
|
||||||
|
verdict = issue_lock_store.verify_lock_for_mutation(
|
||||||
|
recovered_lock,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
)
|
||||||
|
self.assertTrue(verdict["proven"], verdict["reasons"])
|
||||||
|
self.assertFalse(verdict["block"])
|
||||||
|
|
||||||
|
def test_pre_recovery_lock_fails_ownership_check(self):
|
||||||
|
# Guards against a false positive above: the dead-PID lock must fail.
|
||||||
|
verdict = issue_lock_store.verify_lock_for_mutation(
|
||||||
|
make_lock(),
|
||||||
|
issue_number=ISSUE,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
worktree_path=WORKTREE,
|
||||||
|
)
|
||||||
|
self.assertTrue(verdict["block"])
|
||||||
|
self.assertTrue(any("not live" in reason for reason in verdict["reasons"]))
|
||||||
|
|
||||||
|
def test_no_manual_file_seeding_required(self):
|
||||||
|
# The whole decision is reachable from the durable record plus live
|
||||||
|
# observation; nothing is written to disk to reach a verdict.
|
||||||
|
self.assertTrue(assess()["recovery_sanctioned"])
|
||||||
|
|
||||||
|
def test_refusal_message_is_fail_closed(self):
|
||||||
|
message = issue_lock_recovery.format_recovery_refusal(
|
||||||
|
assess(porcelain_status=" M x.py\n")
|
||||||
|
)
|
||||||
|
self.assertIn("fail closed", message)
|
||||||
|
self.assertIn("recovery refused", message.lower())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
|
"""Dead-session lock recovery when the issue already owns an open PR (#755).
|
||||||
|
|
||||||
|
#753 added the recovery *assessor*, but the production ``gitea_lock_issue``
|
||||||
|
path still rejected every sanctioned recovery: a dead-session lock is by
|
||||||
|
construction a lock for work that already has an open PR, and the #400
|
||||||
|
duplicate-work gate blocked unconditionally on any linked open PR. These tests
|
||||||
|
drive the real MCP handler, not just the pure assessor, so that gap cannot
|
||||||
|
reopen.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_lock_provenance # noqa: E402
|
||||||
|
import issue_lock_recovery # noqa: E402
|
||||||
|
import issue_lock_store # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
from issue_work_duplicate_gate import ( # noqa: E402
|
||||||
|
OUTCOME_DUPLICATE_PR_PREVENTED,
|
||||||
|
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
|
||||||
|
PHASE_LOCK,
|
||||||
|
assess_work_issue_duplicate_gate,
|
||||||
|
)
|
||||||
|
|
||||||
|
ISSUE = 4755
|
||||||
|
BRANCH = f"fix/issue-{ISSUE}-owning-pr"
|
||||||
|
OTHER_BRANCH = f"fix/issue-{ISSUE}-competing"
|
||||||
|
HEAD = "c" * 40
|
||||||
|
OTHER_HEAD = "d" * 40
|
||||||
|
OWNING_PR = 4756
|
||||||
|
OTHER_PR = 4757
|
||||||
|
IDENTITY = "example-user"
|
||||||
|
PROFILE = "test-author-prgs"
|
||||||
|
ORG = "Scaled-Tech-Consulting"
|
||||||
|
REPO = "Gitea-Tools"
|
||||||
|
|
||||||
|
|
||||||
|
def dead_pid() -> int:
|
||||||
|
"""A PID that has certainly exited (spawned, then reaped)."""
|
||||||
|
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||||
|
proc.wait()
|
||||||
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
|
def shifted_ts(hours: int = 4) -> str:
|
||||||
|
return (
|
||||||
|
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"title": f"fix: something (Closes #{issue})",
|
||||||
|
"body": f"Closes #{issue}.",
|
||||||
|
"head": {"ref": ref, "sha": sha},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sanctioned_token(
|
||||||
|
issue_number=ISSUE, pr_number=OWNING_PR, branch=BRANCH, head=HEAD
|
||||||
|
):
|
||||||
|
"""The evidence shape the server derives from a granted recovery."""
|
||||||
|
return {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"branch_name": branch,
|
||||||
|
"head_sha": head,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── duplicate gate: exemption ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwningPrExemptionGranted(unittest.TestCase):
|
||||||
|
def test_exact_owning_pr_is_not_duplicate_work(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
|
||||||
|
self.assertEqual(result["linked_open_pr"], OWNING_PR)
|
||||||
|
self.assertEqual(result["linked_open_pr_count"], 1)
|
||||||
|
|
||||||
|
def test_unrelated_open_pr_alongside_owning_pr_is_ignored(self):
|
||||||
|
unrelated = {
|
||||||
|
"number": 999,
|
||||||
|
"title": "chore: unrelated",
|
||||||
|
"body": "no linkage",
|
||||||
|
"head": {"ref": "chore/unrelated", "sha": OTHER_HEAD},
|
||||||
|
}
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[unrelated, owning_pr()],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
||||||
|
self.assertEqual(result["linked_open_pr_count"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwningPrExemptionRefused(unittest.TestCase):
|
||||||
|
def assert_blocked(self, result):
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertFalse(result["owning_pr_recovery_exempted"])
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED)
|
||||||
|
|
||||||
|
def test_no_recovery_evidence_keeps_ordinary_blocker(self):
|
||||||
|
self.assert_blocked(
|
||||||
|
assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_competing_pr_number_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr(number=OTHER_PR)],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
def test_multiple_linked_open_prs_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
self.assertEqual(result["linked_open_pr_count"], 2)
|
||||||
|
|
||||||
|
def test_different_branch_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr(ref=OTHER_BRANCH)],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
def test_locked_branch_differing_from_evidence_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=OTHER_BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
def test_different_head_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr(sha=OTHER_HEAD)],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
def test_evidence_for_another_issue_refused(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(issue_number=ISSUE + 1),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
def test_missing_head_in_live_pr_refused(self):
|
||||||
|
pr = owning_pr()
|
||||||
|
pr["head"] = {"ref": BRANCH}
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[pr],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
self.assert_blocked(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrdinaryDuplicateBehaviorUnchanged(unittest.TestCase):
|
||||||
|
def test_clean_issue_still_passes(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[],
|
||||||
|
branch_names=["feat/other-issue-99"],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertFalse(result["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
def test_competing_branch_still_blocks_even_with_owning_pr_evidence(self):
|
||||||
|
result = assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()],
|
||||||
|
branch_names=[BRANCH, OTHER_BRANCH],
|
||||||
|
claim_entry={"status": "not_claimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=PHASE_LOCK,
|
||||||
|
recovered_owning_pr=sanctioned_token(),
|
||||||
|
)
|
||||||
|
# The owning PR is exempt, but the competing branch is not.
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
||||||
|
self.assertIn(OTHER_BRANCH, result["conflicting_branches"])
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────── server-derived evidence cannot be forged ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwningPrEvidenceDerivation(unittest.TestCase):
|
||||||
|
def granted(self, **evidence_overrides):
|
||||||
|
evidence = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"locked_branch": BRANCH,
|
||||||
|
"local_head": HEAD,
|
||||||
|
"remote_head": HEAD,
|
||||||
|
"pr_head": HEAD,
|
||||||
|
"pr_number": OWNING_PR,
|
||||||
|
}
|
||||||
|
evidence.update(evidence_overrides)
|
||||||
|
return {
|
||||||
|
"outcome": issue_lock_recovery.RECOVERY_SANCTIONED,
|
||||||
|
"recovery_sanctioned": True,
|
||||||
|
"is_candidate": True,
|
||||||
|
"reasons": [],
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_granted_recovery_yields_evidence(self):
|
||||||
|
token = issue_lock_recovery.owning_pr_recovery_evidence(self.granted())
|
||||||
|
self.assertEqual(token, sanctioned_token())
|
||||||
|
|
||||||
|
def test_none_assessment_yields_nothing(self):
|
||||||
|
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(None))
|
||||||
|
|
||||||
|
def test_refused_assessment_yields_nothing(self):
|
||||||
|
refused = self.granted()
|
||||||
|
refused["outcome"] = issue_lock_recovery.REFUSED
|
||||||
|
refused["recovery_sanctioned"] = False
|
||||||
|
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(refused))
|
||||||
|
|
||||||
|
def test_sanctioned_flag_without_outcome_yields_nothing(self):
|
||||||
|
forged = self.granted()
|
||||||
|
forged["outcome"] = "SOMETHING_ELSE"
|
||||||
|
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(forged))
|
||||||
|
|
||||||
|
def test_missing_pr_number_yields_nothing(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self.granted(pr_number=None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_head_disagreement_in_evidence_yields_nothing(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self.granted(remote_head=OTHER_HEAD)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self.granted(local_head=OTHER_HEAD)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_real_refused_assessment_yields_nothing(self):
|
||||||
|
"""End-to-end against the real assessor, not a hand-built dict."""
|
||||||
|
lock = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": "/scratch/wt",
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Example-Org",
|
||||||
|
"repo": "Example-Repo",
|
||||||
|
"session_pid": os.getpid(), # alive → must refuse
|
||||||
|
"work_lease": {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch": BRANCH,
|
||||||
|
"worktree_path": "/scratch/wt",
|
||||||
|
"claimant": {"username": IDENTITY, "profile": PROFILE},
|
||||||
|
"expires_at": shifted_ts(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assessment = issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||||
|
lock,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
worktree_path="/scratch/wt",
|
||||||
|
remote="prgs",
|
||||||
|
org="Example-Org",
|
||||||
|
repo="Example-Repo",
|
||||||
|
identity=IDENTITY,
|
||||||
|
profile=PROFILE,
|
||||||
|
current_branch=BRANCH,
|
||||||
|
porcelain_status="",
|
||||||
|
head_sha=HEAD,
|
||||||
|
remote_head_sha=HEAD,
|
||||||
|
pr_head_sha=HEAD,
|
||||||
|
pr_number=OWNING_PR,
|
||||||
|
competing_live_locks=[],
|
||||||
|
candidate_branches=[BRANCH],
|
||||||
|
current_pid=os.getpid(),
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["recovery_sanctioned"])
|
||||||
|
self.assertIsNone(
|
||||||
|
issue_lock_recovery.owning_pr_recovery_evidence(assessment)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────── end-to-end: the real gitea_lock_issue ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class LockIssueEndToEndBase(unittest.TestCase):
|
||||||
|
"""Drives ``mcp_server.gitea_lock_issue`` with live git/Gitea observation
|
||||||
|
stubbed at the module boundary — the production gate chain itself runs."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.lock_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.lock_dir.cleanup)
|
||||||
|
self.worktree = os.path.realpath(os.getcwd())
|
||||||
|
# Bind host/org/repo to what the ``test-author-prgs`` fixture profile is
|
||||||
|
# pinned to, so the session-context gate under test is the real one and
|
||||||
|
# not a cross-host denial. The issue number and lock dir stay synthetic.
|
||||||
|
self.remotes = patch.dict(mcp_server.REMOTES, {
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": ORG,
|
||||||
|
"repo": REPO,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
self.remotes.start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
|
||||||
|
def write_durable_lock(self, *, pid, branch=BRANCH, worktree=None):
|
||||||
|
path = issue_lock_store.lock_file_path(
|
||||||
|
remote="prgs",
|
||||||
|
org=ORG,
|
||||||
|
repo=REPO,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
lock_dir=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
claimant = {"username": IDENTITY, "profile": PROFILE}
|
||||||
|
data = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": branch,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": ORG,
|
||||||
|
"repo": REPO,
|
||||||
|
"worktree_path": worktree or self.worktree,
|
||||||
|
"session_pid": pid,
|
||||||
|
"pid": pid,
|
||||||
|
"work_lease": {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch": branch,
|
||||||
|
"worktree_path": worktree or self.worktree,
|
||||||
|
"claimant": claimant,
|
||||||
|
"created_at": shifted_ts(-1),
|
||||||
|
"last_heartbeat_at": shifted_ts(-1),
|
||||||
|
"expires_at": shifted_ts(),
|
||||||
|
},
|
||||||
|
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||||
|
tool="gitea_lock_issue",
|
||||||
|
claimant=claimant,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
issue_lock_store.save_lock_file(path, data)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def run_lock(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
open_prs,
|
||||||
|
porcelain="",
|
||||||
|
current_branch=BRANCH,
|
||||||
|
base_equivalent=False,
|
||||||
|
branch_names=None,
|
||||||
|
head_sha=HEAD,
|
||||||
|
remote_head=HEAD,
|
||||||
|
):
|
||||||
|
branch_names = branch_names if branch_names is not None else [BRANCH]
|
||||||
|
branch_entries = [
|
||||||
|
{"name": name, "commit": {"id": remote_head}} for name in branch_names
|
||||||
|
]
|
||||||
|
env = shared_mutation_env(
|
||||||
|
"test-author-prgs",
|
||||||
|
include_example_repo=True,
|
||||||
|
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"mcp_server.api_get_all", return_value=branch_entries
|
||||||
|
), patch(
|
||||||
|
"mcp_server._list_open_pulls", return_value=list(open_prs)
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_auth_header", return_value="token x"
|
||||||
|
), patch(
|
||||||
|
"mcp_server._work_lease_claimant",
|
||||||
|
return_value={"username": IDENTITY, "profile": PROFILE},
|
||||||
|
), patch(
|
||||||
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
|
return_value={
|
||||||
|
"current_branch": current_branch,
|
||||||
|
"porcelain_status": porcelain,
|
||||||
|
"base_equivalent": base_equivalent,
|
||||||
|
"head_sha": head_sha,
|
||||||
|
"inspected_git_root": self.worktree,
|
||||||
|
"base_branch": "master",
|
||||||
|
},
|
||||||
|
), patch(
|
||||||
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
|
side_effect=lambda h, o, r, auth, issue_number: (
|
||||||
|
list(open_prs), list(branch_names), {"status": "not_claimed"}
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||||
|
return mcp_server.gitea_lock_issue(
|
||||||
|
issue_number=ISSUE,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path=self.worktree,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecoveryWithOwningPrSucceeds(LockIssueEndToEndBase):
|
||||||
|
def test_dead_session_recovery_with_owning_pr_relocks(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
result = self.run_lock(open_prs=[owning_pr()])
|
||||||
|
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(result["issue_number"], ISSUE)
|
||||||
|
self.assertEqual(result["branch_name"], BRANCH)
|
||||||
|
self.assertTrue(result["lock_freshness"]["live"])
|
||||||
|
self.assertTrue(result["lock_freshness"]["pid_alive"])
|
||||||
|
|
||||||
|
def test_recovered_lock_records_truthful_provenance(self):
|
||||||
|
prior = dead_pid()
|
||||||
|
self.write_durable_lock(pid=prior)
|
||||||
|
self.run_lock(open_prs=[owning_pr()])
|
||||||
|
|
||||||
|
lock = issue_lock_store.load_issue_lock(
|
||||||
|
remote="prgs",
|
||||||
|
org=ORG,
|
||||||
|
repo=REPO,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
lock_dir=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
record = lock.get("dead_session_recovery") or {}
|
||||||
|
self.assertTrue(record.get("recovered"))
|
||||||
|
self.assertEqual(record.get("prior_session_pid"), prior)
|
||||||
|
self.assertEqual(record.get("replacement_session_pid"), os.getpid())
|
||||||
|
self.assertFalse(record.get("prior_pid_alive"))
|
||||||
|
self.assertEqual(record.get("pr_number"), OWNING_PR)
|
||||||
|
self.assertEqual(record.get("branch_name"), BRANCH)
|
||||||
|
self.assertEqual(record.get("identity"), IDENTITY)
|
||||||
|
|
||||||
|
def test_recovered_lock_is_live_and_proves_pr_ownership(self):
|
||||||
|
"""AC6: the persisted lock satisfies update-by-merge's ownership prover."""
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
self.run_lock(open_prs=[owning_pr()])
|
||||||
|
|
||||||
|
lock = issue_lock_store.load_issue_lock(
|
||||||
|
remote="prgs",
|
||||||
|
org=ORG,
|
||||||
|
repo=REPO,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
lock_dir=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
self.assertTrue(issue_lock_store.is_lease_live(lock))
|
||||||
|
|
||||||
|
env = shared_mutation_env(
|
||||||
|
"test-author-prgs",
|
||||||
|
include_example_repo=True,
|
||||||
|
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||||
|
ownership = mcp_server._prove_author_ownership_for_pr(
|
||||||
|
pr_number=OWNING_PR,
|
||||||
|
pr_title=f"fix: something (Closes #{ISSUE})",
|
||||||
|
pr_body=f"Closes #{ISSUE}.",
|
||||||
|
source_branch=BRANCH,
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
org=ORG,
|
||||||
|
repo=REPO,
|
||||||
|
worktree_path=self.worktree,
|
||||||
|
)
|
||||||
|
self.assertTrue(ownership["proven"], ownership["reasons"])
|
||||||
|
self.assertTrue(ownership["has_author_lock"])
|
||||||
|
self.assertEqual(ownership["matched_issue"], ISSUE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecoveryRejectionsEndToEnd(LockIssueEndToEndBase):
|
||||||
|
def assert_lock_refused(self, **kwargs):
|
||||||
|
with self.assertRaises((ValueError, RuntimeError)) as ctx:
|
||||||
|
self.run_lock(**kwargs)
|
||||||
|
return str(ctx.exception)
|
||||||
|
|
||||||
|
def test_competing_pr_still_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
message = self.assert_lock_refused(
|
||||||
|
open_prs=[owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
)
|
||||||
|
self.assertIn("already covers issue", message)
|
||||||
|
|
||||||
|
def test_multiple_linked_open_prs_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
message = self.assert_lock_refused(
|
||||||
|
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
|
||||||
|
)
|
||||||
|
self.assertIn("already covers issue", message)
|
||||||
|
|
||||||
|
def test_owning_pr_on_a_different_head_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
self.assert_lock_refused(open_prs=[owning_pr(sha=OTHER_HEAD)])
|
||||||
|
|
||||||
|
def test_lock_registered_to_a_different_worktree_blocked(self):
|
||||||
|
self.write_durable_lock(
|
||||||
|
pid=dead_pid(), worktree=os.path.join(self.worktree, "elsewhere")
|
||||||
|
)
|
||||||
|
self.assert_lock_refused(open_prs=[owning_pr()])
|
||||||
|
|
||||||
|
def test_dirty_worktree_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
self.assert_lock_refused(
|
||||||
|
open_prs=[owning_pr()], porcelain=" M gitea_mcp_server.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_worktree_parked_on_another_branch_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
self.assert_lock_refused(
|
||||||
|
open_prs=[owning_pr()], current_branch="master"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_head_differing_from_remote_blocked(self):
|
||||||
|
self.write_durable_lock(pid=dead_pid())
|
||||||
|
self.assert_lock_refused(
|
||||||
|
open_prs=[owning_pr()], head_sha=OTHER_HEAD
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_live_prior_pid_blocked(self):
|
||||||
|
self.write_durable_lock(pid=os.getpid())
|
||||||
|
self.assert_lock_refused(open_prs=[owning_pr()])
|
||||||
|
|
||||||
|
def test_new_claim_without_prior_lock_still_requires_base_equivalence(self):
|
||||||
|
"""AC10: no durable lock → no recovery → base-equivalence still rules."""
|
||||||
|
self.assert_lock_refused(open_prs=[], branch_names=[])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,809 @@
|
|||||||
|
"""Regression tests for #757: #274 and #604 must not disagree.
|
||||||
|
|
||||||
|
The sanctioned ``create_issue`` bootstrap (#749/#750) was unreachable in
|
||||||
|
production: the #274 branches-only guard consulted the bootstrap and permitted
|
||||||
|
a clean canonical control checkout, then the bootstrap-blind #604 anti-stomp
|
||||||
|
preflight rejected the same checkout as ``wrong_worktree``.
|
||||||
|
|
||||||
|
These tests pin the fix:
|
||||||
|
|
||||||
|
* one server-derived assessment, interpreted by one shared predicate;
|
||||||
|
* the waiver is narrow (only the wrong-worktree verdict, only create_issue,
|
||||||
|
only from the exact clean canonical control checkout);
|
||||||
|
* every other guard and rejection reason keeps its fail-closed behaviour;
|
||||||
|
* eligibility cannot be forged through a public tool signature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import anti_stomp_preflight as asp # noqa: E402
|
||||||
|
import create_issue_bootstrap as cib # noqa: E402
|
||||||
|
import gitea_mcp_server as srv # noqa: E402
|
||||||
|
|
||||||
|
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||||
|
MASTER_SHA = "a" * 40
|
||||||
|
STALE_SHA = "b" * 40
|
||||||
|
|
||||||
|
current_file_path = Path(__file__).resolve()
|
||||||
|
if "branches" in current_file_path.parts:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||||
|
else:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||||
|
|
||||||
|
BRANCHES_WORKTREE = str(Path(CONTROL_CHECKOUT_ROOT) / "branches" / "issue-1-x")
|
||||||
|
|
||||||
|
|
||||||
|
def proven_bootstrap(
|
||||||
|
*,
|
||||||
|
workspace=CONTROL_CHECKOUT_ROOT,
|
||||||
|
root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
task="create_issue",
|
||||||
|
branch="master",
|
||||||
|
porcelain="",
|
||||||
|
head=MASTER_SHA,
|
||||||
|
remote_sha=MASTER_SHA,
|
||||||
|
):
|
||||||
|
"""Build a real assessment via the production assessor (never hand-rolled)."""
|
||||||
|
return cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=workspace,
|
||||||
|
canonical_repo_root=root,
|
||||||
|
current_branch=branch,
|
||||||
|
head_sha=head,
|
||||||
|
porcelain_status=porcelain,
|
||||||
|
remote_master_sha=remote_sha,
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSharedPredicate(unittest.TestCase):
|
||||||
|
"""The one interpretation both guards consume (AC6, AC7)."""
|
||||||
|
|
||||||
|
def _permits(self, assessment, task="create_issue", workspace=None, root=None):
|
||||||
|
return cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task=task,
|
||||||
|
workspace_path=workspace or CONTROL_CHECKOUT_ROOT,
|
||||||
|
canonical_repo_root=root or CONTROL_CHECKOUT_ROOT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_proven_bootstrap_permits(self):
|
||||||
|
self.assertTrue(self._permits(proven_bootstrap()))
|
||||||
|
|
||||||
|
def test_tool_alias_permits(self):
|
||||||
|
assessment = proven_bootstrap(task="gitea_create_issue")
|
||||||
|
self.assertTrue(self._permits(assessment, task="gitea_create_issue"))
|
||||||
|
|
||||||
|
def test_missing_assessment_fails_closed(self):
|
||||||
|
self.assertFalse(self._permits(None))
|
||||||
|
|
||||||
|
def test_malformed_assessment_fails_closed(self):
|
||||||
|
for bogus in ("allowed", 1, True, [], ["allowed"], object()):
|
||||||
|
with self.subTest(bogus=bogus):
|
||||||
|
self.assertFalse(self._permits(bogus))
|
||||||
|
|
||||||
|
def test_refused_assessment_fails_closed(self):
|
||||||
|
# Dirty control checkout -> assessor blocks -> predicate must refuse.
|
||||||
|
self.assertFalse(
|
||||||
|
self._permits(proven_bootstrap(porcelain=" M gitea_mcp_server.py\n"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_not_applicable_assessment_fails_closed(self):
|
||||||
|
# branches/ worktree -> not_applicable -> no waiver.
|
||||||
|
self.assertFalse(
|
||||||
|
self._permits(proven_bootstrap(workspace=BRANCHES_WORKTREE))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_incomplete_assessment_fails_closed(self):
|
||||||
|
incomplete = dict(proven_bootstrap())
|
||||||
|
del incomplete["bootstrap_path"]
|
||||||
|
self.assertFalse(self._permits(incomplete))
|
||||||
|
|
||||||
|
def test_contradictory_block_and_allow_fails_closed(self):
|
||||||
|
contradictory = dict(proven_bootstrap(), block=True)
|
||||||
|
self.assertFalse(self._permits(contradictory))
|
||||||
|
|
||||||
|
def test_contradictory_dirty_but_allowed_fails_closed(self):
|
||||||
|
contradictory = dict(proven_bootstrap(), dirty_files=["gitea_mcp_server.py"])
|
||||||
|
self.assertFalse(self._permits(contradictory))
|
||||||
|
|
||||||
|
def test_contradictory_under_branches_but_allowed_fails_closed(self):
|
||||||
|
contradictory = dict(proven_bootstrap(), under_branches=True)
|
||||||
|
self.assertFalse(self._permits(contradictory))
|
||||||
|
|
||||||
|
def test_contradictory_reasons_present_fails_closed(self):
|
||||||
|
contradictory = dict(proven_bootstrap(), reasons=["something refused"])
|
||||||
|
self.assertFalse(self._permits(contradictory))
|
||||||
|
|
||||||
|
def test_truthy_non_true_values_fail_closed(self):
|
||||||
|
"""Strict identity: no truthy smuggling (1, 'yes') can assert eligibility."""
|
||||||
|
for value in (1, "yes", "true", [1]):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertFalse(self._permits(dict(proven_bootstrap(), allowed=value)))
|
||||||
|
|
||||||
|
def test_wrong_task_fails_closed(self):
|
||||||
|
"""An otherwise-proven assessment cannot license a different task."""
|
||||||
|
self.assertFalse(self._permits(proven_bootstrap(), task="lock_issue"))
|
||||||
|
self.assertFalse(self._permits(proven_bootstrap(), task="create_pr"))
|
||||||
|
self.assertFalse(self._permits(proven_bootstrap(), task=None))
|
||||||
|
|
||||||
|
def test_foreign_workspace_binding_fails_closed(self):
|
||||||
|
"""Assessment must describe the workspace actually being guarded."""
|
||||||
|
other = proven_bootstrap(workspace="/other/clone", root="/other/clone")
|
||||||
|
self.assertFalse(self._permits(other))
|
||||||
|
|
||||||
|
def test_scope_and_path_tampering_fails_closed(self):
|
||||||
|
self.assertFalse(
|
||||||
|
self._permits(dict(proven_bootstrap(), task_scope="all_tasks"))
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
self._permits(dict(proven_bootstrap(), bootstrap_path="anything_goes"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAntiStompHonorsBootstrap(unittest.TestCase):
|
||||||
|
"""#604 consumes the same decision, and waives only wrong_worktree."""
|
||||||
|
|
||||||
|
def _assess(self, *, task="create_issue", bootstrap=None, workspace=None, **kw):
|
||||||
|
params = dict(
|
||||||
|
task=task,
|
||||||
|
profile_role="author",
|
||||||
|
required_role="author",
|
||||||
|
workspace_path=workspace or CONTROL_CHECKOUT_ROOT,
|
||||||
|
project_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
current_branch="master",
|
||||||
|
root_head_sha=MASTER_SHA,
|
||||||
|
root_porcelain="",
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
check_repo=False,
|
||||||
|
create_issue_bootstrap_assessment=bootstrap,
|
||||||
|
)
|
||||||
|
params.update(kw)
|
||||||
|
return asp.assess_anti_stomp_preflight(**params)
|
||||||
|
|
||||||
|
def _blocker_kinds(self, result):
|
||||||
|
return {b["kind"] for b in result.get("blockers") or []}
|
||||||
|
|
||||||
|
def test_control_checkout_without_bootstrap_still_blocked(self):
|
||||||
|
"""Baseline: the exact production failure, unwaived."""
|
||||||
|
res = self._assess(bootstrap=None)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_control_checkout_with_proven_bootstrap_permitted(self):
|
||||||
|
"""AC1/AC2: the #757 fix — same inputs, bootstrap honoured."""
|
||||||
|
res = self._assess(bootstrap=proven_bootstrap())
|
||||||
|
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
self.assertTrue(res["checks"]["worktree"]["create_issue_bootstrap_waived"])
|
||||||
|
self.assertFalse(res["checks"]["worktree"]["block"])
|
||||||
|
|
||||||
|
def test_tool_alias_permitted(self):
|
||||||
|
res = self._assess(
|
||||||
|
task="gitea_create_issue",
|
||||||
|
bootstrap=proven_bootstrap(task="gitea_create_issue"),
|
||||||
|
)
|
||||||
|
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_non_create_issue_task_never_waived(self):
|
||||||
|
"""AC5: other author mutations keep the branches-only requirement."""
|
||||||
|
for task in ("lock_issue", "create_pr", "commit_files", "mark_issue"):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
res = self._assess(task=task, bootstrap=proven_bootstrap())
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_dirty_control_checkout_still_blocked(self):
|
||||||
|
"""AC4: dirty root refuses the bootstrap, so no waiver."""
|
||||||
|
dirty = " M gitea_mcp_server.py\n"
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(porcelain=dirty), root_porcelain=dirty
|
||||||
|
)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_detached_control_checkout_still_blocked(self):
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(branch=""), current_branch=""
|
||||||
|
)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_base_divergence_still_blocked(self):
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(head=STALE_SHA), root_head_sha=STALE_SHA
|
||||||
|
)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_waiver_does_not_suppress_stale_runtime(self):
|
||||||
|
"""AC: waive only wrong_worktree — stale runtime still fails closed."""
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(),
|
||||||
|
startup_head=STALE_SHA,
|
||||||
|
current_code_head=MASTER_SHA,
|
||||||
|
)
|
||||||
|
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
self.assertIn(asp.BLOCKER_STALE_RUNTIME, self._blocker_kinds(res))
|
||||||
|
self.assertTrue(res["block"])
|
||||||
|
|
||||||
|
def test_waiver_does_not_suppress_wrong_repo(self):
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(),
|
||||||
|
check_repo=True,
|
||||||
|
remote="prgs",
|
||||||
|
resolved_org="Scaled-Tech-Consulting",
|
||||||
|
resolved_repo="Timesheet",
|
||||||
|
# Not both-explicit, so the #530 repo guard actually evaluates the
|
||||||
|
# mismatch against the local remote instead of trusting the caller.
|
||||||
|
org_explicit=False,
|
||||||
|
repo_explicit=False,
|
||||||
|
local_remote_url=(
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertNotIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_REPO, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_waiver_does_not_suppress_wrong_role(self):
|
||||||
|
res = self._assess(
|
||||||
|
bootstrap=proven_bootstrap(),
|
||||||
|
profile_role="reviewer",
|
||||||
|
required_role="author",
|
||||||
|
)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_ROLE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
def test_branches_worktree_unaffected(self):
|
||||||
|
"""AC: ordinary branches/ worktrees keep working, waived or not."""
|
||||||
|
for bootstrap in (None, proven_bootstrap(workspace=BRANCHES_WORKTREE)):
|
||||||
|
with self.subTest(bootstrap=bool(bootstrap)):
|
||||||
|
res = self._assess(
|
||||||
|
workspace=BRANCHES_WORKTREE,
|
||||||
|
bootstrap=bootstrap,
|
||||||
|
current_branch="fix/issue-1-x",
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forged_assessment_rejected(self):
|
||||||
|
"""AC6: a hand-built 'allowed' dict cannot unlock the waiver."""
|
||||||
|
forged = {"allowed": True, "block": False}
|
||||||
|
res = self._assess(bootstrap=forged)
|
||||||
|
self.assertIn(asp.BLOCKER_WRONG_WORKTREE, self._blocker_kinds(res))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuardAgreement(unittest.TestCase):
|
||||||
|
"""AC7: the regression that would have caught the #757 defect.
|
||||||
|
|
||||||
|
For identical evidence, the #274 guard and the #604 guard must return the
|
||||||
|
same wrong-worktree verdict across the full workspace-state matrix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MATRIX = [
|
||||||
|
(
|
||||||
|
"clean control + create_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_issue", "master", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clean control + alias",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "gitea_create_issue", "master", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clean control + lock_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "lock_issue", "master", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clean control + create_pr",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_pr", "master", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dirty control + create_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_issue", "master",
|
||||||
|
" M gitea_mcp_server.py\n", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"detached control + create_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_issue", "", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"non-base control + create_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_issue", "feat/x", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"diverged control + create_issue",
|
||||||
|
CONTROL_CHECKOUT_ROOT, "create_issue", "master", "", STALE_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"branches wt + create_issue",
|
||||||
|
BRANCHES_WORKTREE, "create_issue", "fix/issue-1-x", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"branches wt + lock_issue",
|
||||||
|
BRANCHES_WORKTREE, "lock_issue", "fix/issue-1-x", "", MASTER_SHA,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _guard_274_blocks(self, *, workspace, task, branch, porcelain, head, bootstrap):
|
||||||
|
git_state = {
|
||||||
|
"current_branch": branch,
|
||||||
|
"head_sha": head,
|
||||||
|
"porcelain_status": porcelain,
|
||||||
|
}
|
||||||
|
ctx = {
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"canonical_repo_root": CONTROL_CHECKOUT_ROOT,
|
||||||
|
}
|
||||||
|
with patch.object(srv, "_effective_workspace_role", return_value="author"), \
|
||||||
|
patch.object(srv, "_actual_profile_role", return_value="author"), \
|
||||||
|
patch.object(
|
||||||
|
srv, "_resolve_namespace_mutation_context", return_value=ctx
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=git_state,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
srv._enforce_branches_only_author_mutation(
|
||||||
|
workspace, task=task, bootstrap_assessment=bootstrap
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except RuntimeError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _guard_604_blocks(self, *, workspace, task, branch, porcelain, head, bootstrap):
|
||||||
|
res = asp.assess_anti_stomp_preflight(
|
||||||
|
task=task,
|
||||||
|
profile_role="author",
|
||||||
|
required_role="author",
|
||||||
|
workspace_path=workspace,
|
||||||
|
project_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
current_branch=branch,
|
||||||
|
root_head_sha=head,
|
||||||
|
root_porcelain=porcelain,
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
check_repo=False,
|
||||||
|
create_issue_bootstrap_assessment=bootstrap,
|
||||||
|
)
|
||||||
|
return asp.BLOCKER_WRONG_WORKTREE in {
|
||||||
|
b["kind"] for b in res.get("blockers") or []
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_guards_agree_across_matrix(self):
|
||||||
|
for label, workspace, task, branch, porcelain, head in self.MATRIX:
|
||||||
|
with self.subTest(case=label):
|
||||||
|
# ONE server-derived assessment, exactly as production computes it.
|
||||||
|
bootstrap = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=workspace,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
current_branch=branch,
|
||||||
|
head_sha=head,
|
||||||
|
porcelain_status=porcelain,
|
||||||
|
remote_master_sha=MASTER_SHA,
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
kwargs = dict(
|
||||||
|
workspace=workspace,
|
||||||
|
task=task,
|
||||||
|
branch=branch,
|
||||||
|
porcelain=porcelain,
|
||||||
|
head=head,
|
||||||
|
bootstrap=bootstrap,
|
||||||
|
)
|
||||||
|
blocked_274 = self._guard_274_blocks(**kwargs)
|
||||||
|
blocked_604 = self._guard_604_blocks(**kwargs)
|
||||||
|
self.assertEqual(
|
||||||
|
blocked_274,
|
||||||
|
blocked_604,
|
||||||
|
f"{label}: #274 blocked={blocked_274} "
|
||||||
|
f"but #604 blocked={blocked_604}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clean_control_create_issue_permitted_by_both(self):
|
||||||
|
"""The specific case that was broken: both guards must permit."""
|
||||||
|
kwargs = dict(
|
||||||
|
workspace=CONTROL_CHECKOUT_ROOT,
|
||||||
|
task="create_issue",
|
||||||
|
branch="master",
|
||||||
|
porcelain="",
|
||||||
|
head=MASTER_SHA,
|
||||||
|
bootstrap=proven_bootstrap(),
|
||||||
|
)
|
||||||
|
self.assertFalse(self._guard_274_blocks(**kwargs))
|
||||||
|
self.assertFalse(self._guard_604_blocks(**kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoCallerForgeableEligibility(unittest.TestCase):
|
||||||
|
"""AC6: eligibility is never reachable through a public tool signature."""
|
||||||
|
|
||||||
|
def test_create_issue_tool_exposes_no_bootstrap_argument(self):
|
||||||
|
params = set(inspect.signature(srv.gitea_create_issue).parameters)
|
||||||
|
for forbidden in (
|
||||||
|
"bootstrap",
|
||||||
|
"bootstrap_assessment",
|
||||||
|
"create_issue_bootstrap",
|
||||||
|
"create_issue_bootstrap_assessment",
|
||||||
|
"allow_control_checkout",
|
||||||
|
"bootstrap_allowed",
|
||||||
|
):
|
||||||
|
self.assertNotIn(forbidden, params)
|
||||||
|
|
||||||
|
def test_no_mcp_tool_exposes_bootstrap_argument(self):
|
||||||
|
"""No public gitea_* tool may take bootstrap evidence from the caller."""
|
||||||
|
for name in dir(srv):
|
||||||
|
if not name.startswith("gitea_"):
|
||||||
|
continue
|
||||||
|
fn = getattr(srv, name)
|
||||||
|
if not callable(fn):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
params = set(inspect.signature(fn).parameters)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
leaked = {p for p in params if "bootstrap" in p.lower()}
|
||||||
|
self.assertFalse(leaked, f"{name} exposes bootstrap args: {leaked}")
|
||||||
|
|
||||||
|
def test_internal_helper_yields_nothing_for_other_tasks(self):
|
||||||
|
self.assertTrue(hasattr(srv, "_create_issue_bootstrap_assessment"))
|
||||||
|
self.assertIsNone(
|
||||||
|
srv._create_issue_bootstrap_assessment("lock_issue"),
|
||||||
|
"non-create_issue tasks must yield no bootstrap evidence",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNativeCreateIssueEndToEnd(unittest.TestCase):
|
||||||
|
"""AC8: the production handler, with the #604 gate LIVE (not patched out).
|
||||||
|
|
||||||
|
The pre-existing #749 e2e test patched ``_run_anti_stomp_preflight`` to a
|
||||||
|
no-op, which is exactly why this defect reached production. These tests
|
||||||
|
leave it running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
srv._preflight_whoami_called = True
|
||||||
|
srv._preflight_capability_called = True
|
||||||
|
srv._preflight_resolved_role = "author"
|
||||||
|
srv._preflight_resolved_task = "create_issue"
|
||||||
|
srv._preflight_whoami_violation = False
|
||||||
|
srv._preflight_capability_violation = False
|
||||||
|
self._orig_in_test = srv._preflight_in_test_mode
|
||||||
|
srv._preflight_in_test_mode = lambda: False
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
srv._preflight_in_test_mode = self._orig_in_test
|
||||||
|
srv._preflight_resolved_task = None
|
||||||
|
|
||||||
|
def _git_state(self, branch="master", head=MASTER_SHA, porcelain=""):
|
||||||
|
return {
|
||||||
|
"current_branch": branch,
|
||||||
|
"head_sha": head,
|
||||||
|
"porcelain_status": porcelain,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _run_create_issue(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
git_state,
|
||||||
|
remote_sha=MASTER_SHA,
|
||||||
|
parity=None,
|
||||||
|
title="Bootstrap issue from clean control",
|
||||||
|
):
|
||||||
|
"""Invoke the native handler with the anti-stomp gate live."""
|
||||||
|
parity = parity or {
|
||||||
|
"startup_head": MASTER_SHA,
|
||||||
|
"current_head": MASTER_SHA,
|
||||||
|
}
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
|
||||||
|
patch.object(srv, "_auth", return_value=FAKE_AUTH), \
|
||||||
|
patch.object(srv, "_profile_permission_block", return_value=None), \
|
||||||
|
patch.object(srv, "_namespace_mutation_block", return_value=None), \
|
||||||
|
patch.object(
|
||||||
|
srv.role_session_router,
|
||||||
|
"check_author_mutation_after_reviewer_stop",
|
||||||
|
return_value=(True, []),
|
||||||
|
), \
|
||||||
|
patch.object(srv, "api_get_all", return_value=[]), \
|
||||||
|
patch.object(srv, "api_request") as mock_api, \
|
||||||
|
patch.object(
|
||||||
|
srv.root_checkout_guard,
|
||||||
|
"resolve_remote_master_sha",
|
||||||
|
return_value=remote_sha,
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_current_master_parity", return_value=parity), \
|
||||||
|
patch.object(
|
||||||
|
srv,
|
||||||
|
"get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_actual_profile_role", return_value="author"), \
|
||||||
|
patch.object(srv, "_effective_workspace_role", return_value="author"), \
|
||||||
|
patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=git_state,
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
srv,
|
||||||
|
"_get_workspace_porcelain",
|
||||||
|
return_value=git_state["porcelain_status"],
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_enforce_root_checkout_guard"):
|
||||||
|
mock_api.return_value = {
|
||||||
|
"number": 99,
|
||||||
|
"html_url": "https://gitea.example.com/issues/99",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = srv.gitea_create_issue(
|
||||||
|
title=title,
|
||||||
|
body="Body text for content gate.",
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return {"raised": str(exc)}, mock_api
|
||||||
|
return result, mock_api
|
||||||
|
|
||||||
|
def test_clean_control_checkout_reaches_api(self):
|
||||||
|
"""The exact production failure that blocked filing #757 and #758."""
|
||||||
|
res, mock_api = self._run_create_issue(git_state=self._git_state())
|
||||||
|
self.assertNotIn("raised", res, f"guard still blocks: {res.get('raised')}")
|
||||||
|
self.assertEqual(res.get("number"), 99)
|
||||||
|
mock_api.assert_called_once()
|
||||||
|
|
||||||
|
def test_dirty_control_checkout_blocked(self):
|
||||||
|
dirty = " M gitea_mcp_server.py\n"
|
||||||
|
res, mock_api = self._run_create_issue(
|
||||||
|
git_state=self._git_state(porcelain=dirty)
|
||||||
|
)
|
||||||
|
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_detached_control_checkout_blocked(self):
|
||||||
|
res, mock_api = self._run_create_issue(git_state=self._git_state(branch=""))
|
||||||
|
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_base_mismatch_blocked(self):
|
||||||
|
res, mock_api = self._run_create_issue(
|
||||||
|
git_state=self._git_state(head=STALE_SHA)
|
||||||
|
)
|
||||||
|
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_stale_runtime_blocked(self):
|
||||||
|
res, mock_api = self._run_create_issue(
|
||||||
|
git_state=self._git_state(),
|
||||||
|
parity={"startup_head": STALE_SHA, "current_head": MASTER_SHA},
|
||||||
|
)
|
||||||
|
self.assertFalse(isinstance(res, dict) and res.get("number") == 99)
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_non_create_issue_mutation_still_blocked_from_control(self):
|
||||||
|
"""AC5 through the real preflight, with anti-stomp live."""
|
||||||
|
srv._preflight_resolved_task = "lock_issue"
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
|
||||||
|
patch.object(srv, "_actual_profile_role", return_value="author"), \
|
||||||
|
patch.object(srv, "_effective_workspace_role", return_value="author"), \
|
||||||
|
patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=self._git_state(),
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_get_workspace_porcelain", return_value=""), \
|
||||||
|
patch.object(
|
||||||
|
srv.root_checkout_guard,
|
||||||
|
"resolve_remote_master_sha",
|
||||||
|
return_value=MASTER_SHA,
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_enforce_root_checkout_guard"):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
srv.verify_preflight_purity(remote="prgs", task="lock_issue")
|
||||||
|
self.assertIn("control checkout", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseEquivalenceProofRequired(unittest.TestCase):
|
||||||
|
"""#757 AC3/AC4: an unknown tip is not evidence of agreement.
|
||||||
|
|
||||||
|
The original fix compared SHAs only inside
|
||||||
|
``if remote_tip and local_tip and remote_tip != local_tip``, so a missing
|
||||||
|
local HEAD, an unresolvable live master, or a resolver failure all fell
|
||||||
|
through and *granted* the bootstrap exemption. Base equivalence must be
|
||||||
|
proven, and anything less must fail closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _permits(self, assessment):
|
||||||
|
return cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task="create_issue",
|
||||||
|
workspace_path=CONTROL_CHECKOUT_ROOT,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _assert_fails_closed(self, assessment, *, expect_reason):
|
||||||
|
self.assertTrue(assessment["block"], "assessor must block")
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertFalse(assessment["base_tips_verified"])
|
||||||
|
self.assertFalse(self._permits(assessment), "predicate must refuse")
|
||||||
|
joined = " ".join(assessment["reasons"]).lower()
|
||||||
|
self.assertIn(expect_reason, joined)
|
||||||
|
|
||||||
|
def test_missing_local_head_fails_closed(self):
|
||||||
|
self._assert_fails_closed(
|
||||||
|
proven_bootstrap(head=None),
|
||||||
|
expect_reason="control checkout head sha is unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_remote_master_fails_closed(self):
|
||||||
|
self._assert_fails_closed(
|
||||||
|
proven_bootstrap(remote_sha=None),
|
||||||
|
expect_reason="live master tip is unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_both_tips_missing_fails_closed(self):
|
||||||
|
assessment = proven_bootstrap(head=None, remote_sha=None)
|
||||||
|
self._assert_fails_closed(
|
||||||
|
assessment, expect_reason="control checkout head sha is unknown"
|
||||||
|
)
|
||||||
|
self.assertIn("live master tip is unknown", " ".join(assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_empty_and_whitespace_tips_fail_closed(self):
|
||||||
|
for head, remote in (("", MASTER_SHA), (MASTER_SHA, ""), (" ", " ")):
|
||||||
|
with self.subTest(head=repr(head), remote=repr(remote)):
|
||||||
|
assessment = proven_bootstrap(head=head, remote_sha=remote)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertFalse(self._permits(assessment))
|
||||||
|
|
||||||
|
def test_mismatched_tips_fail_closed(self):
|
||||||
|
self._assert_fails_closed(
|
||||||
|
proven_bootstrap(head=STALE_SHA),
|
||||||
|
expect_reason="does not match",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolver_failure_fails_closed(self):
|
||||||
|
assessment = cib.assess_create_issue_bootstrap(
|
||||||
|
workspace_path=CONTROL_CHECKOUT_ROOT,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=MASTER_SHA,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=None,
|
||||||
|
remote_master_sha_error="TimeoutError: remote unreachable",
|
||||||
|
task="create_issue",
|
||||||
|
)
|
||||||
|
self._assert_fails_closed(
|
||||||
|
assessment, expect_reason="could not be resolved"
|
||||||
|
)
|
||||||
|
# The operator must be able to see *why*, not just that it blocked.
|
||||||
|
self.assertIn("remote unreachable", " ".join(assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_proven_bootstrap_records_both_normalized_shas(self):
|
||||||
|
assessment = proven_bootstrap()
|
||||||
|
self.assertEqual(assessment["local_head_sha"], MASTER_SHA)
|
||||||
|
self.assertEqual(assessment["remote_master_sha"], MASTER_SHA)
|
||||||
|
self.assertTrue(assessment["base_tips_verified"])
|
||||||
|
self.assertTrue(self._permits(assessment))
|
||||||
|
|
||||||
|
def test_tips_are_normalized_before_comparison(self):
|
||||||
|
"""Case and surrounding whitespace are not a different commit."""
|
||||||
|
assessment = proven_bootstrap(
|
||||||
|
head=f" {MASTER_SHA.upper()} ", remote_sha=MASTER_SHA
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["allowed"])
|
||||||
|
self.assertEqual(assessment["local_head_sha"], MASTER_SHA)
|
||||||
|
self.assertTrue(self._permits(assessment))
|
||||||
|
|
||||||
|
def test_predicate_rejects_assessment_with_tips_stripped(self):
|
||||||
|
"""A recorded proof that is later removed cannot still permit."""
|
||||||
|
for field in ("local_head_sha", "remote_master_sha"):
|
||||||
|
with self.subTest(field=field):
|
||||||
|
assessment = dict(proven_bootstrap())
|
||||||
|
assessment[field] = None
|
||||||
|
self.assertFalse(self._permits(assessment))
|
||||||
|
|
||||||
|
def test_predicate_rejects_forged_verified_flag(self):
|
||||||
|
"""base_tips_verified is re-derived, never trusted on its own."""
|
||||||
|
assessment = dict(proven_bootstrap())
|
||||||
|
assessment["local_head_sha"] = MASTER_SHA
|
||||||
|
assessment["remote_master_sha"] = STALE_SHA
|
||||||
|
assessment["base_tips_verified"] = True
|
||||||
|
self.assertFalse(self._permits(assessment))
|
||||||
|
|
||||||
|
def test_predicate_rejects_missing_verified_flag(self):
|
||||||
|
assessment = dict(proven_bootstrap())
|
||||||
|
assessment.pop("base_tips_verified")
|
||||||
|
self.assertFalse(self._permits(assessment))
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerAssessmentFailsClosedOnResolverError(unittest.TestCase):
|
||||||
|
"""AC3/AC4 at the single server-derived computation site."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# verify_preflight_purity short-circuits under pytest; the production
|
||||||
|
# path only runs with test mode disabled (same setup the #757 e2e uses).
|
||||||
|
srv._preflight_whoami_called = True
|
||||||
|
srv._preflight_capability_called = True
|
||||||
|
srv._preflight_resolved_role = "author"
|
||||||
|
srv._preflight_whoami_violation = False
|
||||||
|
srv._preflight_capability_violation = False
|
||||||
|
self._orig_in_test = srv._preflight_in_test_mode
|
||||||
|
srv._preflight_in_test_mode = lambda: False
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
srv._preflight_in_test_mode = self._orig_in_test
|
||||||
|
srv._preflight_resolved_task = None
|
||||||
|
|
||||||
|
def _git_state(self):
|
||||||
|
return {
|
||||||
|
"current_branch": "master",
|
||||||
|
"head_sha": MASTER_SHA,
|
||||||
|
"porcelain_status": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_resolver_exception_produces_blocking_assessment(self):
|
||||||
|
"""A raising resolver must not become a silent, permissive None."""
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
|
||||||
|
patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=self._git_state(),
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
srv.root_checkout_guard,
|
||||||
|
"resolve_remote_master_sha",
|
||||||
|
side_effect=TimeoutError("remote unreachable"),
|
||||||
|
):
|
||||||
|
assessment = srv._create_issue_bootstrap_assessment("create_issue")
|
||||||
|
|
||||||
|
self.assertIsNotNone(assessment)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
self.assertFalse(assessment["base_tips_verified"])
|
||||||
|
self.assertIsNone(assessment["remote_master_sha"])
|
||||||
|
self.assertIn("could not be resolved", " ".join(assessment["reasons"]))
|
||||||
|
self.assertFalse(
|
||||||
|
cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task="create_issue",
|
||||||
|
workspace_path=CONTROL_CHECKOUT_ROOT,
|
||||||
|
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolver_exception_blocks_real_preflight(self):
|
||||||
|
"""Production path: verify_preflight_purity must fail closed."""
|
||||||
|
srv._preflight_resolved_task = "create_issue"
|
||||||
|
try:
|
||||||
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT), \
|
||||||
|
patch.object(srv, "_actual_profile_role", return_value="author"), \
|
||||||
|
patch.object(
|
||||||
|
srv, "_effective_workspace_role", return_value="author"
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=self._git_state(),
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_get_workspace_porcelain", return_value=""), \
|
||||||
|
patch.object(
|
||||||
|
srv.root_checkout_guard,
|
||||||
|
"resolve_remote_master_sha",
|
||||||
|
side_effect=TimeoutError("remote unreachable"),
|
||||||
|
), \
|
||||||
|
patch.object(srv, "_enforce_root_checkout_guard"):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
srv.verify_preflight_purity(remote="prgs", task="create_issue")
|
||||||
|
finally:
|
||||||
|
srv._preflight_resolved_task = None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"""Regression coverage for reviewer-lease preflight ordering (#763)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import anti_stomp_preflight
|
||||||
|
import gitea_mcp_server as server
|
||||||
|
import merger_lease_adoption
|
||||||
|
import reviewer_pr_lease
|
||||||
|
import task_capability_map
|
||||||
|
|
||||||
|
|
||||||
|
def _prime_clean_reviewer_preflight(monkeypatch, resolved_task: str) -> None:
|
||||||
|
"""Install a clean reviewer preflight without bypassing task matching."""
|
||||||
|
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
|
||||||
|
monkeypatch.delenv("GITEA_TEST_FORCE_DIRTY", raising=False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_in_test_mode", lambda: False)
|
||||||
|
monkeypatch.setattr(server, "_process_start_porcelain", "")
|
||||||
|
monkeypatch.setattr(server, "_preflight_whoami_called", False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_capability_called", False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_whoami_violation", False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_capability_violation", False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_resolved_role", None)
|
||||||
|
monkeypatch.setattr(server, "_preflight_resolved_task", None)
|
||||||
|
monkeypatch.setattr(server, "_preflight_whoami_baseline_porcelain", None)
|
||||||
|
monkeypatch.setattr(server, "_preflight_capability_baseline_porcelain", None)
|
||||||
|
monkeypatch.setattr(server, "_preflight_whoami_violation_files", [])
|
||||||
|
monkeypatch.setattr(server, "_preflight_capability_violation_files", [])
|
||||||
|
monkeypatch.setattr(server, "_preflight_reviewer_violation_files", [])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
lambda _worktree=None: {
|
||||||
|
"workspace_path": server.PROJECT_ROOT,
|
||||||
|
"canonical_repo_root": server.PROJECT_ROOT,
|
||||||
|
"process_project_root": server.PROJECT_ROOT,
|
||||||
|
"workspace_role_kind": "reviewer",
|
||||||
|
"workspace_binding_source": "test reviewer binding",
|
||||||
|
"ignored_bindings": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_enforce_stable_branch_contamination_gate", lambda *_a: None)
|
||||||
|
monkeypatch.setattr(server, "_enforce_canonical_repository_root", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr(server, "_enforce_root_checkout_guard", lambda *_a: None)
|
||||||
|
monkeypatch.setattr(server, "_enforce_branches_only_author_mutation", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr(server, "_enforce_issue_scope_guard", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr(server, "_create_issue_bootstrap_assessment", lambda *_a: None)
|
||||||
|
monkeypatch.setattr(server, "_run_anti_stomp_preflight", lambda *_a, **_k: None)
|
||||||
|
|
||||||
|
server.record_preflight_check("whoami")
|
||||||
|
server.record_preflight_check(
|
||||||
|
"capability", resolved_role="reviewer", resolved_task=resolved_task
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_documented_review_capability_allows_reviewer_lease_acquire(monkeypatch):
|
||||||
|
"""whoami -> resolve(review_pr) -> acquire reviewer lease is canonical."""
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
|
||||||
|
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
assert server._preflight_capability_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_lease_capability_without_intervening_call_still_succeeds(monkeypatch):
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
assert server._preflight_capability_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_wrong_and_consumed_capability_fail_closed(monkeypatch):
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "create_issue")
|
||||||
|
with pytest.raises(RuntimeError, match="task mismatch"):
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "acquire_reviewer_pr_lease")
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
with pytest.raises(RuntimeError, match="has not been resolved"):
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
|
||||||
|
def test_documented_intervening_whoami_read_preserves_capability(monkeypatch):
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
|
||||||
|
with patch.object(server, "_get_workspace_porcelain", return_value=""):
|
||||||
|
server.record_preflight_check("whoami")
|
||||||
|
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_transition_is_narrow_alias_aware_and_one_way():
|
||||||
|
assert task_capability_map.preflight_task_matches(
|
||||||
|
"review_pr", "gitea_acquire_reviewer_pr_lease"
|
||||||
|
)
|
||||||
|
assert task_capability_map.preflight_task_matches(
|
||||||
|
"gitea_acquire_reviewer_pr_lease", "acquire_reviewer_pr_lease"
|
||||||
|
)
|
||||||
|
assert not task_capability_map.preflight_task_matches(
|
||||||
|
"acquire_reviewer_pr_lease", "review_pr"
|
||||||
|
)
|
||||||
|
assert not task_capability_map.preflight_task_matches(
|
||||||
|
"review_pr", "acquire_merger_pr_lease"
|
||||||
|
)
|
||||||
|
assert not task_capability_map.preflight_task_matches(
|
||||||
|
"merge_pr", "acquire_reviewer_pr_lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dirty_reviewer_workspace_still_fails_closed(monkeypatch):
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
|
||||||
|
monkeypatch.setenv("GITEA_TEST_PORCELAIN", " M gitea_mcp_server.py\n")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Reviewer role violation"):
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatched_reviewer_workspace_still_fails_closed(monkeypatch):
|
||||||
|
_prime_clean_reviewer_preflight(monkeypatch, "review_pr")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
lambda _worktree=None: {
|
||||||
|
"workspace_path": "/outside/review-pr-762",
|
||||||
|
"canonical_repo_root": "/repo",
|
||||||
|
"process_project_root": "/repo",
|
||||||
|
"workspace_role_kind": "reviewer",
|
||||||
|
"workspace_binding_source": "test reviewer binding",
|
||||||
|
"ignored_bindings": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.author_mutation_worktree,
|
||||||
|
"assess_workspace_repo_membership",
|
||||||
|
lambda **_kwargs: {"block": True, "reasons": ["workspace mismatch"]},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.author_mutation_worktree,
|
||||||
|
"format_workspace_repo_membership_error",
|
||||||
|
lambda _assessment: "workspace mismatch (fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="workspace mismatch"):
|
||||||
|
server.verify_preflight_purity(task="acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_lease_acquire_requires_workflow_load_proof(monkeypatch):
|
||||||
|
sha = "a" * 40
|
||||||
|
monkeypatch.setattr(server, "_anti_stomp_in_test_mode", lambda: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"get_profile",
|
||||||
|
lambda: {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.review",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_actual_profile_role", lambda: "reviewer")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
lambda _worktree=None: {
|
||||||
|
"workspace_path": "/repo/branches/review-pr-762",
|
||||||
|
"canonical_repo_root": "/repo",
|
||||||
|
"process_project_root": "/repo",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
lambda _path: {
|
||||||
|
"current_branch": "master",
|
||||||
|
"head_sha": sha,
|
||||||
|
"porcelain_status": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.root_checkout_guard,
|
||||||
|
"resolve_remote_master_sha",
|
||||||
|
lambda _path: sha,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_current_master_parity",
|
||||||
|
lambda: {"startup_head": sha, "current_head": sha},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_local_git_remote_url", lambda _remote: None)
|
||||||
|
monkeypatch.setattr(server, "_load_stable_contamination_marker", lambda _remote: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_review_workflow_load_gate_reasons",
|
||||||
|
lambda: ["canonical review workflow proof missing"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="workflow"):
|
||||||
|
server._run_anti_stomp_preflight(
|
||||||
|
"acquire_reviewer_pr_lease",
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/repo/branches/review-pr-762",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_whoami_identity_mismatch_invalidates_preflight(monkeypatch):
|
||||||
|
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
|
||||||
|
monkeypatch.setattr(server, "_process_start_porcelain", "")
|
||||||
|
monkeypatch.setattr(server, "_preflight_whoami_called", False)
|
||||||
|
monkeypatch.setattr(server, "_preflight_capability_called", True)
|
||||||
|
monkeypatch.setattr(server, "_auth", lambda _host: "redacted")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"api_request",
|
||||||
|
lambda *_args, **_kwargs: {"login": "wrong-reviewer", "id": 7},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"get_profile",
|
||||||
|
lambda: {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"username": "sysadmin",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.review"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_seed_session_context", lambda **_kwargs: None)
|
||||||
|
monkeypatch.setattr(server.session_ctx, "mutation_context_audit_fields", lambda: {})
|
||||||
|
monkeypatch.setattr(server, "_reveal_endpoints", lambda: False)
|
||||||
|
|
||||||
|
result = server.gitea_whoami(remote="prgs")
|
||||||
|
|
||||||
|
assert result["identity_match"] is False
|
||||||
|
assert server._preflight_whoami_called is False
|
||||||
|
assert server._preflight_capability_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_denied_reviewer_profile_does_not_leave_capability_proof(monkeypatch):
|
||||||
|
profile = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.review",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
monkeypatch.setenv("GITEA_TEST_PORCELAIN", "")
|
||||||
|
monkeypatch.setattr(server, "_process_start_porcelain", "")
|
||||||
|
monkeypatch.setattr(server, "get_profile", lambda: profile)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.gitea_config,
|
||||||
|
"load_config",
|
||||||
|
lambda: {"profiles": {"prgs-author": profile}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server.gitea_config, "is_runtime_switching_enabled", lambda: False)
|
||||||
|
monkeypatch.setattr(server, "_authenticated_username", lambda _host: "jcwalker3")
|
||||||
|
monkeypatch.setattr(server, "_seed_session_context", lambda **_kwargs: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.session_ctx,
|
||||||
|
"assess_session_context",
|
||||||
|
lambda **_kwargs: {"block": False, "reasons": []},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.session_ctx,
|
||||||
|
"assess_identity_match",
|
||||||
|
lambda **_kwargs: {"block": False, "reasons": []},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.session_ctx,
|
||||||
|
"profile_allowed_for_remote",
|
||||||
|
lambda *_args, **_kwargs: {"block": False, "reasons": []},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server.session_ctx, "mutation_context_audit_fields", lambda: {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.role_session_router,
|
||||||
|
"assess_infra_stop",
|
||||||
|
lambda _root: {"infra_stop": False, "infra_stop_reasons": []},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_check_mcp_runtimes_diagnostics", lambda *_a: [])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_assess_stale_active_binding",
|
||||||
|
lambda **_kwargs: {"classification": "unbound"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "record_mutation_authority", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(server, "init_review_decision_lock", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr(server.capability_stop_terminal, "is_active", lambda: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server.capability_stop_terminal,
|
||||||
|
"sync_from_capability_result",
|
||||||
|
lambda _result: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||||
|
|
||||||
|
assert result["allowed_in_current_session"] is False
|
||||||
|
assert result["required_role_kind"] == "reviewer"
|
||||||
|
assert server._preflight_capability_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_and_foreign_lease_protections_remain_enforced():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
head = "a" * 40
|
||||||
|
moved_head = "b" * 40
|
||||||
|
body = reviewer_pr_lease.format_lease_body(
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
pr_number=762,
|
||||||
|
issue_number=605,
|
||||||
|
reviewer_identity="other-reviewer",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="foreign-session",
|
||||||
|
worktree="/repo/branches/review-pr-762",
|
||||||
|
phase="claimed",
|
||||||
|
candidate_head=head,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="c" * 40,
|
||||||
|
last_activity=now,
|
||||||
|
)
|
||||||
|
comments = [{"id": 10, "author": "other-reviewer", "body": body}]
|
||||||
|
|
||||||
|
acquire = reviewer_pr_lease.assess_acquire_lease(
|
||||||
|
comments,
|
||||||
|
pr_number=762,
|
||||||
|
reviewer_identity="sysadmin",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="my-session",
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
issue_number=605,
|
||||||
|
worktree="/repo/branches/review-pr-762-mine",
|
||||||
|
candidate_head=head,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="c" * 40,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
assert acquire["acquire_allowed"] is False
|
||||||
|
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
|
reviewer_pr_lease.record_session_lease(
|
||||||
|
{
|
||||||
|
"pr_number": 762,
|
||||||
|
"session_id": "foreign-session",
|
||||||
|
"candidate_head": head,
|
||||||
|
"comment_id": 10,
|
||||||
|
},
|
||||||
|
lease_provenance=merger_lease_adoption.build_lease_provenance(
|
||||||
|
source=merger_lease_adoption.SOURCE_ACQUIRE,
|
||||||
|
comment_id=10,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
gate = reviewer_pr_lease.assess_mutation_lease_gate(
|
||||||
|
pr_number=762,
|
||||||
|
comments=comments,
|
||||||
|
reviewer_identity="other-reviewer",
|
||||||
|
session_id="foreign-session",
|
||||||
|
mutation="approve",
|
||||||
|
live_head_sha=moved_head,
|
||||||
|
pinned_head_sha=head,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
|
|
||||||
|
assert gate["block"] is True
|
||||||
|
assert any("head changed" in reason for reason in gate["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_lease_role_gate_is_not_weakened():
|
||||||
|
result = anti_stomp_preflight.assess_anti_stomp_preflight(
|
||||||
|
task="acquire_reviewer_pr_lease",
|
||||||
|
profile_name="prgs-author",
|
||||||
|
profile_role="author",
|
||||||
|
required_role="reviewer",
|
||||||
|
required_permission="gitea.pr.comment",
|
||||||
|
allowed_operations=["gitea.read"],
|
||||||
|
check_repo=False,
|
||||||
|
check_root_checkout=False,
|
||||||
|
check_worktree=False,
|
||||||
|
check_stale_runtime=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["block"] is True
|
||||||
|
assert result["blocker_kind"] == anti_stomp_preflight.BLOCKER_WRONG_ROLE
|
||||||
@@ -19,6 +19,8 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import post_merge_moot_lease_gate as moot_gate # noqa: E402
|
||||||
import reviewer_pr_lease as leases # noqa: E402
|
import reviewer_pr_lease as leases # noqa: E402
|
||||||
from mcp_server import ( # noqa: E402
|
from mcp_server import ( # noqa: E402
|
||||||
gitea_acquire_reviewer_pr_lease,
|
gitea_acquire_reviewer_pr_lease,
|
||||||
@@ -30,6 +32,13 @@ MERGER_ENV = {
|
|||||||
"GITEA_PROFILE_NAME": "prgs-merger",
|
"GITEA_PROFILE_NAME": "prgs-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment",
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment",
|
||||||
}
|
}
|
||||||
|
# #745: applying the terminal marker is reconciler-owned.
|
||||||
|
RECONCILER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reconciler",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment,gitea.pr.close",
|
||||||
|
}
|
||||||
|
CLEANUP_TASK = moot_gate.CLEANUP_TASK
|
||||||
|
REPO_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
PR = 487
|
PR = 487
|
||||||
ISSUE = 485
|
ISSUE = 485
|
||||||
SESSION = "97274-676d20a825c4"
|
SESSION = "97274-676d20a825c4"
|
||||||
@@ -204,6 +213,8 @@ class TestAcquireToolRefusesMergedPR(unittest.TestCase):
|
|||||||
class TestCleanupTool(unittest.TestCase):
|
class TestCleanupTool(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
leases.clear_session_lease()
|
leases.clear_session_lease()
|
||||||
|
moot_gate._reset_for_testing()
|
||||||
|
self.addCleanup(moot_gate._reset_for_testing)
|
||||||
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@@ -223,23 +234,53 @@ class TestCleanupTool(unittest.TestCase):
|
|||||||
self.assertEqual(calls["post"], [])
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||||
|
@patch("mcp_server._repository_binding_block", return_value=None)
|
||||||
|
@patch("mcp_server._bound_repository_slug", return_value=REPO_SLUG)
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_apply_posts_released_marker_on_merged_pr(
|
def test_apply_posts_released_marker_on_merged_pr(
|
||||||
self, mock_api, _auth, _purity):
|
self, mock_api, _auth, _slug, _binding, _purity):
|
||||||
|
"""#745: apply is reconciler-only and needs a matching dry run first."""
|
||||||
side, calls = _api_side_effect(
|
side, calls = _api_side_effect(
|
||||||
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
||||||
mock_api.side_effect = side
|
mock_api.side_effect = side
|
||||||
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
with patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CLEANUP_TASK), \
|
||||||
|
patch.dict(os.environ, RECONCILER_ENV, clear=True):
|
||||||
|
gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=False, remote="prgs")
|
||||||
result = gitea_cleanup_post_merge_moot_lease(
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
pr_number=PR, apply=True, remote="prgs")
|
pr_number=PR, apply=True, remote="prgs")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"], result.get("reasons"))
|
||||||
self.assertTrue(result["cleanup_performed"])
|
self.assertTrue(result["cleanup_performed"])
|
||||||
self.assertEqual(result["released_comment_id"], 9999)
|
self.assertEqual(result["released_comment_id"], 9999)
|
||||||
self.assertEqual(len(calls["post"]), 1)
|
self.assertEqual(len(calls["post"]), 1)
|
||||||
self.assertIn("phase: released", calls["post"][0]["payload"]["body"])
|
self.assertIn("phase: released", calls["post"][0]["payload"]["body"])
|
||||||
self.assertIn("post-merge-moot", calls["post"][0]["payload"]["body"])
|
self.assertIn("post-merge-moot", calls["post"][0]["payload"]["body"])
|
||||||
|
|
||||||
|
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||||
|
@patch("mcp_server._repository_binding_block", return_value=None)
|
||||||
|
@patch("mcp_server._bound_repository_slug", return_value=REPO_SLUG)
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
def test_merger_can_no_longer_apply(
|
||||||
|
self, mock_api, _auth, _slug, _binding, _purity):
|
||||||
|
"""#745: holding gitea.pr.comment is no longer sufficient to apply."""
|
||||||
|
side, calls = _api_side_effect(
|
||||||
|
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
||||||
|
mock_api.side_effect = side
|
||||||
|
with patch.object(mcp_server, "_preflight_resolved_task",
|
||||||
|
CLEANUP_TASK), \
|
||||||
|
patch.dict(os.environ, MERGER_ENV, clear=True):
|
||||||
|
gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=False, remote="prgs")
|
||||||
|
result = gitea_cleanup_post_merge_moot_lease(
|
||||||
|
pr_number=PR, apply=True, remote="prgs")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["cleanup_performed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "wrong_role")
|
||||||
|
self.assertEqual(calls["post"], [])
|
||||||
|
|
||||||
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
|
|||||||
srv._preflight_capability_violation = False
|
srv._preflight_capability_violation = False
|
||||||
self._orig_in_test = srv._preflight_in_test_mode
|
self._orig_in_test = srv._preflight_in_test_mode
|
||||||
srv._preflight_in_test_mode = lambda: False
|
srv._preflight_in_test_mode = lambda: False
|
||||||
|
self._orig_resolved_task = srv._preflight_resolved_task
|
||||||
|
self._orig_resolved_role = srv._preflight_resolved_role
|
||||||
self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False)
|
self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False)
|
||||||
self._env_patch.start()
|
self._env_patch.start()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
srv._preflight_in_test_mode = self._orig_in_test
|
srv._preflight_in_test_mode = self._orig_in_test
|
||||||
|
# Preflight task/role are module-level; restore so test order cannot
|
||||||
|
# leak a resolved task into sibling cases.
|
||||||
|
srv._preflight_resolved_task = self._orig_resolved_task
|
||||||
|
srv._preflight_resolved_role = self._orig_resolved_role
|
||||||
self._env_patch.stop()
|
self._env_patch.stop()
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
@@ -81,26 +87,28 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
|
|||||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
return_value={"current_branch": "master"},
|
return_value={"current_branch": "master"},
|
||||||
)
|
)
|
||||||
def test_author_create_issue_still_blocked_on_control_checkout(
|
def test_author_non_create_issue_still_blocked_on_control_checkout(
|
||||||
self, _git, _get_all, _role, _ns, _prof, _auth
|
self, _git, _get_all, _role, _ns, _prof, _auth
|
||||||
):
|
):
|
||||||
|
"""Author mutations other than create_issue keep the branches-only rule.
|
||||||
|
|
||||||
|
#749/#750 sanctioned ``create_issue`` from a clean control checkout, and
|
||||||
|
#757 made the #604 anti-stomp guard honour that same decision — so
|
||||||
|
``create_issue`` is no longer a valid probe for this boundary. This case
|
||||||
|
previously asserted create_issue stayed blocked, which only held because
|
||||||
|
the bootstrap-blind #604 guard was overriding #750; that is precisely
|
||||||
|
the defect #757 fixed. ``lock_issue`` is issue-backed and post-ownership,
|
||||||
|
so it still requires a ``branches/`` worktree.
|
||||||
|
"""
|
||||||
srv._preflight_resolved_role = "author"
|
srv._preflight_resolved_role = "author"
|
||||||
|
srv._preflight_resolved_task = "lock_issue"
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
try:
|
try:
|
||||||
res = srv.gitea_create_issue(title="Test", body="body")
|
srv.verify_preflight_purity(remote="prgs", task="lock_issue")
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
self.assertIn("stable control checkout", str(exc))
|
self.assertIn("control checkout", str(exc).lower())
|
||||||
else:
|
else:
|
||||||
# #683 typed blocker at mutation entrypoint
|
self.fail("lock_issue must stay blocked on the control checkout")
|
||||||
self.assertFalse(res.get("success"))
|
|
||||||
blob = " ".join(res.get("reasons") or []) + str(
|
|
||||||
res.get("blocker_kind") or ""
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
"stable control checkout" in blob
|
|
||||||
or "missing_issue_worktree" in blob
|
|
||||||
or "control checkout" in blob.lower()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -79,22 +79,26 @@ class TestReviewerLeaseAcquire(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestReviewerLeaseFreshness(unittest.TestCase):
|
class TestReviewerLeaseFreshness(unittest.TestCase):
|
||||||
def test_stale_warning_after_30_minutes(self):
|
def test_stale_warning_at_half_the_sliding_window(self):
|
||||||
|
# #747 warns at half the 10-minute window, while the owner can still
|
||||||
|
# heartbeat and keep the lease.
|
||||||
lease = leases.parse_lease_comment(
|
lease = leases.parse_lease_comment(
|
||||||
_lease_comment(382, "session-a", minutes_ago=35)["body"]
|
_lease_comment(382, "session-a", minutes_ago=6)["body"]
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
leases.classify_lease_freshness(lease),
|
leases.classify_lease_freshness(lease),
|
||||||
"stale_warning",
|
"stale_warning",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_reclaimable_after_60_minutes(self):
|
def test_expired_once_the_sliding_window_lapses(self):
|
||||||
|
# Pre-#747 a 65-minute-idle lease was "reclaimable" and had to wait out
|
||||||
|
# a second timer. It is now simply expired and immediately takeable.
|
||||||
lease = leases.parse_lease_comment(
|
lease = leases.parse_lease_comment(
|
||||||
_lease_comment(382, "session-a", minutes_ago=65)["body"]
|
_lease_comment(382, "session-a", minutes_ago=65)["body"]
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
leases.classify_lease_freshness(lease),
|
leases.classify_lease_freshness(lease),
|
||||||
"reclaimable",
|
"expired",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -281,7 +285,10 @@ class TestReviewerLeaseHandoffDiagnose(unittest.TestCase):
|
|||||||
self.assertEqual(result["active_lease"]["comment_id"], 8647)
|
self.assertEqual(result["active_lease"]["comment_id"], 8647)
|
||||||
self.assertFalse(result["mutation_allowed"])
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
|
||||||
def test_foreign_reclaimable_release_expired(self):
|
def test_foreign_expired_release_expired(self):
|
||||||
|
# Pre-#747 this classified as "foreign_reclaimable" after the 60-minute
|
||||||
|
# activity band. Under the sliding TTL the lease is simply expired, and
|
||||||
|
# the sanctioned next action is unchanged.
|
||||||
reclaim = _lease_comment(
|
reclaim = _lease_comment(
|
||||||
592, "foreign-old", phase="claimed", minutes_ago=65
|
592, "foreign-old", phase="claimed", minutes_ago=65
|
||||||
)
|
)
|
||||||
@@ -293,7 +300,7 @@ class TestReviewerLeaseHandoffDiagnose(unittest.TestCase):
|
|||||||
current_reviewer_identity="sysadmin",
|
current_reviewer_identity="sysadmin",
|
||||||
proposed_worktree="branches/review-pr-592",
|
proposed_worktree="branches/review-pr-592",
|
||||||
)
|
)
|
||||||
self.assertEqual(result["classification"], "foreign_reclaimable")
|
self.assertEqual(result["classification"], "foreign_expired")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
result["next_action"], leases.NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
result["next_action"], leases.NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
)
|
)
|
||||||
|
|||||||
+37
-7
@@ -278,12 +278,19 @@ def assess_root_source_mutation(
|
|||||||
current_branch: str | None = None,
|
current_branch: str | None = None,
|
||||||
locked_issue_number: int | None = None,
|
locked_issue_number: int | None = None,
|
||||||
role_kind: str | None = None,
|
role_kind: str | None = None,
|
||||||
|
mutation_task: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Fail closed for diagnostic/source edits on the control/root checkout.
|
"""Fail closed for diagnostic/source edits on the control/root checkout.
|
||||||
|
|
||||||
Allowed only when the active workspace is under ``branches/``. Dirty
|
Allowed only when the active workspace is under ``branches/``. Dirty
|
||||||
tracked source/test files on the control checkout always block, including
|
tracked source/test files on the control checkout always block, including
|
||||||
temporary/diagnostic/test-only intent.
|
temporary/diagnostic/test-only intent.
|
||||||
|
|
||||||
|
#749: ``create_issue`` is a pure remote mutation with no local tree write.
|
||||||
|
When *mutation_task* is create_issue and the control checkout has no dirty
|
||||||
|
source/test files, the missing-worktree signal is suppressed so the
|
||||||
|
sanctioned bootstrap path can proceed. Dirty roots and every other task
|
||||||
|
still fail closed.
|
||||||
"""
|
"""
|
||||||
role = (role_kind or "").strip().lower()
|
role = (role_kind or "").strip().lower()
|
||||||
if role == "reconciler":
|
if role == "reconciler":
|
||||||
@@ -302,6 +309,7 @@ def assess_root_source_mutation(
|
|||||||
dirty_src = dirty_source_files(porcelain_status)
|
dirty_src = dirty_source_files(porcelain_status)
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
blocker_kind: str | None = None
|
blocker_kind: str | None = None
|
||||||
|
create_issue_bootstrap = False
|
||||||
|
|
||||||
if not under_branches and workspace == root and dirty_src:
|
if not under_branches and workspace == root and dirty_src:
|
||||||
# Root workspace with source dirtiness is unattributed root WIP.
|
# Root workspace with source dirtiness is unattributed root WIP.
|
||||||
@@ -313,32 +321,50 @@ def assess_root_source_mutation(
|
|||||||
)
|
)
|
||||||
blocker_kind = BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
blocker_kind = BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
||||||
|
|
||||||
|
# Lazy import keeps workflow_scope_guard free of circular import at module load.
|
||||||
|
try:
|
||||||
|
import create_issue_bootstrap as _cib
|
||||||
|
except Exception: # pragma: no cover - import always available in-tree
|
||||||
|
_cib = None
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not under_branches
|
not under_branches
|
||||||
and workspace == root
|
and workspace == root
|
||||||
and not dirty_src
|
and not dirty_src
|
||||||
and role == "author"
|
and role == "author"
|
||||||
):
|
):
|
||||||
# Explicit missing-worktree signal for force-on author entrypoints.
|
if _cib is not None and _cib.is_create_issue_task(mutation_task):
|
||||||
reasons.append(
|
# #749: clean-root create_issue is the sanctioned bootstrap path.
|
||||||
"author source/test mutation from the stable control checkout is "
|
create_issue_bootstrap = True
|
||||||
"forbidden; bind an issue-backed worktree under branches/ first"
|
else:
|
||||||
)
|
# Explicit missing-worktree signal for force-on author entrypoints.
|
||||||
blocker_kind = BLOCKER_MISSING_WORKTREE
|
reasons.append(
|
||||||
|
"author source/test mutation from the stable control checkout is "
|
||||||
|
"forbidden; bind an issue-backed worktree under branches/ first"
|
||||||
|
)
|
||||||
|
blocker_kind = BLOCKER_MISSING_WORKTREE
|
||||||
|
|
||||||
if reasons:
|
if reasons:
|
||||||
kind = blocker_kind or BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
kind = blocker_kind or BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
||||||
|
next_action = _NEXT_ACTIONS[kind]
|
||||||
|
if (
|
||||||
|
kind == BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
||||||
|
and _cib is not None
|
||||||
|
and _cib.is_create_issue_task(mutation_task)
|
||||||
|
):
|
||||||
|
next_action = _cib.EXACT_NEXT_ACTION_BOOTSTRAP
|
||||||
return {
|
return {
|
||||||
"proven": False,
|
"proven": False,
|
||||||
"block": True,
|
"block": True,
|
||||||
"blocker_kind": kind,
|
"blocker_kind": kind,
|
||||||
"exact_next_action": _NEXT_ACTIONS[kind],
|
"exact_next_action": next_action,
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
"dirty_source_files": dirty_src,
|
"dirty_source_files": dirty_src,
|
||||||
"workspace_path": workspace,
|
"workspace_path": workspace,
|
||||||
"canonical_repo_root": root,
|
"canonical_repo_root": root,
|
||||||
"under_branches": under_branches,
|
"under_branches": under_branches,
|
||||||
"locked_issue_number": locked_issue_number,
|
"locked_issue_number": locked_issue_number,
|
||||||
|
"create_issue_bootstrap": False,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"proven": True,
|
"proven": True,
|
||||||
@@ -351,6 +377,7 @@ def assess_root_source_mutation(
|
|||||||
"canonical_repo_root": root,
|
"canonical_repo_root": root,
|
||||||
"under_branches": under_branches,
|
"under_branches": under_branches,
|
||||||
"locked_issue_number": locked_issue_number,
|
"locked_issue_number": locked_issue_number,
|
||||||
|
"create_issue_bootstrap": create_issue_bootstrap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -365,6 +392,7 @@ def assess_production_mutation_guards(
|
|||||||
role_kind: str | None = None,
|
role_kind: str | None = None,
|
||||||
require_author_lock: bool = False,
|
require_author_lock: bool = False,
|
||||||
in_test_mode: bool = False,
|
in_test_mode: bool = False,
|
||||||
|
mutation_task: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Compose root + scope production guards when they must be active (#683)."""
|
"""Compose root + scope production guards when they must be active (#683)."""
|
||||||
if not production_guards_active(in_test_mode=in_test_mode):
|
if not production_guards_active(in_test_mode=in_test_mode):
|
||||||
@@ -385,6 +413,7 @@ def assess_production_mutation_guards(
|
|||||||
current_branch=current_branch,
|
current_branch=current_branch,
|
||||||
locked_issue_number=locked_issue_number,
|
locked_issue_number=locked_issue_number,
|
||||||
role_kind=role_kind,
|
role_kind=role_kind,
|
||||||
|
mutation_task=mutation_task,
|
||||||
)
|
)
|
||||||
if root_assess["block"]:
|
if root_assess["block"]:
|
||||||
return {**root_assess, "skipped": False}
|
return {**root_assess, "skipped": False}
|
||||||
@@ -408,6 +437,7 @@ def assess_production_mutation_guards(
|
|||||||
"skipped": False,
|
"skipped": False,
|
||||||
"root": root_assess,
|
"root": root_assess,
|
||||||
"scope": scope_assess,
|
"scope": scope_assess,
|
||||||
|
"create_issue_bootstrap": bool(root_assess.get("create_issue_bootstrap")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user