Compare commits
86
Commits
58d6b5844f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
300e8acd13 | ||
|
|
a002864a06 | ||
|
|
8e149e6cfa | ||
|
|
ed0e8c82de | ||
|
|
df3167488c | ||
|
|
ddc9b97d40 | ||
|
|
1d11cbab0f | ||
|
|
d17f055e86 | ||
|
|
52ded0ea71 | ||
|
|
296601647d | ||
|
|
702ceb2480 | ||
|
|
6c15aa88b3 | ||
|
|
c31df2130c | ||
|
|
ccfaa0ec0c | ||
|
|
ca76dacd73 | ||
|
|
ad13d872df | ||
|
|
0c2f45abb7 | ||
|
|
5ed2ab8a38 | ||
|
|
0568f44cb2 | ||
|
|
ab34280f90 | ||
|
|
9bf3acfef6 | ||
|
|
059ee77c1f | ||
|
|
bc968dd2e0 | ||
|
|
716fc21a0d | ||
|
|
edaeede250 | ||
|
|
5547399037 | ||
|
|
cb6ae0ca50 | ||
|
|
d12adabeb1 | ||
|
|
4b8a9219d8 | ||
|
|
e168978579 | ||
|
|
fcf6981b1b | ||
|
|
d181d499d3 | ||
|
|
b7a5284b98 | ||
|
|
6b568d8805 | ||
|
|
7f2b9f36de | ||
|
|
8a851eb87e | ||
|
|
ad59053cd7 | ||
|
|
854818e65a | ||
|
|
9d2c652ae8 | ||
|
|
adc61255b2 | ||
|
|
bde5c5fb20 | ||
|
|
a517655aad | ||
|
|
447595b72b | ||
|
|
5e0e474350 | ||
|
|
043df0f7df | ||
|
|
f858c1d1b2 | ||
|
|
08f67007c5 | ||
|
|
3edeba4d7f | ||
|
|
0425bf9a43 | ||
|
|
03c64a3219 | ||
|
|
eac7afe5cb | ||
|
|
e349839fd7 | ||
|
|
fdab6b6c69 | ||
|
|
277ec5269d | ||
|
|
b00e09a781 | ||
|
|
b05075fd25 | ||
|
|
61c3a57df5 | ||
|
|
c908ed6050 | ||
|
|
0a38d92382 | ||
|
|
fde95b9266 | ||
|
|
7ae5f3a541 | ||
|
|
22d0fdd251 | ||
|
|
15a8a76e99 | ||
|
|
a8d2087b4a | ||
|
|
61c0d73cd1 | ||
|
|
6a0d7bbef4 | ||
|
|
29d96c8946 | ||
|
|
8d8d2d8f81 | ||
|
|
0d8a2c2b1d | ||
|
|
11d1d2e99f | ||
|
|
3990fc684f | ||
|
|
daf60266d0 | ||
|
|
7e5eca08b3 | ||
|
|
39e27dc63f | ||
|
|
7eb4884658 | ||
|
|
3e761206e5 | ||
|
|
0801e2455b | ||
|
|
ec2433f13b | ||
|
|
899ef8ec7f | ||
|
|
f34ec86b90 | ||
|
|
c780ded653 | ||
|
|
dc899d23c8 | ||
|
|
943d40270e | ||
|
|
d936da5c87 | ||
|
|
29ffe1407f | ||
|
|
632c568865 |
@@ -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)
|
||||
+502
-11
@@ -18,10 +18,12 @@ after they exist as normal issues; this module never assigns incidents.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Sequence
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from control_plane_db import (
|
||||
ControlPlaneDB,
|
||||
@@ -40,6 +42,31 @@ OUTCOME_NEEDS_CONTROLLER = "needs_controller"
|
||||
OUTCOME_NO_SAFE = "no_safe_work"
|
||||
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
||||
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
||||
# #765: ownership could not be established for every remaining candidate.
|
||||
OUTCOME_OWNERSHIP_DEFECT = "allocator_ownership_defect"
|
||||
# #776: excluded issue still carries a live same-owner lease — resume or release.
|
||||
OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE = "blocked_by_excluded_own_lease"
|
||||
# #776: dry-run/apply candidate-set fingerprint mismatch (CAS drift).
|
||||
OUTCOME_CANDIDATE_SET_DRIFT = "candidate_set_drift"
|
||||
|
||||
# #765 skip reason code for work already claimed by a different controller.
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
||||
# #776: controller-supplied pre-rank exclusion.
|
||||
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
|
||||
|
||||
# Ownership verdicts for a live claim on a candidate (#765).
|
||||
OWNERSHIP_OWN = "own"
|
||||
OWNERSHIP_FOREIGN = "foreign"
|
||||
OWNERSHIP_UNKNOWN = "unknown"
|
||||
|
||||
# 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_REVIEWER = "reviewer"
|
||||
@@ -135,9 +162,76 @@ class SkipRecord:
|
||||
kind: str
|
||||
number: int
|
||||
reason: str
|
||||
reason_code: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"kind": self.kind, "number": self.number, "reason": self.reason}
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"number": self.number,
|
||||
"reason": self.reason,
|
||||
"reason_code": self.reason_code,
|
||||
}
|
||||
|
||||
|
||||
CONTROLLER_INSTANCE_ENV = "GITEA_CONTROLLER_INSTANCE_ID"
|
||||
|
||||
|
||||
def resolve_controller_instance_id(
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> str | None:
|
||||
"""Return this controller's stable identity, or ``None`` if undeclared.
|
||||
|
||||
Deliberately has no derived fallback. The obvious candidates are unsafe:
|
||||
``session_id`` is regenerated per invocation, and the MCP process pid is
|
||||
shared by every controller attached to the same daemon — two independent
|
||||
controllers really do report the same pid and profile. Guessing from either
|
||||
would let one controller adopt another's lease, which is the failure #765
|
||||
exists to prevent. When this returns ``None``, live claims are treated as
|
||||
unidentified: they are excluded from selection and reported as ownership
|
||||
defects rather than adopted.
|
||||
"""
|
||||
source = env if env is not None else os.environ
|
||||
return (source.get(CONTROLLER_INSTANCE_ENV) or "").strip() or None
|
||||
|
||||
|
||||
def classify_claim_ownership(
|
||||
claim: dict[str, Any] | None,
|
||||
*,
|
||||
session_id: str | None,
|
||||
controller_instance_id: str | None,
|
||||
) -> str | None:
|
||||
"""Classify a live claim as own / foreign / unknown ownership (#765).
|
||||
|
||||
Returns ``None`` when the candidate carries no live claim.
|
||||
|
||||
Session ids are regenerated per allocator invocation, so they only prove
|
||||
ownership positively (an exact match is certainly this session). The
|
||||
durable signal is ``controller_instance_id``. When either side lacks one,
|
||||
ownership is *unknown*: the allocator must not assume that a lease sharing
|
||||
the same profile belongs to this controller, so unknown is treated as
|
||||
not-ours for selection purposes and reported as an ownership defect.
|
||||
"""
|
||||
if not claim:
|
||||
return None
|
||||
claim_session = str(claim.get("session_id") or "").strip()
|
||||
claim_instance = str(claim.get("controller_instance_id") or "").strip()
|
||||
own_session = str(session_id or "").strip()
|
||||
own_instance = str(controller_instance_id or "").strip()
|
||||
|
||||
if claim_session and own_session and claim_session == own_session:
|
||||
return OWNERSHIP_OWN
|
||||
if claim_instance and own_instance:
|
||||
return (
|
||||
OWNERSHIP_OWN if claim_instance == own_instance else OWNERSHIP_FOREIGN
|
||||
)
|
||||
if not claim_instance and not own_instance:
|
||||
# Neither side declares a controller identity. The session ids differ
|
||||
# (an exact match returned OWN above), so this is simply someone
|
||||
# else's lease: foreign, and we wait rather than adopt.
|
||||
return OWNERSHIP_FOREIGN
|
||||
# Exactly one side is identified, so the two cannot be compared: this may
|
||||
# or may not be our own task under a different session id. Never guess.
|
||||
return OWNERSHIP_UNKNOWN
|
||||
|
||||
|
||||
def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
|
||||
@@ -194,8 +288,16 @@ def classify_skip(
|
||||
*,
|
||||
role: str,
|
||||
terminal_pr: int | None,
|
||||
claim_ownership: str | None = None,
|
||||
) -> str | None:
|
||||
"""Return skip reason, or None if candidate is selectable for *role*."""
|
||||
"""Return skip reason, or None if candidate is selectable for *role*.
|
||||
|
||||
*claim_ownership* (#765) is the verdict from
|
||||
:func:`classify_claim_ownership` for this candidate's live claim. Foreign
|
||||
and unknown claims are excluded so one session's in-progress task can never
|
||||
blockade the queue for a different controller; ``own`` stays selectable so
|
||||
a controller can resume its own work.
|
||||
"""
|
||||
if c.state in ("merged", "closed"):
|
||||
return f"{c.kind}#{c.number} is {c.state}; never assign"
|
||||
if c.blocked or "status:blocked" in c.labels:
|
||||
@@ -205,6 +307,16 @@ def classify_skip(
|
||||
c.dependency_reason
|
||||
or f"{c.kind}#{c.number} has unmet dependencies"
|
||||
)
|
||||
if claim_ownership in (OWNERSHIP_FOREIGN, OWNERSHIP_UNKNOWN):
|
||||
detail = (
|
||||
"owned by another controller instance"
|
||||
if claim_ownership == OWNERSHIP_FOREIGN
|
||||
else "owner could not be identified; never adopt on a guess"
|
||||
)
|
||||
return (
|
||||
f"{c.kind}#{c.number} {SKIP_CLAIMED_BY_OTHER_SESSION}: "
|
||||
f"active lease {detail}"
|
||||
)
|
||||
if c.already_claimed_elsewhere:
|
||||
return f"{c.kind}#{c.number} already claimed elsewhere"
|
||||
if c.kind == "pr" and not (c.head_sha or "").strip():
|
||||
@@ -242,13 +354,136 @@ def classify_skip(
|
||||
|
||||
|
||||
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(
|
||||
candidates,
|
||||
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
|
||||
)
|
||||
|
||||
|
||||
def _require_strict_int(value: Any, *, field: str) -> int:
|
||||
"""Parse an issue number; reject bools and non-integers (#776)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"{field} must be an integer (booleans and non-integers rejected; "
|
||||
f"got {type(value).__name__})"
|
||||
)
|
||||
return int(value)
|
||||
|
||||
|
||||
def normalize_exclude_issue_numbers(
|
||||
exclude_issue_numbers: Any = None,
|
||||
) -> list[int]:
|
||||
"""Normalize controller-supplied exclusions to a sorted unique int list (#776).
|
||||
|
||||
``None`` / omitted → empty list (existing behavior). Accepts a list/tuple of
|
||||
integers. Rejects scalars, bools-as-ints, nested structures, and strings.
|
||||
"""
|
||||
if exclude_issue_numbers is None:
|
||||
return []
|
||||
if isinstance(exclude_issue_numbers, (str, bytes)) or not isinstance(
|
||||
exclude_issue_numbers, (list, tuple)
|
||||
):
|
||||
raise ValueError(
|
||||
"exclude_issue_numbers must be a list of integers "
|
||||
f"(got {type(exclude_issue_numbers).__name__})"
|
||||
)
|
||||
out: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for idx, raw in enumerate(exclude_issue_numbers):
|
||||
num = _require_strict_int(raw, field=f"exclude_issue_numbers[{idx}]")
|
||||
if num not in seen:
|
||||
seen.add(num)
|
||||
out.append(num)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def candidate_set_fingerprint(
|
||||
candidates: Sequence[WorkCandidate],
|
||||
*,
|
||||
exclude_issue_numbers: Sequence[int] | None = None,
|
||||
) -> str:
|
||||
"""Stable CAS fingerprint of normalized candidate set + exclusions (#776 AC4)."""
|
||||
payload = {
|
||||
"candidates": sorted(
|
||||
({"kind": c.kind, "number": int(c.number)} for c in candidates),
|
||||
key=lambda x: (x["kind"], x["number"]),
|
||||
),
|
||||
"exclude_issue_numbers": list(
|
||||
normalize_exclude_issue_numbers(exclude_issue_numbers)
|
||||
),
|
||||
}
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_candidates_payload(raw: Any) -> list[WorkCandidate]:
|
||||
"""Decode MCP ``candidates_json`` from list or JSON string (#776 AC3).
|
||||
|
||||
Accepts:
|
||||
* an already-decoded ``list`` of candidate dicts (native MCP transport);
|
||||
* a valid JSON string that decodes to such a list (backward compatible).
|
||||
|
||||
Rejects malformed JSON, scalars, non-list containers, invalid records,
|
||||
booleans-as-integers, and unsupported types with fail-closed ``ValueError``.
|
||||
"""
|
||||
if raw is None:
|
||||
raise ValueError("candidates_json is empty")
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
try:
|
||||
raw = raw.decode("utf-8")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError(
|
||||
f"candidates_json bytes are not valid utf-8: {exc}"
|
||||
) from exc
|
||||
if isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
raise ValueError("candidates_json string is empty")
|
||||
try:
|
||||
decoded = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"malformed candidates_json JSON: {exc.msg} at pos {exc.pos}"
|
||||
) from exc
|
||||
raw = decoded
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(
|
||||
"candidates_json must be a JSON list (or already-decoded list); "
|
||||
f"got {type(raw).__name__}"
|
||||
)
|
||||
candidates: list[WorkCandidate] = []
|
||||
for idx, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(
|
||||
f"invalid candidate record at index {idx}: expected object, "
|
||||
f"got {type(item).__name__}"
|
||||
)
|
||||
try:
|
||||
candidates.append(candidate_from_dict(item))
|
||||
except (KeyError, TypeError, ValueError, InvalidWorkKindError) as exc:
|
||||
raise ValueError(
|
||||
f"invalid candidate record at index {idx}: {exc}"
|
||||
) from exc
|
||||
return candidates
|
||||
|
||||
|
||||
def allocate_next_work(
|
||||
db: ControlPlaneDB,
|
||||
*,
|
||||
@@ -262,12 +497,22 @@ def allocate_next_work(
|
||||
profile_name: str | None = None,
|
||||
username: str | None = None,
|
||||
lease_ttl_seconds: int | None = None,
|
||||
controller_instance_id: str | None = None,
|
||||
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
|
||||
exclude_issue_numbers: Sequence[int] | None = None,
|
||||
expected_candidate_set_fingerprint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Select and optionally reserve the next work unit via control-plane DB.
|
||||
|
||||
*apply=False* (default): dry-run selection only — no lease/assignment.
|
||||
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
||||
|
||||
*exclude_issue_numbers* (#776): numbers removed before ranking. Omitted /
|
||||
empty preserves prior behavior.
|
||||
|
||||
*expected_candidate_set_fingerprint* (#776 AC4): when set on apply, rejects
|
||||
material candidate-set drift vs a prior dry-run.
|
||||
|
||||
Never uses file locks or comment-only leases as the assignment source.
|
||||
"""
|
||||
if db is None:
|
||||
@@ -303,6 +548,7 @@ def allocate_next_work(
|
||||
role=role_norm,
|
||||
profile=profile_name,
|
||||
pid=os.getpid(),
|
||||
controller_instance_id=controller_instance_id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — surface structured
|
||||
return {
|
||||
@@ -344,30 +590,238 @@ def allocate_next_work(
|
||||
}
|
||||
terminal_pr = int(terminal["terminal_pr"]) if terminal else None
|
||||
|
||||
# #765: live claims exclude work owned by a *different* controller before
|
||||
# ranking, so one session's in-progress task cannot blockade the queue.
|
||||
# #776 AC5: load live claims in this call path immediately before selection
|
||||
# (and before apply reserve) so ownership is never stale within the
|
||||
# allocation attempt. Test callers may inject *claims* explicitly.
|
||||
if claims is None:
|
||||
try:
|
||||
claims = db.list_active_claims(remote=remote, org=org, repo=repo)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [
|
||||
f"active claim lookup failed: {exc} (fail closed, #765)"
|
||||
],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
|
||||
try:
|
||||
exclude_nums = normalize_exclude_issue_numbers(exclude_issue_numbers)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"apply": bool(apply),
|
||||
"reasons": [
|
||||
f"invalid exclude_issue_numbers: {exc} (fail closed, #776)"
|
||||
],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
exclude_set = set(exclude_nums)
|
||||
cas_fp = candidate_set_fingerprint(
|
||||
candidates, exclude_issue_numbers=exclude_nums
|
||||
)
|
||||
expected_fp = (expected_candidate_set_fingerprint or "").strip() or None
|
||||
if expected_fp and expected_fp != cas_fp:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_CANDIDATE_SET_DRIFT,
|
||||
"apply": bool(apply),
|
||||
"reasons": [
|
||||
"candidate-set fingerprint drift: apply rejected rather than "
|
||||
"silently leasing a different candidate (#776 AC4)"
|
||||
],
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"expected_candidate_set_fingerprint": expected_fp,
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
skipped: list[SkipRecord] = []
|
||||
ordered = sort_candidates(list(candidates))
|
||||
claims_excluded: list[dict[str, Any]] = []
|
||||
ownership_defects: list[dict[str, Any]] = []
|
||||
controller_excluded: list[dict[str, Any]] = []
|
||||
|
||||
# #776 AC2: remove excluded numbers *before* ranking / selection / lease.
|
||||
rankable: list[WorkCandidate] = []
|
||||
for c in candidates:
|
||||
if int(c.number) in exclude_set:
|
||||
reason = (
|
||||
f"{c.kind}#{c.number} {SKIP_EXCLUDED_BY_CONTROLLER}: "
|
||||
"controller pre-rank exclusion"
|
||||
)
|
||||
skipped.append(
|
||||
SkipRecord(
|
||||
c.kind,
|
||||
c.number,
|
||||
reason,
|
||||
SKIP_EXCLUDED_BY_CONTROLLER,
|
||||
)
|
||||
)
|
||||
controller_excluded.append(
|
||||
{
|
||||
"kind": c.kind,
|
||||
"number": c.number,
|
||||
"reason_code": SKIP_EXCLUDED_BY_CONTROLLER,
|
||||
}
|
||||
)
|
||||
# #776 AC5: same-owner live lease on an excluded issue is a
|
||||
# structured resume/release blocker, never a silent strand.
|
||||
claim = claims.get((c.kind, int(c.number))) if claims else None
|
||||
ownership = classify_claim_ownership(
|
||||
claim,
|
||||
session_id=session_id,
|
||||
controller_instance_id=controller_instance_id,
|
||||
)
|
||||
if ownership == OWNERSHIP_OWN and claim:
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
|
||||
"apply": bool(apply),
|
||||
"role": role_norm,
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": None,
|
||||
"expected_role_next": None,
|
||||
"reasons": [
|
||||
f"{c.kind}#{c.number} is excluded_by_controller but "
|
||||
"carries a live same-owner lease; resume or release "
|
||||
"that lease before allocating other work (#776 AC5)"
|
||||
],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"blocked_lease": {
|
||||
"kind": c.kind,
|
||||
"number": c.number,
|
||||
"lease_id": claim.get("lease_id"),
|
||||
"owner_session_id": claim.get("session_id"),
|
||||
"owner_controller_instance_id": claim.get(
|
||||
"controller_instance_id"
|
||||
),
|
||||
"expires_at": claim.get("expires_at"),
|
||||
"safe_next_action": (
|
||||
"resume the same-owner lease or release it, then "
|
||||
"re-run allocation without stranding the excluded "
|
||||
"issue"
|
||||
),
|
||||
},
|
||||
}
|
||||
continue
|
||||
rankable.append(c)
|
||||
|
||||
ordered = sort_candidates(rankable)
|
||||
selected: WorkCandidate | None = None
|
||||
for c in ordered:
|
||||
reason = classify_skip(c, role=role_norm, terminal_pr=terminal_pr)
|
||||
claim = claims.get((c.kind, int(c.number))) if claims else None
|
||||
ownership = classify_claim_ownership(
|
||||
claim,
|
||||
session_id=session_id,
|
||||
controller_instance_id=controller_instance_id,
|
||||
)
|
||||
reason = classify_skip(
|
||||
c,
|
||||
role=role_norm,
|
||||
terminal_pr=terminal_pr,
|
||||
claim_ownership=ownership,
|
||||
)
|
||||
if reason:
|
||||
skipped.append(SkipRecord(c.kind, c.number, reason))
|
||||
is_claim_skip = SKIP_CLAIMED_BY_OTHER_SESSION in reason
|
||||
skipped.append(
|
||||
SkipRecord(
|
||||
c.kind,
|
||||
c.number,
|
||||
reason,
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION if is_claim_skip else None,
|
||||
)
|
||||
)
|
||||
if is_claim_skip and claim:
|
||||
record = {
|
||||
"kind": c.kind,
|
||||
"number": c.number,
|
||||
"ownership": ownership,
|
||||
"lease_id": claim.get("lease_id"),
|
||||
"owner_session_id": claim.get("session_id"),
|
||||
"owner_controller_instance_id": claim.get(
|
||||
"controller_instance_id"
|
||||
),
|
||||
"expires_at": claim.get("expires_at"),
|
||||
}
|
||||
claims_excluded.append(record)
|
||||
if ownership == OWNERSHIP_UNKNOWN:
|
||||
ownership_defects.append(record)
|
||||
continue
|
||||
selected = c
|
||||
break
|
||||
|
||||
if selected is None:
|
||||
# If terminal lock blocks all review work, surface that explicitly.
|
||||
owner_session_id: str | None = None
|
||||
if terminal_pr is not None and role_norm in (ROLE_REVIEWER, ROLE_MERGER):
|
||||
outcome = OUTCOME_BLOCKED_TERMINAL
|
||||
reasons = [
|
||||
f"no safe work for role '{role_norm}': active terminal-review "
|
||||
f"lock on PR #{terminal_pr} (resolve terminal path first, #332/#600)"
|
||||
]
|
||||
elif ownership_defects:
|
||||
# #765: every remaining candidate is claimed and at least one owner
|
||||
# could not be identified. Report the defect; never adopt.
|
||||
outcome = OUTCOME_OWNERSHIP_DEFECT
|
||||
reasons = [
|
||||
f"no safe assignable work for role '{role_norm}': "
|
||||
f"{len(ownership_defects)} candidate(s) carry an active lease "
|
||||
"whose controller ownership could not be established. Record a "
|
||||
"controller_instance_id on those sessions; the allocator will "
|
||||
"not assume a shared profile means shared ownership (#765)."
|
||||
]
|
||||
elif claims_excluded:
|
||||
outcome = OUTCOME_WAIT
|
||||
reasons = [
|
||||
f"no unclaimed work for role '{role_norm}': "
|
||||
f"{len(claims_excluded)} candidate(s) are actively claimed by "
|
||||
"another controller. Waiting; their leases are not adopted (#765)."
|
||||
]
|
||||
# Preserve the pre-#765 wait contract: name the blocking owner.
|
||||
owner_session_id = claims_excluded[0].get("owner_session_id")
|
||||
elif controller_excluded and not ordered:
|
||||
# #776 AC7: every candidate was controller-excluded → wait / no lease.
|
||||
outcome = OUTCOME_WAIT
|
||||
reasons = [
|
||||
f"no assignable work for role '{role_norm}': all "
|
||||
f"{len(controller_excluded)} candidate(s) were removed by "
|
||||
f"{SKIP_EXCLUDED_BY_CONTROLLER} before ranking; no assignment "
|
||||
"or lease created (#776 AC7)"
|
||||
]
|
||||
else:
|
||||
outcome = OUTCOME_NO_SAFE
|
||||
reasons = [
|
||||
f"no safe assignable work for role '{role_norm}' "
|
||||
f"among {len(ordered)} candidates"
|
||||
f"among {len(ordered)} rankable candidates "
|
||||
f"({len(controller_excluded)} controller-excluded)"
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
@@ -389,6 +843,13 @@ def allocate_next_work(
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"owner_session_id": owner_session_id,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
@@ -435,6 +896,12 @@ def allocate_next_work(
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
@@ -558,6 +1025,12 @@ def allocate_next_work(
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"controller_instance_id": controller_instance_id,
|
||||
"claims_excluded": list(claims_excluded),
|
||||
"ownership_defects": list(ownership_defects),
|
||||
"controller_excluded": list(controller_excluded),
|
||||
"exclude_issue_numbers": list(exclude_nums),
|
||||
"candidate_set_fingerprint": cas_fp,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
@@ -589,14 +1062,32 @@ def _next_command(role: str, c: WorkCandidate) -> str:
|
||||
|
||||
|
||||
def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
||||
"""Build a WorkCandidate from a plain dict (tests / MCP inventory)."""
|
||||
"""Build a WorkCandidate from a plain dict (tests / MCP inventory).
|
||||
|
||||
#776: reject booleans-as-integers and non-int numbers fail-closed.
|
||||
"""
|
||||
if "number" not in data:
|
||||
raise KeyError("number")
|
||||
number = _require_strict_int(data["number"], field="number")
|
||||
priority_raw = data.get("priority") or 0
|
||||
if isinstance(priority_raw, bool) or not isinstance(priority_raw, (int, float)):
|
||||
# Allow numeric strings only for priority? Keep strict for bools.
|
||||
if isinstance(priority_raw, str) and priority_raw.strip().lstrip("-").isdigit():
|
||||
priority = int(priority_raw)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"priority must be numeric (booleans rejected; "
|
||||
f"got {type(priority_raw).__name__})"
|
||||
)
|
||||
else:
|
||||
priority = int(priority_raw)
|
||||
return WorkCandidate(
|
||||
kind=str(data.get("kind") or "issue"),
|
||||
number=int(data["number"]),
|
||||
number=number,
|
||||
state=str(data.get("state") or "open"),
|
||||
labels=tuple(data.get("labels") or ()),
|
||||
title=str(data.get("title") or ""),
|
||||
priority=int(data.get("priority") or 0),
|
||||
priority=priority,
|
||||
head_sha=data.get("head_sha"),
|
||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||
approval_on_current_head=bool(data.get("approval_on_current_head")),
|
||||
|
||||
+21
-2
@@ -33,6 +33,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
import author_mutation_worktree
|
||||
import create_issue_bootstrap
|
||||
import master_parity_gate
|
||||
import remote_repo_guard
|
||||
import root_checkout_guard
|
||||
@@ -76,6 +77,7 @@ MUTATION_TASKS = frozenset({
|
||||
"create_issue",
|
||||
"comment_issue",
|
||||
"close_issue",
|
||||
"edit_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"set_issue_labels",
|
||||
@@ -354,6 +356,7 @@ def assess_anti_stomp_preflight(
|
||||
remote_master_sha: str | None = None,
|
||||
check_root_checkout: bool = True,
|
||||
check_worktree: bool = True,
|
||||
create_issue_bootstrap_assessment: dict[str, Any] | None = None,
|
||||
# stale runtime (master parity)
|
||||
startup_head: str | None = None,
|
||||
current_code_head: str | None = None,
|
||||
@@ -584,12 +587,28 @@ def assess_anti_stomp_preflight(
|
||||
project_root=project_root,
|
||||
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"] = {
|
||||
"block": bool(wt.get("block")),
|
||||
"block": bool(wt.get("block")) and not bootstrap_waived,
|
||||
"reasons": list(wt.get("reasons") or []),
|
||||
"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(
|
||||
_blocker(
|
||||
BLOCKER_WRONG_WORKTREE,
|
||||
|
||||
+544
-3
@@ -1,7 +1,15 @@
|
||||
"""Branches-only author mutation worktree guard (#274).
|
||||
"""Branches-only author mutation worktree guard (#274) with durable resolution (#618).
|
||||
|
||||
Author/coder mutations must run from a session-owned worktree under the
|
||||
project's ``branches/`` directory, never from the stable control checkout.
|
||||
|
||||
#618 durable resolution:
|
||||
- Prefer an explicit validated ``worktree_path`` argument.
|
||||
- Else derive the workspace from the active author issue lock's worktree.
|
||||
- Env bindings (``GITEA_ACTIVE_WORKTREE`` / ``GITEA_AUTHOR_WORKTREE``) may bind
|
||||
when present and valid.
|
||||
- Author mutations never silently fall back to the control checkout or master.
|
||||
- Missing configured bindings fail closed with a clear operator recovery action.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,6 +23,18 @@ AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
||||
# via namespace_workspace_binding (#510).
|
||||
|
||||
BOUND_WORKTREE_MISSING = "bound_worktree_missing"
|
||||
BOUND_WORKTREE_MISSING_MESSAGE = (
|
||||
"bound worktree missing; operator must recreate or repoint the worktree "
|
||||
"and reconnect"
|
||||
)
|
||||
OPERATOR_RECOVERY_RECREATE_REPOINT = (
|
||||
"Recreate the worktree under branches/ (scripts/worktree-start or "
|
||||
"git worktree add), set GITEA_AUTHOR_WORKTREE / GITEA_ACTIVE_WORKTREE "
|
||||
"to that path (or pass worktree_path on mutation tools), keep the control "
|
||||
"checkout clean on master, then reconnect the author MCP session and re-run."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
@@ -45,7 +65,11 @@ def resolve_mutation_workspace(
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the workspace path inspected before author mutations."""
|
||||
"""Resolve the workspace path inspected before author mutations.
|
||||
|
||||
Legacy helper: returns the first non-empty candidate path. Prefer
|
||||
:func:`resolve_durable_author_worktree` for mutation guards (#618).
|
||||
"""
|
||||
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
@@ -231,4 +255,521 @@ def format_author_mutation_worktree_error(assessment: dict) -> str:
|
||||
f"Branches-only mutation guard (#274): {reasons}. "
|
||||
f"project root: {root}; workspace: {workspace}. "
|
||||
"Create a session-owned worktree under branches/ before mutating."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #618 durable author worktree resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _abs_real(path: str) -> str:
|
||||
return os.path.realpath(os.path.abspath((path or "").strip()))
|
||||
|
||||
|
||||
def assess_path_traversal_safety(
|
||||
*,
|
||||
path: str,
|
||||
canonical_repo_root: str,
|
||||
) -> dict:
|
||||
"""Fail closed on traversal/symlink escapes outside the target repository.
|
||||
|
||||
Uses ``realpath`` so intermediate symlinks cannot walk outside
|
||||
``canonical_repo_root``. Author mutation workspaces must also land under
|
||||
``branches/`` of that root (enforced separately by the branches-only guard).
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
raw = (path or "").strip()
|
||||
if not raw:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": ["worktree path is empty (fail closed)"],
|
||||
"workspace_path": None,
|
||||
"canonical_repo_root": os.path.realpath(canonical_repo_root),
|
||||
}
|
||||
if "\x00" in raw:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": ["worktree path contains a null byte (fail closed)"],
|
||||
"workspace_path": raw,
|
||||
"canonical_repo_root": os.path.realpath(canonical_repo_root),
|
||||
}
|
||||
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
# Resolve without requiring existence first: abspath then realpath of parents.
|
||||
abs_path = os.path.abspath(raw)
|
||||
try:
|
||||
real = os.path.realpath(abs_path)
|
||||
except OSError as exc:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [f"worktree path could not be resolved safely: {exc}"],
|
||||
"workspace_path": abs_path,
|
||||
"canonical_repo_root": root,
|
||||
}
|
||||
|
||||
root_norm = _normalize_path(root)
|
||||
real_norm = _normalize_path(real)
|
||||
if real_norm != root_norm and not real_norm.startswith(f"{root_norm}/"):
|
||||
reasons.append(
|
||||
f"worktree path '{real}' escapes canonical repository root '{root}' "
|
||||
"(traversal/symlink safety, fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"workspace_path": real,
|
||||
"canonical_repo_root": root,
|
||||
}
|
||||
|
||||
|
||||
def list_git_worktree_paths(canonical_repo_root: str) -> list[str]:
|
||||
"""Return realpaths registered in ``git worktree list --porcelain``."""
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
paths: list[str] = []
|
||||
for line in (res.stdout or "").splitlines():
|
||||
if line.startswith("worktree "):
|
||||
raw = line[len("worktree ") :].strip()
|
||||
if raw:
|
||||
paths.append(os.path.realpath(raw))
|
||||
return paths
|
||||
|
||||
|
||||
def path_in_git_worktree_list(path: str, canonical_repo_root: str) -> bool | None:
|
||||
"""True/False when inventory is available; None when git inventory fails.
|
||||
|
||||
An empty inventory with a working git root is treated as inconclusive
|
||||
(``None``) so unit tests and partial sandboxes are not false-negative
|
||||
blocked when ``git worktree list`` is mocked/unavailable.
|
||||
"""
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
inventory: list[str] = []
|
||||
for line in (res.stdout or "").splitlines():
|
||||
if line.startswith("worktree "):
|
||||
raw = line[len("worktree ") :].strip()
|
||||
if raw:
|
||||
inventory.append(os.path.realpath(raw))
|
||||
if not inventory:
|
||||
return None
|
||||
return os.path.realpath(path) in inventory
|
||||
|
||||
|
||||
def assess_bound_worktree_existence(
|
||||
*,
|
||||
configured_path: str,
|
||||
binding_source: str,
|
||||
canonical_repo_root: str | None = None,
|
||||
role_kind: str = "author",
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when a configured role-bound worktree path is missing (#618)."""
|
||||
raw = (configured_path or "").strip()
|
||||
if not raw:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": None,
|
||||
"in_git_worktree_list": None,
|
||||
"inspected_git_root": None,
|
||||
"reasons": [],
|
||||
"configured_path": None,
|
||||
"binding_source": binding_source,
|
||||
"role_kind": role_kind,
|
||||
"profile_name": profile_name,
|
||||
"blocker_kind": None,
|
||||
"operator_recovery": None,
|
||||
}
|
||||
|
||||
try:
|
||||
real = _abs_real(raw)
|
||||
except OSError:
|
||||
real = os.path.abspath(raw)
|
||||
|
||||
path_exists = os.path.isdir(real)
|
||||
in_list: bool | None = None
|
||||
inspected_git_root: str | None = None
|
||||
root = (canonical_repo_root or "").strip()
|
||||
if root:
|
||||
in_list = path_in_git_worktree_list(real, root) if path_exists else False
|
||||
if path_exists:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", real, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
inspected_git_root = (res.stdout or "").strip() or None
|
||||
except Exception:
|
||||
inspected_git_root = None
|
||||
|
||||
if path_exists:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": True,
|
||||
"in_git_worktree_list": in_list,
|
||||
"inspected_git_root": inspected_git_root,
|
||||
"reasons": [],
|
||||
"configured_path": real,
|
||||
"binding_source": binding_source,
|
||||
"role_kind": role_kind,
|
||||
"profile_name": profile_name,
|
||||
"blocker_kind": None,
|
||||
"operator_recovery": None,
|
||||
}
|
||||
|
||||
reasons = [
|
||||
BOUND_WORKTREE_MISSING_MESSAGE,
|
||||
(
|
||||
f"role/profile '{profile_name or role_kind}' binding via {binding_source} "
|
||||
f"points to '{real}' which does not exist on disk"
|
||||
),
|
||||
f"path_exists=false; in_git_worktree_list={in_list}; inspected_git_root=null",
|
||||
]
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"bound_worktree_missing": True,
|
||||
"path_exists": False,
|
||||
"in_git_worktree_list": False if in_list is not None else False,
|
||||
"inspected_git_root": None,
|
||||
"reasons": reasons,
|
||||
"configured_path": real,
|
||||
"binding_source": binding_source,
|
||||
"role_kind": role_kind,
|
||||
"profile_name": profile_name,
|
||||
"blocker_kind": BOUND_WORKTREE_MISSING,
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
}
|
||||
|
||||
|
||||
def format_bound_worktree_missing_error(assessment: dict) -> str:
|
||||
"""Canonical operator-facing message for a missing author worktree binding."""
|
||||
reasons = list(assessment.get("reasons") or [BOUND_WORKTREE_MISSING_MESSAGE])
|
||||
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
|
||||
profile = assessment.get("profile_name") or assessment.get("role_kind") or "author"
|
||||
source = (
|
||||
assessment.get("binding_source")
|
||||
or assessment.get("workspace_binding_source")
|
||||
or "unknown binding"
|
||||
)
|
||||
path = (
|
||||
assessment.get("configured_path")
|
||||
or assessment.get("workspace_path")
|
||||
or "(unknown)"
|
||||
)
|
||||
return (
|
||||
f"Author worktree binding unhealthy (#618): {'; '.join(reasons)}. "
|
||||
f"role/profile: {profile}; binding_source: {source}; configured_path: {path}. "
|
||||
f"Operator recovery: {recovery}"
|
||||
)
|
||||
|
||||
|
||||
def assess_lock_worktree_ownership(
|
||||
*,
|
||||
workspace_path: str,
|
||||
session_lock_worktree: str | None,
|
||||
) -> dict:
|
||||
"""When a live lock records a worktree, mutation workspace must match it."""
|
||||
locked = (session_lock_worktree or "").strip()
|
||||
if not locked:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"workspace_path": os.path.realpath(workspace_path) if workspace_path else None,
|
||||
"lock_worktree_path": None,
|
||||
}
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
locked_real = os.path.realpath(locked)
|
||||
if workspace != locked_real:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
f"active author issue lock worktree '{locked_real}' does not match "
|
||||
f"mutation workspace '{workspace}' (lock ownership, fail closed)"
|
||||
],
|
||||
"workspace_path": workspace,
|
||||
"lock_worktree_path": locked_real,
|
||||
}
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"workspace_path": workspace,
|
||||
"lock_worktree_path": locked_real,
|
||||
}
|
||||
|
||||
|
||||
def resolve_durable_author_worktree(
|
||||
*,
|
||||
worktree_path: str | None = None,
|
||||
worktree: str | None = None,
|
||||
process_project_root: str,
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
canonical_repo_root: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
validate: bool = True,
|
||||
) -> dict:
|
||||
"""Resolve author mutation workspace without silent control-checkout fallback (#618).
|
||||
|
||||
Candidate priority:
|
||||
1. explicit ``worktree_path`` argument
|
||||
2. ``worktree`` argument
|
||||
3. ``GITEA_ACTIVE_WORKTREE``
|
||||
4. ``GITEA_AUTHOR_WORKTREE``
|
||||
5. active author issue lock ``worktree_path``
|
||||
6. process project root **only** when it is already under ``branches/``
|
||||
|
||||
Configured bindings that point at a missing path fail closed immediately
|
||||
(no demotion to the control checkout). Validation (when *validate*) covers
|
||||
existence, traversal/symlink safety, repository identity, branches/
|
||||
containment, and lock ownership.
|
||||
"""
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
canonical = os.path.realpath(canonical_repo_root or process_root)
|
||||
reasons: list[str] = []
|
||||
role = "author"
|
||||
|
||||
candidates: list[tuple[str | None, str, bool]] = [
|
||||
(worktree_path, "worktree_path argument", False),
|
||||
(worktree, "worktree argument", False),
|
||||
(active_worktree_env, f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
||||
(author_worktree_env, f"{AUTHOR_WORKTREE_ENV} environment variable", True),
|
||||
(session_lock_worktree, "active author issue lock worktree", False),
|
||||
]
|
||||
|
||||
selected_path: str | None = None
|
||||
selected_source: str | None = None
|
||||
existence: dict | None = None
|
||||
|
||||
for candidate, source, _configured in candidates:
|
||||
text = (candidate or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
real = _abs_real(text)
|
||||
except OSError:
|
||||
real = os.path.abspath(text)
|
||||
|
||||
existence = assess_bound_worktree_existence(
|
||||
configured_path=real,
|
||||
binding_source=source,
|
||||
canonical_repo_root=canonical,
|
||||
role_kind=role,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
if existence["block"]:
|
||||
# Missing configured binding: fail closed, never fall back (#618).
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"workspace_path": real,
|
||||
"workspace_binding_source": source,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": True,
|
||||
"path_exists": False,
|
||||
"in_git_worktree_list": existence.get("in_git_worktree_list"),
|
||||
"inspected_git_root": None,
|
||||
"reasons": list(existence.get("reasons") or []),
|
||||
"blocker_kind": BOUND_WORKTREE_MISSING,
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
selected_path = real
|
||||
selected_source = source
|
||||
break
|
||||
|
||||
if selected_path is None:
|
||||
# No explicit/env/lock binding. Allow process root only when it is a
|
||||
# branches/ worktree (MCP launched from the task worktree). Never
|
||||
# silently bind the stable control checkout.
|
||||
if is_path_under_branches(process_root, canonical):
|
||||
selected_path = process_root
|
||||
selected_source = "MCP process root under branches/ (session-owned)"
|
||||
else:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"workspace_path": process_root,
|
||||
"workspace_binding_source": "no author worktree binding",
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": os.path.isdir(process_root),
|
||||
"in_git_worktree_list": None,
|
||||
"inspected_git_root": None,
|
||||
"reasons": [
|
||||
"author mutation blocked: workspace is the stable control checkout; "
|
||||
"author mutation requires an explicit validated worktree_path "
|
||||
"or a worktree derived from the active author issue lock; "
|
||||
"silent fallback to the control checkout/master is forbidden (#618)"
|
||||
],
|
||||
"blocker_kind": "author_worktree_unbound_control_checkout",
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
workspace = selected_path
|
||||
source = selected_source or "unknown"
|
||||
path_exists = os.path.isdir(workspace)
|
||||
inspected_git_root: str | None = None
|
||||
in_list: bool | None = None
|
||||
|
||||
if not validate:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": source,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": False,
|
||||
"path_exists": path_exists,
|
||||
"in_git_worktree_list": None,
|
||||
"inspected_git_root": None,
|
||||
"reasons": [],
|
||||
"blocker_kind": None,
|
||||
"operator_recovery": None,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
# Traversal / symlink safety
|
||||
safety = assess_path_traversal_safety(
|
||||
path=workspace, canonical_repo_root=canonical
|
||||
)
|
||||
if safety["block"]:
|
||||
reasons.extend(safety["reasons"])
|
||||
else:
|
||||
workspace = safety["workspace_path"] or workspace
|
||||
|
||||
# Existence + git inventory
|
||||
if not path_exists:
|
||||
reasons.append(BOUND_WORKTREE_MISSING_MESSAGE)
|
||||
reasons.append(f"resolved worktree '{workspace}' does not exist")
|
||||
else:
|
||||
in_list = path_in_git_worktree_list(workspace, canonical)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", workspace, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
inspected_git_root = (res.stdout or "").strip() or None
|
||||
except Exception:
|
||||
inspected_git_root = None
|
||||
if in_list is False:
|
||||
# Only hard-fail when inventory was obtained and the path is absent.
|
||||
reasons.append(
|
||||
f"worktree '{workspace}' is not listed in git worktree list for "
|
||||
f"'{canonical}' (fail closed)"
|
||||
)
|
||||
|
||||
# Repository identity
|
||||
if path_exists:
|
||||
membership = assess_workspace_repo_membership(
|
||||
workspace_path=workspace,
|
||||
canonical_repo_root=canonical,
|
||||
)
|
||||
if membership["block"]:
|
||||
reasons.extend(membership["reasons"])
|
||||
|
||||
# branches/ containment
|
||||
branches = assess_author_mutation_worktree(
|
||||
workspace_path=workspace,
|
||||
project_root=canonical,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
|
||||
# Lock ownership (when a lock worktree is recorded)
|
||||
lock_own = assess_lock_worktree_ownership(
|
||||
workspace_path=workspace,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
)
|
||||
if lock_own["block"]:
|
||||
reasons.extend(lock_own["reasons"])
|
||||
|
||||
# Forbid resolved control checkout even if somehow selected
|
||||
if workspace == canonical or workspace == process_root:
|
||||
if not is_path_under_branches(workspace, canonical):
|
||||
if not any("control checkout" in r for r in reasons):
|
||||
reasons.append(
|
||||
"author mutation blocked: resolved workspace is the stable "
|
||||
"control checkout; silent fallback forbidden (#618)"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
bound_missing = any("does not exist" in r or BOUND_WORKTREE_MISSING_MESSAGE in r for r in reasons)
|
||||
return {
|
||||
"proven": not block,
|
||||
"block": block,
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": source,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical,
|
||||
"bound_worktree_missing": bound_missing,
|
||||
"path_exists": path_exists,
|
||||
"in_git_worktree_list": in_list,
|
||||
"inspected_git_root": inspected_git_root,
|
||||
"reasons": reasons,
|
||||
"blocker_kind": BOUND_WORKTREE_MISSING if bound_missing else (
|
||||
"author_worktree_validation_failed" if block else None
|
||||
),
|
||||
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT if block else None,
|
||||
"silent_control_fallback": False,
|
||||
}
|
||||
|
||||
|
||||
def format_durable_author_worktree_error(assessment: dict) -> str:
|
||||
"""Format fail-closed error for durable author worktree resolution."""
|
||||
if assessment.get("bound_worktree_missing") or assessment.get("blocker_kind") == BOUND_WORKTREE_MISSING:
|
||||
return format_bound_worktree_missing_error(assessment)
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
source = assessment.get("workspace_binding_source") or "unknown"
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons") or ["author worktree resolution failed"]
|
||||
)
|
||||
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
|
||||
return (
|
||||
f"Durable author worktree resolution blocked (#618): {reasons}. "
|
||||
f"workspace: {workspace}; binding_source: {source}. "
|
||||
f"Operator recovery: {recovery}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Immutable canonical repository root for cross-repository namespaces (#706).
|
||||
|
||||
The Gitea-Tools MCP server historically derived the ``canonical_repo_root`` from
|
||||
the *install checkout* the server script lives in
|
||||
(``PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))``). A namespace
|
||||
that runs the same server script against an *external* repository (e.g.
|
||||
``eagenda-author`` targeting ``eAgenda``) then failed every mutation: the
|
||||
branches-only / worktree-membership guards (#274) compared the task workspace
|
||||
against the Gitea-Tools ``.git`` directory, which it can never belong to.
|
||||
|
||||
This module separates two distinct concepts:
|
||||
|
||||
* the immutable code/install root (``PROJECT_ROOT``) — where the server lives, and
|
||||
* the namespace-scoped **canonical repository root** — the working root of the
|
||||
repository whose issues/PRs the namespace mutates.
|
||||
|
||||
The canonical repository root is configured per namespace (profile field or an
|
||||
environment variable, typically set alongside the namespace ``cwd`` in the MCP
|
||||
config). It is validated (existence, git identity, git common-directory
|
||||
membership) and pinned immutably into the session context so a later call cannot
|
||||
forge or swap it. When *no* binding is configured the single-repo default is
|
||||
preserved unchanged: the canonical root is derived from the process checkout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Mapping
|
||||
|
||||
import remote_repo_guard
|
||||
|
||||
# Namespace-scoped override, typically exported next to the server ``cwd`` in the
|
||||
# MCP config for a cross-repository namespace.
|
||||
CANONICAL_ROOT_ENV = "GITEA_CANONICAL_REPOSITORY_ROOT"
|
||||
|
||||
# Candidate git remote names probed when deriving repository identity.
|
||||
_IDENTITY_REMOTE_CANDIDATES = ("prgs", "origin", "dadeschools", "mdcps")
|
||||
|
||||
|
||||
def configured_canonical_root(
|
||||
profile: Mapping | None,
|
||||
env: Mapping | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return ``(value, source)`` for the declared canonical repository root.
|
||||
|
||||
Precedence: the ``GITEA_CANONICAL_REPOSITORY_ROOT`` environment variable
|
||||
(namespace-scoped) overrides the profile ``canonical_repository_root``
|
||||
field. Blank values are treated as unset. Returns ``(None, None)`` when no
|
||||
binding is declared (the single-repo default).
|
||||
"""
|
||||
env_map = env if env is not None else os.environ
|
||||
env_val = (env_map.get(CANONICAL_ROOT_ENV) or "").strip()
|
||||
if env_val:
|
||||
return env_val, f"{CANONICAL_ROOT_ENV} environment variable"
|
||||
if profile:
|
||||
prof_val = (profile.get("canonical_repository_root") or "").strip()
|
||||
if prof_val:
|
||||
return prof_val, "profile canonical_repository_root"
|
||||
return None, None
|
||||
|
||||
|
||||
def resolve_repo_toplevel(path: str) -> str | None:
|
||||
"""Realpath of the git working-tree top level for *path*, or None."""
|
||||
text = (path or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", text, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
top = (res.stdout or "").strip()
|
||||
return os.path.realpath(top) if top else None
|
||||
|
||||
|
||||
def repository_identity_slug(path: str, *, remote: str | None = None) -> str | None:
|
||||
"""``owner/repository`` derived from a git remote configured at *path*.
|
||||
|
||||
Tries the caller-named remote first, then a small set of known remote names,
|
||||
then whatever remote the repository actually has. Returns None when no remote
|
||||
URL is parseable (identity cannot be proven).
|
||||
"""
|
||||
text = (path or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
ordered: list[str] = []
|
||||
for name in (remote, *_IDENTITY_REMOTE_CANDIDATES):
|
||||
clean = (name or "").strip()
|
||||
if clean and clean not in ordered:
|
||||
ordered.append(clean)
|
||||
|
||||
try:
|
||||
listed = subprocess.run(
|
||||
["git", "-C", text, "remote"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.split()
|
||||
except Exception:
|
||||
listed = []
|
||||
for name in listed:
|
||||
if name and name not in ordered:
|
||||
ordered.append(name)
|
||||
|
||||
for name in ordered:
|
||||
try:
|
||||
url = subprocess.run(
|
||||
["git", "-C", text, "remote", "get-url", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
except Exception:
|
||||
continue
|
||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||
if parsed:
|
||||
return f"{parsed[0]}/{parsed[1]}"
|
||||
return None
|
||||
|
||||
|
||||
def assess_canonical_repository_root(
|
||||
*,
|
||||
configured_value: str | None,
|
||||
source: str | None,
|
||||
expected_slug: str | None,
|
||||
process_project_root: str,
|
||||
remote: str | None = None,
|
||||
require_binding: bool = False,
|
||||
) -> dict:
|
||||
"""Validate the canonical repository root binding, failing closed on forgery.
|
||||
|
||||
Returns a dict with ``proven`` / ``block`` / ``reasons`` plus the resolved
|
||||
``canonical_repo_root`` (the value downstream guards must use),
|
||||
``configured`` (whether a cross-repo binding was declared),
|
||||
``resolved_slug`` and ``source``.
|
||||
|
||||
Without a configured binding the single-repo default is preserved: the
|
||||
canonical root is derived from *process_project_root* and never blocks
|
||||
(unless *require_binding* explicitly demands one).
|
||||
|
||||
With a configured binding the path must exist, be a git repository, and —
|
||||
when *expected_slug* is known — carry a matching repository identity. A
|
||||
mismatched or (when *require_binding*) unprovable identity is a forged or
|
||||
conflicting binding and fails closed.
|
||||
"""
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
declared = (configured_value or "").strip()
|
||||
|
||||
if not declared:
|
||||
if require_binding:
|
||||
return _assessment(
|
||||
proven=False,
|
||||
reasons=[
|
||||
"no canonical_repository_root configured for a cross-repository "
|
||||
f"namespace; set {CANONICAL_ROOT_ENV} or the profile "
|
||||
"canonical_repository_root field (fail closed)"
|
||||
],
|
||||
configured=False,
|
||||
canonical_repo_root=process_root,
|
||||
resolved_slug=None,
|
||||
source=None,
|
||||
)
|
||||
# Single-repo default: canonical root follows the install checkout.
|
||||
derived = resolve_repo_toplevel(process_root) or process_root
|
||||
return _assessment(
|
||||
proven=True,
|
||||
reasons=[],
|
||||
configured=False,
|
||||
canonical_repo_root=derived,
|
||||
resolved_slug=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
real = os.path.realpath(os.path.abspath(declared))
|
||||
if not os.path.isdir(real):
|
||||
return _assessment(
|
||||
proven=False,
|
||||
reasons=[
|
||||
f"configured canonical repository root '{real}' does not exist "
|
||||
"or is not a directory (fail closed)"
|
||||
],
|
||||
configured=True,
|
||||
canonical_repo_root=real,
|
||||
resolved_slug=None,
|
||||
source=source,
|
||||
)
|
||||
|
||||
toplevel = resolve_repo_toplevel(real)
|
||||
if not toplevel:
|
||||
return _assessment(
|
||||
proven=False,
|
||||
reasons=[
|
||||
f"configured canonical repository root '{real}' is not a git "
|
||||
"repository (fail closed)"
|
||||
],
|
||||
configured=True,
|
||||
canonical_repo_root=real,
|
||||
resolved_slug=None,
|
||||
source=source,
|
||||
)
|
||||
|
||||
resolved_slug = repository_identity_slug(toplevel, remote=remote)
|
||||
reasons: list[str] = []
|
||||
expected = (expected_slug or "").strip() or None
|
||||
if expected:
|
||||
if resolved_slug and resolved_slug.lower() != expected.lower():
|
||||
reasons.append(
|
||||
f"canonical repository root identity mismatch: '{toplevel}' resolves "
|
||||
f"to repository '{resolved_slug}' but the session is authorized for "
|
||||
f"'{expected}' (forged or conflicting binding, fail closed)"
|
||||
)
|
||||
elif not resolved_slug and require_binding:
|
||||
reasons.append(
|
||||
f"canonical repository root '{toplevel}' has no resolvable git "
|
||||
f"remote identity to confirm authorization for '{expected}' "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
return _assessment(
|
||||
proven=not reasons,
|
||||
reasons=reasons,
|
||||
configured=True,
|
||||
canonical_repo_root=toplevel,
|
||||
resolved_slug=resolved_slug,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def format_canonical_repository_root_error(assessment: Mapping) -> str:
|
||||
"""Single RuntimeError message for MCP preflight gates."""
|
||||
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||
source = assessment.get("source") or "(unconfigured)"
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons") or ["unknown canonical repository root violation"]
|
||||
)
|
||||
return (
|
||||
f"Canonical repository root guard (#706): {reasons}. "
|
||||
f"binding source: {source}; canonical repository root: {root}. "
|
||||
"Configure a valid canonical_repository_root for the target repository "
|
||||
"and relaunch; do not point it at the Gitea-Tools install checkout."
|
||||
)
|
||||
|
||||
|
||||
def _assessment(
|
||||
*,
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
configured: bool,
|
||||
canonical_repo_root: str,
|
||||
resolved_slug: str | None,
|
||||
source: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(reasons),
|
||||
"configured": configured,
|
||||
"canonical_repo_root": canonical_repo_root,
|
||||
"resolved_slug": resolved_slug,
|
||||
"source": source,
|
||||
}
|
||||
+112
-13
@@ -302,6 +302,7 @@ class ControlPlaneDB:
|
||||
conn.executescript(_SCHEMA_SQL)
|
||||
self._migrate_incident_links_null_scope(conn)
|
||||
self._migrate_lease_lifecycle_columns(conn)
|
||||
self._migrate_session_ownership_columns(conn)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
@@ -492,32 +493,62 @@ class ControlPlaneDB:
|
||||
namespace: str | None = None,
|
||||
pid: int | None = None,
|
||||
status: str = "active",
|
||||
controller_instance_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Register/refresh a session row.
|
||||
|
||||
*controller_instance_id* (#765) is the stable identity of the
|
||||
controller that owns this session. Session ids are regenerated per
|
||||
invocation, so they cannot express "my own in-progress task"; the
|
||||
controller instance can. It is never overwritten with ``None``, so a
|
||||
heartbeat from a caller that does not supply one cannot erase
|
||||
ownership.
|
||||
"""
|
||||
now = _ts()
|
||||
instance = (controller_instance_id or "").strip() or None
|
||||
with self._tx() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT session_id FROM sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
||||
last_heartbeat_at = ?, status = ?
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(role, profile, namespace, pid, now, status, session_id),
|
||||
)
|
||||
if instance is None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
||||
last_heartbeat_at = ?, status = ?
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(role, profile, namespace, pid, now, status, session_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET role = ?, profile = ?, namespace = ?, pid = ?,
|
||||
last_heartbeat_at = ?, status = ?,
|
||||
controller_instance_id = ?
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(
|
||||
role, profile, namespace, pid, now, status,
|
||||
instance, session_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions(
|
||||
session_id, role, profile, namespace, pid,
|
||||
started_at, last_heartbeat_at, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
started_at, last_heartbeat_at, status,
|
||||
controller_instance_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(session_id, role, profile, namespace, pid, now, now, status),
|
||||
(
|
||||
session_id, role, profile, namespace, pid, now, now,
|
||||
status, instance,
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sessions WHERE session_id = ?",
|
||||
@@ -1215,6 +1246,73 @@ class ControlPlaneDB:
|
||||
if name not in cols:
|
||||
conn.execute(f"ALTER TABLE leases ADD COLUMN {name} {decl}")
|
||||
|
||||
_SESSION_OWNERSHIP_COLUMNS: tuple[tuple[str, str], ...] = (
|
||||
("controller_instance_id", "TEXT"),
|
||||
)
|
||||
|
||||
def _migrate_session_ownership_columns(self, conn: sqlite3.Connection) -> None:
|
||||
"""Add the stable controller identity to sessions (#765).
|
||||
|
||||
Pre-existing rows migrate with ``NULL``. A NULL instance is treated as
|
||||
*unknown ownership* by the allocator and is never silently adopted.
|
||||
"""
|
||||
cols = {
|
||||
row[1]
|
||||
for row in conn.execute("PRAGMA table_info(sessions)").fetchall()
|
||||
}
|
||||
if not cols:
|
||||
return
|
||||
for name, decl in self._SESSION_OWNERSHIP_COLUMNS:
|
||||
if name not in cols:
|
||||
conn.execute(f"ALTER TABLE sessions ADD COLUMN {name} {decl}")
|
||||
|
||||
def list_active_claims(
|
||||
self,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
role: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> dict[tuple[str, int], dict[str, Any]]:
|
||||
"""Map ``(work_kind, work_number)`` to its live claim (#765).
|
||||
|
||||
Only ``active`` leases count as claims; released/expired rows never
|
||||
withhold work. Callers compare the returned ``controller_instance_id``
|
||||
against their own to decide own-task vs foreign-task.
|
||||
"""
|
||||
claims: dict[tuple[str, int], dict[str, Any]] = {}
|
||||
for row in self.list_leases(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
role=role,
|
||||
statuses=("active",),
|
||||
limit=limit,
|
||||
):
|
||||
kind = str(row.get("work_kind") or "").strip().lower()
|
||||
number = row.get("work_number")
|
||||
if not kind or number is None:
|
||||
continue
|
||||
key = (kind, int(number))
|
||||
claim = {
|
||||
"lease_id": row.get("lease_id"),
|
||||
"session_id": row.get("session_id"),
|
||||
"controller_instance_id": row.get("session_controller_instance_id"),
|
||||
"role": row.get("role"),
|
||||
"profile": row.get("session_profile"),
|
||||
"expires_at": row.get("expires_at"),
|
||||
"work_kind": kind,
|
||||
"work_number": int(number),
|
||||
}
|
||||
# Keep the longest-lived claim when duplicates exist.
|
||||
previous = claims.get(key)
|
||||
if previous is None or str(claim["expires_at"] or "") > str(
|
||||
previous["expires_at"] or ""
|
||||
):
|
||||
claims[key] = claim
|
||||
return claims
|
||||
|
||||
def _lease_columns(self, conn: sqlite3.Connection) -> set[str]:
|
||||
return {
|
||||
row[1]
|
||||
@@ -1256,7 +1354,8 @@ class ControlPlaneDB:
|
||||
w.number AS work_number, w.state AS work_state,
|
||||
w.current_head_sha AS work_head_sha,
|
||||
s.pid AS session_pid, s.profile AS session_profile,
|
||||
s.status AS session_status
|
||||
s.status AS session_status,
|
||||
s.controller_instance_id AS session_controller_instance_id
|
||||
FROM leases l
|
||||
JOIN work_items w ON w.work_item_id = l.work_item_id
|
||||
LEFT JOIN sessions s ON s.session_id = l.session_id
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -171,13 +171,18 @@ then:
|
||||
- Does not replace CI or code review for MCP changes
|
||||
- Does not authorize editing stable checkout “because tests need a quick fix”
|
||||
|
||||
## 5. Implementation follow-ups (optional tooling)
|
||||
## 5. Implementation follow-ups
|
||||
|
||||
These may land in later issues; the **policy binds sessions now**:
|
||||
The **policy binds sessions now**. The enforcement layer landed with issue #615
|
||||
acceptance criteria 6–11 in `stable_control_runtime.py`:
|
||||
|
||||
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
|
||||
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
|
||||
3. Promotion checklist script that emits the durable promotion marker fields.
|
||||
1. ~~Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”~~ **Landed.** `_runtime_mode_block()` refuses every mutating operation from a `dev-test`, dev-worktree-launched, dirty-stable, misaligned, or `unknown` runtime; `gitea.read` is never blocked, so an operator can still diagnose a sick runtime.
|
||||
2. ~~Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.~~ **Landed** as `runtime_mode` (`stable-control` | `dev-test` | `unknown`), reported by `gitea_get_runtime_context` under `stable_control_runtime` together with the runtime git SHA, branch, checkout path, process root, active workspace, alignment, dirty files, and `real_mutations_allowed`. Operators running a packaged layout with no git checkout declare the mode explicitly with `GITEA_MCP_RUNTIME_MODE`.
|
||||
3. ~~Promotion checklist script that emits the durable promotion marker fields.~~ **Landed** as `scripts/promote-stable-runtime` (read-only; emits and validates the record) plus [`../stable-runtime-promotion-runbook.md`](../stable-runtime-promotion-runbook.md).
|
||||
|
||||
Post-transport-flap proof is enforced per namespace: a flap invalidates every
|
||||
`gitea-*` namespace at once, and author proof never transfers to reviewer,
|
||||
merger, or reconciler (`namespace_not_reproven_after_flap`).
|
||||
|
||||
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -45,6 +45,7 @@ authenticated capability set. Each profile defines the following fields:
|
||||
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
||||
| `allowed_operations` | list | Operation categories this profile may perform. |
|
||||
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
||||
| `allowed_repositories` | list | Optional. Canonical `owner/repository` slugs this profile may bind to. An authorization boundary, not the binding itself — see [Repository scope](#repository-scope-714). |
|
||||
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
||||
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
||||
| `can_approve_prs` | bool | May submit an approving PR review. |
|
||||
@@ -57,6 +58,47 @@ authenticated capability set. Each profile defines the following fields:
|
||||
name), never the token itself. Token values are never part of a profile object,
|
||||
never logged, never returned by a tool, and never committed.
|
||||
|
||||
## Repository scope (#714)
|
||||
|
||||
`allowed_repositories` declares the canonical `owner/repository` slugs a profile
|
||||
may operate on:
|
||||
|
||||
```json
|
||||
"prgs-author": {
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"]
|
||||
}
|
||||
```
|
||||
|
||||
It is an **authorization boundary, not the session binding**. The binding is
|
||||
derived and enforced like this:
|
||||
|
||||
1. The session repository is derived from the **verified, workspace-aligned git
|
||||
remote** — never from a caller-supplied `org`/`repo` argument, and never from
|
||||
the `REMOTES` table (whose entries are default *targets*: `prgs` defaults to
|
||||
`Timesheet`, which is not this project).
|
||||
2. That workspace-derived slug is validated against `allowed_repositories`.
|
||||
3. The session binds immutably to that one canonical `owner/repository`. The
|
||||
organization is derived from the slug; there is no independent,
|
||||
caller-controlled organization value.
|
||||
4. If a profile authorizes several repositories, the verified workspace still
|
||||
selects exactly one. A session never binds to the whole list and never
|
||||
switches between entries.
|
||||
|
||||
Fail-closed rules:
|
||||
|
||||
- Activation is rejected when the workspace repository is absent from the
|
||||
allowlist.
|
||||
- A mutation is rejected when no verified workspace repository can be
|
||||
established.
|
||||
- A tool-level `org`/`repo` override that disagrees with the binding is rejected
|
||||
before the mutation runs.
|
||||
- A mutation request can never establish, complete, or replace the binding.
|
||||
|
||||
The field is **config-only**: no environment variable can widen or forge it. It
|
||||
is optional — a profile that omits it keeps the previous behaviour, so existing
|
||||
static-profile namespaces stay functional until an operator provisions the
|
||||
scope. Provisioning it is what activates enforcement for that profile.
|
||||
|
||||
## Example profiles
|
||||
|
||||
The following are the reference profiles. Booleans express intended capability
|
||||
@@ -275,6 +317,44 @@ Least-privilege constraints:
|
||||
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
||||
`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
|
||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||
|
||||
@@ -131,6 +131,44 @@ Suggested lifecycle:
|
||||
The helper module `issue_workflow_labels.py` is the source of truth for the
|
||||
canonical label specs and status transition replacement behavior.
|
||||
|
||||
## Terminal PR transitions retire `status:pr-open` (#780)
|
||||
|
||||
`status:pr-open` states that a linked PR is *currently open*. The moment that
|
||||
stops being true the label must go, whatever ended the PR:
|
||||
|
||||
| Terminal reason | Raised by |
|
||||
|---|---|
|
||||
| `merged` | `gitea_merge_pr` |
|
||||
| `closed_without_merge` | `gitea_edit_pr` closing the PR |
|
||||
| `superseded` | `gitea_reconcile_superseded_by_merged_pr` |
|
||||
| `already_landed` | `gitea_reconcile_already_landed_pr` |
|
||||
| `controller_closure` | `gitea_close_issue` |
|
||||
| `abandoned` | abandonment handling |
|
||||
| `retry_recovery` | `gitea_cleanup_terminal_pr_labels` after a partial failure |
|
||||
|
||||
All of these route through one rule in `terminal_pr_label_cleanup.py`, so the
|
||||
paths cannot drift apart. The rule guarantees:
|
||||
|
||||
- only `status:pr-open` is removed — every other label is preserved verbatim;
|
||||
- an empty resulting label set is valid (it was the issue's only label);
|
||||
- an issue that no longer carries the label is a no-op, so retries are safe;
|
||||
- the result is confirmed by a read-after-write re-read, not assumed.
|
||||
|
||||
Controller closure runs the cleanup **before** changing issue state and fails
|
||||
closed if it cannot be completed and verified — closing first would bake in the
|
||||
stale label with no later step to catch it. Post-merge cleanup never blocks the
|
||||
merge: the transition already happened, so failures are reported with a
|
||||
`safe_next_action` instead.
|
||||
|
||||
Use `gitea_assess_terminal_label_hygiene` as terminal validation before
|
||||
declaring a transition or cleanup batch complete. It enumerates issues plus the
|
||||
live open PRs and reports any issue still carrying `status:pr-open` without an
|
||||
open PR to justify it. Issues with a genuinely open PR are exempt, not
|
||||
residual.
|
||||
|
||||
Recovery from a partial failure is `gitea_cleanup_terminal_pr_labels` with
|
||||
`terminal_reason='retry_recovery'`.
|
||||
|
||||
## Discussion Issues
|
||||
|
||||
Discussion issues must be labeled `type:discussion`.
|
||||
@@ -157,6 +195,10 @@ If a discussion produces implementation work, either:
|
||||
be applied to the locked issue, then applies it after the PR is created.
|
||||
- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author
|
||||
sessions can satisfy the branches-only mutation guard while changing labels.
|
||||
- `gitea_cleanup_terminal_pr_labels` retires `status:pr-open` after a terminal
|
||||
PR transition; it is idempotent, so it is also the retry/recovery path.
|
||||
- `gitea_assess_terminal_label_hygiene` is the read-only terminal validation
|
||||
for residual `status:pr-open`.
|
||||
|
||||
## Existing Non-Workflow Labels
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
```text
|
||||
@@ -696,7 +706,9 @@ do **not** improvise shell wrappers or fall back to direct API / temp scripts.
|
||||
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||
update tests if behavior changes; run the full suite; commit with an
|
||||
issue-linked message; open a PR to `master`; move the issue to
|
||||
`status:pr-open`. **Do not** review or merge your own PR. Include an
|
||||
`status:pr-open` (every terminal transition later retires that label
|
||||
automatically — see [`label-taxonomy.md`](label-taxonomy.md)). **Do not**
|
||||
review or merge your own PR. Include an
|
||||
`LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see
|
||||
[`llm-agent-sha.md`](llm-agent-sha.md).
|
||||
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
||||
@@ -1231,6 +1243,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
## Related documents
|
||||
|
||||
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
||||
- [`stable-runtime-promotion-runbook.md`](stable-runtime-promotion-runbook.md) — operator promotion procedure, required promotion-record fields, per-namespace post-flap re-proving, and rollback for the stable control runtime (#615).
|
||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||
|
||||
@@ -40,6 +40,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
|
||||
| Workflow dashboard (queue, leases, next safe action) | Documents the read-only `gitea_workflow_dashboard` MCP tool (#605): live PR/issue queues, leases by role, terminal review lock, blocked items, and exact next-safe prompts. **Does not assign work** — assignment still uses `gitea_allocate_next_work`. Never presents blocked/terminal-locked items as safe. The shell entry is documentation only (no Gitea mutation). |
|
||||
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
||||
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
|
||||
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
|
||||
@@ -50,6 +51,22 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
||||
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
|
||||
| Exit | Quit the menu. |
|
||||
|
||||
### Workflow dashboard MCP tool (#605)
|
||||
|
||||
From any healthy Gitea MCP namespace with `gitea.read`:
|
||||
|
||||
```text
|
||||
gitea_workflow_dashboard(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
```
|
||||
|
||||
Response includes `human_summary` plus structured queues, `active_leases_by_role`,
|
||||
`terminal_review_lock`, `blocked_items`, `next_safe_by_role`, and
|
||||
`primary_next_safe_action`. Incomplete inventory fails closed.
|
||||
|
||||
## Placeholder-only entries
|
||||
|
||||
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# Registered MCP tool inventory
|
||||
|
||||
This is the canonical list of tools the Gitea-Tools MCP server registers. It
|
||||
exists because documentation and the registered inventory drifted: the workflow
|
||||
documented a `gitea_edit_issue` tool that no namespace had ever registered, so a
|
||||
mutation could be planned against a tool that did not exist and only fail at
|
||||
execution time (#781).
|
||||
|
||||
## The rule
|
||||
|
||||
**Documentation must never name a tool an actor cannot reach.**
|
||||
|
||||
Two guards enforce it, both in `tests/test_issue_781_edit_issue_tool.py`:
|
||||
|
||||
1. The list below must equal the registered tool set exactly — sorted, no
|
||||
duplicates, nothing missing in either direction. Adding a tool without
|
||||
documenting it fails, and documenting a tool without registering it fails.
|
||||
2. Every backticked `gitea_*` / `mcp_*` identifier in `skills/**/*.md` must be a
|
||||
registered tool. Module and script names that share the prefix are listed
|
||||
explicitly in `mcp_tool_inventory.NON_TOOL_IDENTIFIERS` rather than being
|
||||
waved through by a looser pattern.
|
||||
|
||||
## Updating this file
|
||||
|
||||
When you add or remove an `@mcp.tool()`, regenerate the block below:
|
||||
|
||||
```bash
|
||||
PYTEST_CURRENT_TEST=1 venv/bin/python -c "
|
||||
import mcp_server, mcp_tool_inventory
|
||||
print(mcp_tool_inventory.render_inventory_block(
|
||||
mcp_server.mcp._tool_manager._tools))
|
||||
"
|
||||
```
|
||||
|
||||
Replace everything between the markers with that output. Do not hand-edit
|
||||
individual entries — the generator and the guard share one ordering rule.
|
||||
|
||||
## Registered tools
|
||||
|
||||
Namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`)
|
||||
register the same tool set; what differs per namespace is the execution profile
|
||||
that gates each call, not which tools exist.
|
||||
|
||||
<!-- BEGIN REGISTERED TOOL INVENTORY -->
|
||||
|
||||
- `gitea_abandon_workflow_lease`
|
||||
- `gitea_acquire_conflict_fix_lease`
|
||||
- `gitea_acquire_merger_pr_lease`
|
||||
- `gitea_acquire_reviewer_pr_lease`
|
||||
- `gitea_activate_profile`
|
||||
- `gitea_adopt_merger_pr_lease`
|
||||
- `gitea_adopt_workflow_lease`
|
||||
- `gitea_allocate_next_work`
|
||||
- `gitea_assess_already_landed_reconciliation`
|
||||
- `gitea_assess_conflict_fix_classification`
|
||||
- `gitea_assess_conflict_fix_push`
|
||||
- `gitea_assess_gitea_operation_path`
|
||||
- `gitea_assess_master_parity`
|
||||
- `gitea_assess_mcp_namespace_health`
|
||||
- `gitea_assess_pr_sync_status`
|
||||
- `gitea_assess_review_merge_state_machine`
|
||||
- `gitea_assess_reviewer_pr_lease`
|
||||
- `gitea_assess_terminal_label_hygiene`
|
||||
- `gitea_assess_work_issue_duplicate`
|
||||
- `gitea_assess_worktree_cleanup_integrity`
|
||||
- `gitea_audit_config`
|
||||
- `gitea_audit_stable_branch_contamination`
|
||||
- `gitea_audit_worktree_cleanup`
|
||||
- `gitea_authorize_reconciliation_cleanup_phase`
|
||||
- `gitea_authorize_review_correction`
|
||||
- `gitea_capability_stop_terminal_report`
|
||||
- `gitea_capture_branches_worktree_snapshot`
|
||||
- `gitea_check_pr_eligibility`
|
||||
- `gitea_cleanup_merged_pr_branch`
|
||||
- `gitea_cleanup_obsolete_reviewer_comment_lease`
|
||||
- `gitea_cleanup_post_merge_moot_lease`
|
||||
- `gitea_cleanup_stale_claims`
|
||||
- `gitea_cleanup_stale_review_decision_lock`
|
||||
- `gitea_cleanup_terminal_pr_labels`
|
||||
- `gitea_close_issue`
|
||||
- `gitea_commit_files`
|
||||
- `gitea_consume_irrecoverable_decision_lock_provenance`
|
||||
- `gitea_create_issue`
|
||||
- `gitea_create_issue_comment`
|
||||
- `gitea_create_label`
|
||||
- `gitea_create_pr`
|
||||
- `gitea_delete_branch`
|
||||
- `gitea_diagnose_review_decision_lock`
|
||||
- `gitea_diagnose_reviewer_pr_lease_handoff`
|
||||
- `gitea_diagnose_terminal`
|
||||
- `gitea_dry_run_pr_review`
|
||||
- `gitea_edit_issue`
|
||||
- `gitea_edit_pr`
|
||||
- `gitea_expire_workflow_leases`
|
||||
- `gitea_get_authenticated_user`
|
||||
- `gitea_get_current_user`
|
||||
- `gitea_get_file`
|
||||
- `gitea_get_pr_review_feedback`
|
||||
- `gitea_get_profile`
|
||||
- `gitea_get_runtime_context`
|
||||
- `gitea_get_shell_health`
|
||||
- `gitea_heartbeat_reviewer_pr_lease`
|
||||
- `gitea_inspect_workflow_lease`
|
||||
- `gitea_issue_irrecoverable_provenance_authorization`
|
||||
- `gitea_list_issue_comments`
|
||||
- `gitea_list_issues`
|
||||
- `gitea_list_labels`
|
||||
- `gitea_list_profiles`
|
||||
- `gitea_list_prs`
|
||||
- `gitea_list_workflow_leases`
|
||||
- `gitea_load_review_workflow`
|
||||
- `gitea_lock_issue`
|
||||
- `gitea_mark_final_review_decision`
|
||||
- `gitea_mark_issue`
|
||||
- `gitea_merge_pr`
|
||||
- `gitea_mirror_refs`
|
||||
- `gitea_observability_link_issue`
|
||||
- `gitea_observability_list_projects`
|
||||
- `gitea_observability_reconcile_incident`
|
||||
- `gitea_post_heartbeat`
|
||||
- `gitea_quarantine_contaminated_review`
|
||||
- `gitea_reclaim_expired_workflow_lease`
|
||||
- `gitea_reconcile_already_landed_pr`
|
||||
- `gitea_reconcile_issue_claims`
|
||||
- `gitea_reconcile_merged_cleanups`
|
||||
- `gitea_reconcile_superseded_by_merged_pr`
|
||||
- `gitea_record_irrecoverable_decision_lock_provenance`
|
||||
- `gitea_record_pre_review_command`
|
||||
- `gitea_record_shell_spawn_outcome`
|
||||
- `gitea_record_stable_branch_push_attempt`
|
||||
- `gitea_release_merger_pr_lease`
|
||||
- `gitea_release_reviewer_pr_lease`
|
||||
- `gitea_release_workflow_lease`
|
||||
- `gitea_resolve_task_capability`
|
||||
- `gitea_resume_review_draft`
|
||||
- `gitea_review_pr`
|
||||
- `gitea_route_task_session`
|
||||
- `gitea_save_review_draft`
|
||||
- `gitea_scan_already_landed_open_prs`
|
||||
- `gitea_sentry_get_issue_events`
|
||||
- `gitea_sentry_link_gitea_issue`
|
||||
- `gitea_sentry_list_issues`
|
||||
- `gitea_sentry_reconcile_issue`
|
||||
- `gitea_sentry_watchdog`
|
||||
- `gitea_set_issue_labels`
|
||||
- `gitea_submit_pr_review`
|
||||
- `gitea_update_pr_branch_by_merge`
|
||||
- `gitea_validate_review_final_report`
|
||||
- `gitea_view_issue`
|
||||
- `gitea_view_pr`
|
||||
- `gitea_whoami`
|
||||
- `gitea_workflow_dashboard`
|
||||
- `mcp_check_workflow_skill_preflight`
|
||||
- `mcp_get_control_plane_guide`
|
||||
- `mcp_get_skill_guide`
|
||||
- `mcp_list_project_skills`
|
||||
|
||||
<!-- END REGISTERED TOOL INVENTORY -->
|
||||
|
||||
## Issue-content editing
|
||||
|
||||
`gitea_edit_issue` is the only path that changes an issue's title or body. It
|
||||
PATCHes the issue endpoint, refuses a pull-request number, sends only the fields
|
||||
the caller named, and proves the result by read-after-write — including that
|
||||
state, labels, assignees, and milestone did not move.
|
||||
|
||||
`gitea_edit_pr` remains pull-request-only. The two paths never merge: a single
|
||||
tool that accepted either kind would make the narrower capability reachable
|
||||
through the wider one.
|
||||
@@ -131,7 +131,59 @@ and `incident_links` rows.
|
||||
- The bridge remains the **only** sanctioned route from an alert back into
|
||||
Gitea workflow state.
|
||||
|
||||
## 7. Non-goals
|
||||
## 7. Reading Sentry back into Gitea (#607)
|
||||
|
||||
[`sentry_incident_bridge.py`](../../sentry_incident_bridge.py) supplies the
|
||||
**read** half of the inbound path: it pulls unresolved issues/events from the
|
||||
self-hosted Sentry API, normalizes them into #612 observations, and hands them
|
||||
to `incident_bridge.reconcile_incident`. It never adds a second linking store —
|
||||
`incident_links` on the #613 control-plane DB stays canonical, which is what
|
||||
makes the mapping survive restarts.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `SENTRY_BASE_URL` | Self-hosted Sentry root | `https://sentry.prgs.cc` |
|
||||
| `SENTRY_AUTH_TOKEN` | API token — **env only**, never logged or returned | _(unset)_ |
|
||||
| `SENTRY_ORG` | Sentry organization slug | _(unset)_ |
|
||||
| `SENTRY_PROJECT` | Sentry project slug | _(unset)_ |
|
||||
| `MCP_SENTRY_ISSUE_BRIDGE_ENABLED` | Required for `apply=true` | `false` |
|
||||
| `MCP_SENTRY_MIN_EVENTS_FOR_ISSUE` | Recurrence threshold before an issue is worth filing | `2` |
|
||||
| `MCP_SENTRY_LOOKBACK` | Scan window (`statsPeriod`, e.g. `24h`) | `24h` |
|
||||
|
||||
Missing org/project fails closed as `not_configured`; a missing token fails
|
||||
closed as `missing_token` **before** any HTTP call is made.
|
||||
|
||||
### Tools
|
||||
|
||||
| Tool | Mode | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `gitea_sentry_list_issues` | read-only | Unresolved issues, `Link`-header pagination |
|
||||
| `gitea_sentry_get_issue_events` | read-only | Sanitized recent + latest event for one issue |
|
||||
| `gitea_sentry_reconcile_issue` | dry-run default | One Sentry issue → durable Gitea issue |
|
||||
| `gitea_sentry_link_gitea_issue` | dry-run default | Link a Sentry issue to an existing Gitea issue |
|
||||
| `gitea_sentry_watchdog` | dry-run default | Scan + create/update issues for active incidents |
|
||||
|
||||
### Policy
|
||||
|
||||
- **Dedupe:** one Sentry issue maps to exactly one Gitea issue, keyed by
|
||||
provider + base URL + org + project + issue id. Recurrence updates the link
|
||||
(and its `event_count`) instead of filing a duplicate.
|
||||
- **No reopen:** a Sentry issue that is no longer `unresolved` is skipped; the
|
||||
bridge never reopens or re-files a closed Gitea issue.
|
||||
- **Threshold:** issues below `MCP_SENTRY_MIN_EVENTS_FOR_ISSUE` are skipped, so
|
||||
one-off noise does not become durable work.
|
||||
- **Apply is explicit:** `apply=true` requires both
|
||||
`MCP_SENTRY_ISSUE_BRIDGE_ENABLED` and issue-create permission on the profile.
|
||||
- **Outages fail closed:** an unreachable Sentry returns `sentry_unavailable`
|
||||
and creates nothing.
|
||||
- **Redaction:** secrets are scrubbed and absolute local paths are reduced to a
|
||||
category token (`[path:author]`, `[path:root]`, …) before any value reaches a
|
||||
Gitea issue body. Sensitive tag keys (`authorization`, `cookie`, …) are
|
||||
dropped, and permalinks carrying embedded credentials are discarded entirely.
|
||||
|
||||
## 8. Non-goals
|
||||
|
||||
- Sentry must **not** become the workflow source of truth.
|
||||
- Sentry must **not** approve, merge, close, or mutate Gitea workflow state.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Stable control runtime — promotion runbook (#615)
|
||||
|
||||
Operator / release-manager procedure for promoting a revision into the **stable
|
||||
control runtime**: the Gitea MCP server that performs real issue/PR mutations.
|
||||
|
||||
Policy source: [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md).
|
||||
Enforcement: `stable_control_runtime.py` (runtime mode classification, mutation
|
||||
gates, per-namespace post-flap re-proving, promotion-record validation).
|
||||
|
||||
**Promotion is operator-owned.** Normal author / reviewer / merger / reconciler
|
||||
sessions must never kill, restart, or relaunch the MCP server, and must never
|
||||
edit the stable runtime checkout. A session that needs newer server code stops
|
||||
with `BLOCKED + DIAGNOSE` and hands off to the operator.
|
||||
|
||||
---
|
||||
|
||||
## 1. When a promotion is required
|
||||
|
||||
- A merged PR changes MCP server code the control plane must now enforce.
|
||||
- `gitea_assess_master_parity` reports `stale: true` / `restart_required: true`.
|
||||
- `gitea_get_runtime_context` reports a `runtime_mode` other than
|
||||
`stable-control`, or `real_mutations_allowed: false`.
|
||||
|
||||
## 2. Pre-promotion checks
|
||||
|
||||
Run these **before** advancing the stable checkout:
|
||||
|
||||
1. The target revision is on remote `master` and was merged through
|
||||
`gitea_merge_pr` (never a direct stable-branch push — see #671).
|
||||
2. The stable control checkout is clean (`git status --porcelain` empty) and on
|
||||
`master`. A dirty stable runtime is itself a mutation blocker.
|
||||
3. The advance is strictly fast-forwardable: local `master` is an ancestor of
|
||||
`prgs/master`.
|
||||
4. No active workflow lease is mid-mutation (`gitea_list_workflow_leases`).
|
||||
|
||||
## 3. Promotion steps
|
||||
|
||||
1. Record the **previous** runtime SHA (`gitea_assess_master_parity` →
|
||||
`startup_head`).
|
||||
2. `git fetch --prune prgs` in the stable control checkout.
|
||||
3. `git merge --ff-only prgs/master` — never rebase, reset, or force.
|
||||
4. Record the **promoted** runtime SHA (`git rev-parse HEAD`).
|
||||
5. Reload the runtime using the sanctioned client path (IDE/client reconnect or
|
||||
the operator's supervised service reload). Never `pkill` the daemon from a
|
||||
workflow session.
|
||||
6. Re-prove **each** namespace independently (see §5).
|
||||
7. Record the promotion (see §4) and post it as a durable comment on the
|
||||
tracking issue.
|
||||
|
||||
## 4. Promotion record (required fields)
|
||||
|
||||
Every promotion must record all of the following. `assess_promotion_record()`
|
||||
validates them and fails closed on any missing field, or when
|
||||
`previous_runtime_sha` equals `promoted_runtime_sha` (nothing was promoted).
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `previous_runtime_sha` | SHA the stable runtime was serving before promotion |
|
||||
| `promoted_runtime_sha` | SHA the stable runtime serves after promotion |
|
||||
| `source_branch` | Branch the promoted revision came from |
|
||||
| `source_pr` | PR number that merged it |
|
||||
| `restart_method` | Exact reload/restart mechanism the operator used |
|
||||
| `health_check_proof` | `gitea_assess_mcp_namespace_health` result per namespace |
|
||||
| `identity_proof` | `gitea_whoami` username + profile per namespace |
|
||||
| `profile_proof` | `gitea_get_runtime_context` active profile per namespace |
|
||||
| `workspace_proof` | Process root, canonical root, alignment, clean state |
|
||||
| `mutation_capability_proof` | `gitea_resolve_task_capability` for the intended task |
|
||||
| `rollback_instructions` | Exact steps to return to `previous_runtime_sha` |
|
||||
|
||||
Helper: `scripts/promote-stable-runtime` emits and validates the record. It
|
||||
never restarts anything — it reads state and prints the record for the operator
|
||||
to act on and archive.
|
||||
|
||||
## 5. Post-promotion namespace re-proving
|
||||
|
||||
A restart or transport flap drops every `gitea-*` namespace together. Author
|
||||
proof is **not** global proof. For each of `author`, `reviewer`, `merger`,
|
||||
`reconciler`, in that namespace:
|
||||
|
||||
1. `gitea_whoami`
|
||||
2. `gitea_get_runtime_context`
|
||||
3. `gitea_resolve_task_capability` immediately before the intended mutation
|
||||
4. Mutate only when no reconnect / restart / stale-runtime gate is reported
|
||||
|
||||
Until a namespace passes all four, its mutations stay blocked with
|
||||
`namespace_not_reproven_after_flap`.
|
||||
|
||||
## 6. Rollback
|
||||
|
||||
If the promoted runtime is unhealthy — namespace EOF that does not recover,
|
||||
identity or profile mismatch, capability resolution failure, or an unexpected
|
||||
`runtime_mode`:
|
||||
|
||||
1. **Stop all PR/review/merge work.** An unhealthy stable runtime fails closed;
|
||||
do not route around it.
|
||||
2. Fast-forward or check out `previous_runtime_sha` in the stable checkout.
|
||||
3. Reload the runtime by the same sanctioned method.
|
||||
4. Re-prove every namespace (§5).
|
||||
5. Record the rollback as a promotion record whose `promoted_runtime_sha` is the
|
||||
restored SHA, with the failure evidence in `health_check_proof`.
|
||||
|
||||
## 7. Runtime modes seen in reports
|
||||
|
||||
| Mode | Meaning | Real mutations |
|
||||
|------|---------|----------------|
|
||||
| `stable-control` | Promoted revision, stable branch, clean checkout | Allowed |
|
||||
| `dev-test` | Launched from a `branches/` worktree or a feature branch | Blocked against production |
|
||||
| `unknown` | Root unresolvable, not a git checkout, or detached HEAD with no declaration | Blocked |
|
||||
|
||||
A packaged deployment with no git checkout must declare itself explicitly with
|
||||
`GITEA_MCP_RUNTIME_MODE=stable-control`; an unset or misspelled value falls back
|
||||
to inference and, failing that, to `unknown`.
|
||||
|
||||
## 8. Related
|
||||
|
||||
- `architecture/mcp-stable-control-runtime-policy-adr.md` — the policy (#615)
|
||||
- `mcp-namespace-health.md` — client-namespace health (#543)
|
||||
- `mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery
|
||||
- `mcp-daemon-import-guard.md` — sanctioned daemon only (#558)
|
||||
- `bootstrap-review-path.md` — controller bootstrap when the live runtime cannot
|
||||
review its own fix (#557)
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
"""Authoritative rule for editing an issue's title and body (#781).
|
||||
|
||||
The workflow documented a ``gitea_edit_issue`` tool that was never registered,
|
||||
so an authorized body correction on an issue had no sanctioned path at all: the
|
||||
only edit tool, ``gitea_edit_pr``, PATCHes the pull-request endpoint and cannot
|
||||
target an issue. This module is the rule that path is built on, kept separate
|
||||
from the pull-request edit path by construction.
|
||||
|
||||
- :func:`validate_edit_request` rejects structurally invalid requests before any
|
||||
credential, network, or profile work happens. A request that names no field,
|
||||
or names one with the wrong type, is a pure input error.
|
||||
- :func:`assess_issue_target` refuses a pull request. Gitea serves pull requests
|
||||
from the same ``/issues/{n}`` collection, so without this check the issue edit
|
||||
path would quietly become a second, ungated PR edit path.
|
||||
- :func:`plan_issue_edit` decides the exact PATCH payload from the pre-image. It
|
||||
only ever sends fields the caller named, and it reports a request that would
|
||||
change nothing as an explicit no-op rather than a silent success.
|
||||
- :func:`verify_issue_edit` is the read-after-write check. It proves the applied
|
||||
title/body match what was requested *and* that every field the caller did not
|
||||
name — state, labels, assignees, milestone — is unchanged.
|
||||
|
||||
This module performs no I/O — callers own the Gitea API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
import issue_workflow_labels
|
||||
|
||||
#: Fields this tool is allowed to change. Anything else must be untouched.
|
||||
EDITABLE_FIELDS: tuple[str, ...] = ("title", "body")
|
||||
|
||||
#: Fields the caller never names and which must survive an edit verbatim.
|
||||
PRESERVED_FIELDS: tuple[str, ...] = ("state", "labels", "assignees", "milestone")
|
||||
|
||||
|
||||
def validate_edit_request(
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Return the requested field map, failing closed on an invalid request.
|
||||
|
||||
Raises ``ValueError`` when no field is named, when a named field is not a
|
||||
string, or when a title is blank. An empty *body* is legitimate — clearing
|
||||
an issue description is a real edit — but an empty title is not, because
|
||||
Gitea has no issue without one.
|
||||
"""
|
||||
requested: dict[str, str] = {}
|
||||
|
||||
if title is not None:
|
||||
if not isinstance(title, str):
|
||||
raise ValueError(
|
||||
f"Invalid title type {type(title).__name__}: title must be a "
|
||||
"string (fail closed)."
|
||||
)
|
||||
if not title.strip():
|
||||
raise ValueError(
|
||||
"Invalid title: an issue title cannot be blank. Pass the exact "
|
||||
"replacement title, or omit title= to leave it unchanged "
|
||||
"(fail closed)."
|
||||
)
|
||||
requested["title"] = title
|
||||
|
||||
if body is not None:
|
||||
if not isinstance(body, str):
|
||||
raise ValueError(
|
||||
f"Invalid body type {type(body).__name__}: body must be a "
|
||||
"string (fail closed)."
|
||||
)
|
||||
requested["body"] = body
|
||||
|
||||
if not requested:
|
||||
raise ValueError(
|
||||
"At least one field to edit (title, body) must be provided. "
|
||||
"gitea_edit_issue never edits state, labels, assignees, or "
|
||||
"milestone (fail closed)."
|
||||
)
|
||||
|
||||
return requested
|
||||
|
||||
|
||||
def assess_issue_target(
|
||||
issue: Mapping[str, Any],
|
||||
*,
|
||||
issue_number: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Confirm the fetched object is an issue and not a pull request.
|
||||
|
||||
Gitea serves pull requests from ``/issues/{n}`` as well, so a PR number
|
||||
reaches this path unchallenged. Issue and pull-request edits stay separate
|
||||
capabilities, so a PR target is refused here rather than silently PATCHed.
|
||||
"""
|
||||
is_pull_request = bool(issue.get("pull_request"))
|
||||
return {
|
||||
"is_issue": not is_pull_request,
|
||||
"is_pull_request": is_pull_request,
|
||||
"reasons": (
|
||||
[
|
||||
f"#{issue_number} is a pull request, not an issue; "
|
||||
"gitea_edit_issue never edits pull requests"
|
||||
]
|
||||
if is_pull_request
|
||||
else []
|
||||
),
|
||||
"safe_next_action": (
|
||||
f"Use gitea_edit_pr for pull request #{issue_number}."
|
||||
if is_pull_request
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def preserved_snapshot(issue: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Capture the fields an edit must leave alone, in a comparable shape."""
|
||||
return {
|
||||
"state": issue.get("state"),
|
||||
"labels": issue_workflow_labels.label_names(issue),
|
||||
"assignees": _assignee_names(issue),
|
||||
"milestone": _milestone_key(issue),
|
||||
}
|
||||
|
||||
|
||||
def _assignee_names(issue: Mapping[str, Any]) -> list[str]:
|
||||
names: list[str] = []
|
||||
for entry in issue.get("assignees") or []:
|
||||
if isinstance(entry, Mapping):
|
||||
login = entry.get("login") or entry.get("username")
|
||||
else:
|
||||
login = entry
|
||||
if login:
|
||||
names.append(str(login))
|
||||
return names
|
||||
|
||||
|
||||
def _milestone_key(issue: Mapping[str, Any]) -> str | None:
|
||||
milestone = issue.get("milestone")
|
||||
if not milestone:
|
||||
return None
|
||||
if isinstance(milestone, Mapping):
|
||||
key = milestone.get("title") or milestone.get("id")
|
||||
return None if key is None else str(key)
|
||||
return str(milestone)
|
||||
|
||||
|
||||
def plan_issue_edit(
|
||||
current: Mapping[str, Any],
|
||||
*,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Plan the PATCH payload for an issue edit against its pre-image.
|
||||
|
||||
Only fields the caller named are ever put in the payload, so unspecified
|
||||
fields cannot be overwritten with a stale read. A request whose named fields
|
||||
already hold the requested values is reported as a no-op with an actionable
|
||||
reason instead of being sent and reported as a success.
|
||||
"""
|
||||
requested = validate_edit_request(title=title, body=body)
|
||||
number = issue_number if issue_number is not None else current.get("number")
|
||||
|
||||
changes: dict[str, dict[str, Any]] = {}
|
||||
unchanged: list[str] = []
|
||||
for field, value in requested.items():
|
||||
before = current.get(field)
|
||||
if field == "body":
|
||||
before = before or ""
|
||||
if before == value:
|
||||
unchanged.append(field)
|
||||
else:
|
||||
changes[field] = {"before": before, "after": value}
|
||||
|
||||
no_op = not changes
|
||||
payload = {field: requested[field] for field in changes}
|
||||
|
||||
return {
|
||||
"issue_number": number,
|
||||
"requested_fields": sorted(requested),
|
||||
"requested": dict(requested),
|
||||
"payload": payload,
|
||||
"changes": changes,
|
||||
"unchanged_fields": sorted(unchanged),
|
||||
"no_op": no_op,
|
||||
"preserved_before": preserved_snapshot(current),
|
||||
"reasons": (
|
||||
[
|
||||
"requested "
|
||||
+ ", ".join(sorted(unchanged))
|
||||
+ " already match the issue's current content; no edit was sent"
|
||||
]
|
||||
if no_op
|
||||
else []
|
||||
),
|
||||
"safe_next_action": (
|
||||
(
|
||||
f"Re-read issue #{number} and call gitea_edit_issue only with "
|
||||
"content that differs, or drop the call if the issue is already "
|
||||
"correct."
|
||||
)
|
||||
if no_op
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def verify_issue_edit(
|
||||
observed: Mapping[str, Any],
|
||||
*,
|
||||
plan: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Read-after-write proof for an applied issue edit.
|
||||
|
||||
Fails closed on two distinct defects: an edited field whose stored value is
|
||||
not what was requested, and an untouched field that moved anyway.
|
||||
"""
|
||||
requested = dict(plan.get("requested") or {})
|
||||
number = plan.get("issue_number")
|
||||
|
||||
applied: dict[str, Any] = {}
|
||||
mismatches: list[dict[str, Any]] = []
|
||||
for field, expected in requested.items():
|
||||
actual = observed.get(field)
|
||||
if field == "body":
|
||||
actual = actual or ""
|
||||
applied[field] = actual
|
||||
if actual != expected:
|
||||
mismatches.append(
|
||||
{"field": field, "expected": expected, "observed": actual}
|
||||
)
|
||||
|
||||
before = dict(plan.get("preserved_before") or {})
|
||||
after = preserved_snapshot(observed)
|
||||
preserved_changed: list[dict[str, Any]] = [
|
||||
{"field": field, "before": before.get(field), "after": after.get(field)}
|
||||
for field in PRESERVED_FIELDS
|
||||
if before.get(field) != after.get(field)
|
||||
]
|
||||
|
||||
reasons: list[str] = []
|
||||
for entry in mismatches:
|
||||
reasons.append(
|
||||
f"{entry['field']} was not applied: requested "
|
||||
f"{entry['expected']!r} but the issue stores {entry['observed']!r}"
|
||||
)
|
||||
for entry in preserved_changed:
|
||||
reasons.append(
|
||||
f"{entry['field']} changed during the edit: {entry['before']!r} "
|
||||
f"became {entry['after']!r}; gitea_edit_issue must leave it alone"
|
||||
)
|
||||
|
||||
verified = not reasons
|
||||
return {
|
||||
"verified": verified,
|
||||
"applied": applied,
|
||||
"mismatches": mismatches,
|
||||
"preserved_before": before,
|
||||
"preserved_after": after,
|
||||
"preserved_changed": preserved_changed,
|
||||
"preserved_intact": not preserved_changed,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if verified
|
||||
else (
|
||||
f"Re-read issue #{number} with gitea_view_issue and reconcile it "
|
||||
"before treating the edit as applied. Do not retry blindly — the "
|
||||
"stored content does not match what was requested."
|
||||
)
|
||||
),
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import reviewer_handoff_consistency
|
||||
import thread_state_ledger_validator
|
||||
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||
from self_propagating_handoff import (
|
||||
HANDOFF_HEADING as SELF_PROPAGATING_HANDOFF_HEADING,
|
||||
assess_final_report_self_propagating_handoff,
|
||||
)
|
||||
from review_proofs import (
|
||||
HANDOFF_HEADING,
|
||||
assess_controller_handoff,
|
||||
@@ -728,6 +732,65 @@ def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
|
||||
)
|
||||
|
||||
|
||||
_MUTATION_ACCOUNTING_PATTERNS = {
|
||||
"local_failed_attempts": re.compile(
|
||||
r"local\s+failed\s+attempts\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
"blocked_api_attempts": re.compile(
|
||||
r"blocked\s+api\s+attempts\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
"successful_server_mutations": re.compile(
|
||||
r"successful\s+server(?:[-\s]side)?\s+mutations\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
}
|
||||
|
||||
_READBACK_VERIFIED_PATTERN = re.compile(
|
||||
r"read[-\s]?after[-\s]?write\s+verified\s*:\s*(yes|true)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_mutation_budget_accounting(
|
||||
report_text: str,
|
||||
*,
|
||||
mutation_attempt_ledger: list[dict] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""#617: mutation budget counts server-side changes only.
|
||||
|
||||
No-op unless the session supplies an attempt ledger. When it does, the
|
||||
report's three attempt categories must match the ledger exactly, so a
|
||||
pre-API validator rejection can never be reported as a Gitea mutation and
|
||||
a real mutation can never be hidden.
|
||||
"""
|
||||
if mutation_attempt_ledger is None:
|
||||
return []
|
||||
|
||||
from mutation_budget_classifier import assess_final_report_mutation_accounting
|
||||
|
||||
text = report_text or ""
|
||||
claimed: dict[str, Any] = {}
|
||||
for field, pattern in _MUTATION_ACCOUNTING_PATTERNS.items():
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
claimed[field] = int(match.group(1))
|
||||
if _READBACK_VERIFIED_PATTERN.search(text):
|
||||
claimed["readback_verified"] = True
|
||||
|
||||
result = assess_final_report_mutation_accounting(claimed, mutation_attempt_ledger)
|
||||
if result.get("valid"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.mutation_budget_accounting",
|
||||
result.get("reasons") or [],
|
||||
field="Mutation accounting",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"report 'Local failed attempts:', 'Blocked API attempts:', and "
|
||||
"'Successful server-side mutations:' with counts matching the "
|
||||
"attempt ledger; pre-API rejections are not Gitea mutations"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from conflict_fix_classification import (
|
||||
assess_conflict_fix_classification_final_report,
|
||||
@@ -1564,6 +1627,21 @@ def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, st
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_self_propagating_handoff(report_text: str) -> list[dict[str, str]]:
|
||||
"""#626: a report that adopts the handoff protocol must complete it."""
|
||||
result = assess_final_report_self_propagating_handoff(report_text)
|
||||
if not result.get("applicable") or not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.self_propagating_handoff",
|
||||
result.get("reasons") or ["incomplete canonical handoff"],
|
||||
field=SELF_PROPAGATING_HANDOFF_HEADING,
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "complete every canonical handoff field before posting",
|
||||
)
|
||||
|
||||
|
||||
_SHARED_ISSUE_LOCK_RULES = (
|
||||
_rule_shared_issue_lock_external_state,
|
||||
_rule_shared_manual_lock_pr_override,
|
||||
@@ -1584,13 +1662,24 @@ _SHARED_CANONICAL_COMMENT_RULES = (
|
||||
_rule_shared_canonical_comment_post_claim,
|
||||
)
|
||||
|
||||
_SHARED_MUTATION_BUDGET_RULES = (
|
||||
_rule_shared_mutation_budget_accounting,
|
||||
)
|
||||
|
||||
# #626: enforced for every task kind that can continue the workflow chain.
|
||||
_SHARED_SELF_PROPAGATING_HANDOFF_RULES = (
|
||||
_rule_shared_self_propagating_handoff,
|
||||
)
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_pr": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
@@ -1620,6 +1709,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"merge_pr": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
@@ -1631,11 +1721,13 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"reconcile_already_landed": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_reconcile_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
*_SHARED_CLEANUP_PROOF_RULES,
|
||||
_rule_reconcile_stale_author_fields,
|
||||
@@ -1650,20 +1742,24 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_audit_reconciliation_boundary,
|
||||
],
|
||||
"author_issue": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
],
|
||||
"work_issue": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_shared_issue_acceptance_gate,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
@@ -1672,28 +1768,34 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_worktree_cleanup_audit_proof,
|
||||
],
|
||||
"issue_filing": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
"inventory": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_pagination_proof,
|
||||
],
|
||||
"issue_selection": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
# Controller issue closure (#529): a closure report must not bury an
|
||||
@@ -1701,6 +1803,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
# Kept intentionally narrow so a closure pre-check does not demand the
|
||||
# full reviewer/author handoff schema.
|
||||
"controller_close": [
|
||||
*_SHARED_SELF_PROPAGATING_HANDOFF_RULES,
|
||||
_rule_reviewer_premerge_baseline_proof,
|
||||
],
|
||||
}
|
||||
@@ -1766,6 +1869,7 @@ def assess_final_report_validator(
|
||||
session_pr_opened: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
reconciler_close_lock: dict | None = None,
|
||||
mutation_attempt_ledger: list[dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate final-report text against task-specific proof rules (#327).
|
||||
|
||||
@@ -1829,6 +1933,7 @@ def assess_final_report_validator(
|
||||
"session_pr_opened": session_pr_opened,
|
||||
"validation_session": validation_session,
|
||||
"reconciler_close_lock": reconciler_close_lock,
|
||||
"mutation_attempt_ledger": mutation_attempt_ledger,
|
||||
}
|
||||
|
||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"execution_profile": "example-author",
|
||||
"audit_label": "example-author",
|
||||
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
|
||||
"forbidden_operations": ["approve", "request_changes", "merge"]
|
||||
},
|
||||
|
||||
+51
-3
@@ -182,11 +182,51 @@ def get_auth_header(host):
|
||||
|
||||
def resolve_remote(args):
|
||||
"""Given parsed argparse args with --remote/--host/--org/--repo,
|
||||
return (host, org, repo) with overrides applied."""
|
||||
return (host, org, repo) with overrides applied.
|
||||
|
||||
#714 / #530: when the caller omits org and/or repo, prefer the
|
||||
workspace-aligned git remote over REMOTES defaults (e.g. bare
|
||||
``--remote prgs`` must not force Timesheet when the checkout is
|
||||
Gitea-Tools). Explicit --org/--repo always win.
|
||||
"""
|
||||
profile = REMOTES[args.remote]
|
||||
host = args.host or profile["host"]
|
||||
org = args.org or profile["org"]
|
||||
repo = args.repo or profile["repo"]
|
||||
org_explicit = getattr(args, "org", None) is not None
|
||||
repo_explicit = getattr(args, "repo", None) is not None
|
||||
org = args.org if org_explicit else profile["org"]
|
||||
repo = args.repo if repo_explicit else profile["repo"]
|
||||
if not org_explicit or not repo_explicit:
|
||||
try:
|
||||
import remote_repo_guard
|
||||
import subprocess
|
||||
# Prefer the named remote URL when present; fall back to origin.
|
||||
url = None
|
||||
for remote_name in (args.remote, "origin"):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "remote", "get-url", remote_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and (proc.stdout or "").strip():
|
||||
url = proc.stdout.strip()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
# Allow tests to inject a deterministic remote URL without Git.
|
||||
env_url = os.environ.get("GITEA_TEST_WORKSPACE_REMOTE_URL")
|
||||
if env_url:
|
||||
url = env_url
|
||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||
if parsed:
|
||||
if not org_explicit:
|
||||
org = parsed[0]
|
||||
if not repo_explicit:
|
||||
repo = parsed[1]
|
||||
except Exception:
|
||||
pass
|
||||
return host, org, repo
|
||||
|
||||
|
||||
@@ -758,6 +798,14 @@ def get_profile():
|
||||
"profile_name": name,
|
||||
"allowed_operations": ops,
|
||||
"forbidden_operations": forbidden,
|
||||
# #714 repository authorization boundary. Config-only on purpose: an
|
||||
# environment variable must never widen or forge the set of
|
||||
# repositories a session may bind to.
|
||||
"allowed_repositories": _json_list("allowed_repositories"),
|
||||
# #706 cross-repository canonical root binding. Config-sourced here (the
|
||||
# namespace-scoped GITEA_CANONICAL_REPOSITORY_ROOT env override is applied
|
||||
# by canonical_repository_root.configured_canonical_root, not widened here).
|
||||
"canonical_repository_root": jp.get("canonical_repository_root") or None,
|
||||
"audit_label": audit_label,
|
||||
"token_source_name": token_source,
|
||||
"auth_source_type": auth_type,
|
||||
|
||||
@@ -272,6 +272,15 @@ def load_config(path=None):
|
||||
)
|
||||
if not isinstance(data.get("profiles"), dict):
|
||||
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
|
||||
|
||||
|
||||
@@ -358,6 +367,14 @@ def _flatten_identity(env_name, svc_name, svc, ident_name, ident):
|
||||
for key in ("role", "username", "execution_profile", "audit_label"):
|
||||
if ident.get(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
|
||||
|
||||
|
||||
@@ -475,6 +492,57 @@ def _require_enabled(kind, name, obj):
|
||||
return enabled
|
||||
|
||||
|
||||
_REPO_SCOPE_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
|
||||
|
||||
|
||||
def _validate_allowed_repositories(name, raw):
|
||||
"""Validate the optional per-profile repository authorization scope (#714).
|
||||
|
||||
``allowed_repositories`` is an authorization boundary of canonical
|
||||
``owner/repository`` slugs. It is not the session binding: the verified
|
||||
workspace repository selects exactly one entry at bind time. Absent means
|
||||
"no repository scope configured" and is allowed, so existing profiles keep
|
||||
working until an operator provisions the field.
|
||||
"""
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, list):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' allowed_repositories must be a list of "
|
||||
"'owner/repository' strings"
|
||||
)
|
||||
for entry in raw:
|
||||
if not isinstance(entry, str) or not _REPO_SCOPE_RE.match(entry.strip()):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' allowed_repositories entry {entry!r} is not "
|
||||
"a canonical 'owner/repository' slug"
|
||||
)
|
||||
|
||||
|
||||
def _validate_canonical_repository_root(name, raw):
|
||||
"""Validate the optional per-profile canonical repository root (#706).
|
||||
|
||||
``canonical_repository_root`` binds a cross-repository namespace to the
|
||||
working root of its target repository (separate from the immutable
|
||||
Gitea-Tools install checkout). It is an absolute filesystem path; existence
|
||||
and git identity are validated at bind time by the runtime guard, not here
|
||||
(config validation stays filesystem-independent). Absent means the
|
||||
single-repo default and is allowed.
|
||||
"""
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
raise ConfigError(
|
||||
f"profile '{name}' canonical_repository_root must be a non-empty "
|
||||
"absolute path string to the target repository working root"
|
||||
)
|
||||
if not os.path.isabs(raw.strip()):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' canonical_repository_root {raw!r} must be an "
|
||||
"absolute path"
|
||||
)
|
||||
|
||||
|
||||
def _reject_inline_secrets(kind, name, obj):
|
||||
for key in _INLINE_SECRET_KEYS:
|
||||
if key in obj:
|
||||
@@ -549,6 +617,10 @@ def _load_v2_contexts(data, path):
|
||||
forbidden = raw.get("forbidden_operations") or []
|
||||
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
||||
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
||||
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
|
||||
_validate_canonical_repository_root(
|
||||
name, raw.get("canonical_repository_root")
|
||||
)
|
||||
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
||||
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
||||
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
||||
|
||||
+4133
-477
File diff suppressed because it is too large
Load Diff
@@ -484,6 +484,78 @@ def build_gitea_issue_body(inc: NormalizedIncident) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def incident_recurred(
|
||||
existing: dict[str, Any], inc: NormalizedIncident
|
||||
) -> tuple[bool, str]:
|
||||
"""Did new provider events arrive since the existing link was last synced?
|
||||
|
||||
AC4 asks for a recurrence comment when events *continue*, so a scan that
|
||||
observes no new events must stay silent instead of re-posting the same
|
||||
state on every pass.
|
||||
"""
|
||||
old_count = existing.get("event_count")
|
||||
new_count = inc.event_count
|
||||
if (
|
||||
isinstance(old_count, int)
|
||||
and isinstance(new_count, int)
|
||||
and new_count > old_count
|
||||
):
|
||||
return True, f"event_count advanced {old_count} -> {new_count}"
|
||||
old_seen = str(existing.get("last_seen") or "").strip()
|
||||
new_seen = str(inc.last_seen or "").strip()
|
||||
if new_seen and new_seen != old_seen:
|
||||
return True, f"last_seen advanced '{old_seen}' -> '{new_seen}'"
|
||||
return False, "no new provider events since the last sync"
|
||||
|
||||
|
||||
def build_recurrence_comment_body(
|
||||
inc: NormalizedIncident, existing: dict[str, Any], *, reason: str = ""
|
||||
) -> str:
|
||||
"""Sanitized recurrence comment for an already-linked Gitea issue (AC4).
|
||||
|
||||
Uses the same redaction path as :func:`build_gitea_issue_body`; never
|
||||
carries tokens, raw paths, or session state.
|
||||
"""
|
||||
lines = [
|
||||
"## Observability incident recurrence (bridge #612)",
|
||||
"",
|
||||
"<!-- mcp-incident-bridge:recurrence:v1 -->",
|
||||
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
|
||||
"",
|
||||
f"Continued `{inc.provider}` events for this linked incident.",
|
||||
"",
|
||||
f"- **provider_issue_id:** `{inc.provider_issue_id}`",
|
||||
]
|
||||
if inc.provider_short_id:
|
||||
lines.append(f"- **provider_short_id:** `{inc.provider_short_id}`")
|
||||
if inc.provider_permalink:
|
||||
lines.append(f"- **provider_url:** {inc.provider_permalink}")
|
||||
lines.extend(
|
||||
[
|
||||
f"- **event_count:** `{existing.get('event_count')}` -> "
|
||||
f"`{inc.event_count if inc.event_count is not None else ''}`",
|
||||
f"- **first_seen:** `{inc.first_seen or ''}`",
|
||||
f"- **last_seen:** `{inc.last_seen or ''}`",
|
||||
f"- **environment:** `{inc.environment or ''}`",
|
||||
f"- **severity:** `{inc.severity or ''}`",
|
||||
f"- **culprit:** `{inc.culprit or ''}`",
|
||||
f"- **status:** `{inc.status}`",
|
||||
f"- **recurrence_basis:** `{reason}`",
|
||||
"",
|
||||
"### Latest summary",
|
||||
"",
|
||||
redact_text(inc.summary) or "(no summary)",
|
||||
"",
|
||||
"### Canonical next action",
|
||||
"",
|
||||
"Author: this incident is still firing — investigate under the "
|
||||
"normal Gitea workflow. This comment records observability "
|
||||
"recurrence only and changes no workflow state.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | None:
|
||||
"""Fail closed if existing link targets a different Gitea issue/repo."""
|
||||
eg_org = str(existing.get("gitea_org") or "")
|
||||
@@ -514,6 +586,9 @@ def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | N
|
||||
CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]]
|
||||
# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...}
|
||||
|
||||
CommentIssueFn = Callable[[int, str, str, str], dict[str, Any]]
|
||||
# comment_issue_fn(gitea_issue_number, body, gitea_org, gitea_repo) -> {"success": bool, ...}
|
||||
|
||||
|
||||
def reconcile_incident(
|
||||
db: ControlPlaneDB | None,
|
||||
@@ -523,6 +598,7 @@ def reconcile_incident(
|
||||
mapping: ProjectMapping | None = None,
|
||||
apply: bool = False,
|
||||
create_issue_fn: CreateIssueFn | None = None,
|
||||
comment_issue_fn: CommentIssueFn | None = None,
|
||||
force_gitea_issue_number: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reconcile one observation into incident_links + optional Gitea issue.
|
||||
@@ -531,6 +607,11 @@ def reconcile_incident(
|
||||
*apply=True*: upsert link; create Gitea issue when none linked (requires
|
||||
``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link.
|
||||
|
||||
When an existing link is reused and the provider reports *new* events,
|
||||
``comment_issue_fn`` posts a sanitized recurrence comment on the linked
|
||||
Gitea issue (AC4). Dry runs never comment, and a missing
|
||||
``comment_issue_fn`` withholds the comment without failing the link.
|
||||
|
||||
Never creates control-plane ``work_items`` for raw incidents.
|
||||
"""
|
||||
base: dict[str, Any] = {
|
||||
@@ -549,6 +630,7 @@ def reconcile_incident(
|
||||
"gitea_issue": None,
|
||||
"action": None,
|
||||
"mapping": None,
|
||||
"recurrence_comment": None,
|
||||
"substrate": "control_plane_db.incident_links",
|
||||
"durable_work_system": "gitea_issues",
|
||||
}
|
||||
@@ -652,10 +734,14 @@ def reconcile_incident(
|
||||
# --- apply path ---
|
||||
issue_number: int | None = None
|
||||
created = False
|
||||
recurrence: tuple[bool, str] | None = None
|
||||
if existing:
|
||||
issue_number = int(existing["gitea_issue_number"])
|
||||
action = "updated_existing_link"
|
||||
outcome = OUTCOME_UPDATED
|
||||
# Compare against the pre-upsert link row: the upsert below overwrites
|
||||
# event_count/last_seen, which would erase the recurrence signal.
|
||||
recurrence = incident_recurred(existing, inc)
|
||||
elif force_gitea_issue_number is not None:
|
||||
issue_number = int(force_gitea_issue_number)
|
||||
action = "link_explicit_issue"
|
||||
@@ -729,6 +815,63 @@ def reconcile_incident(
|
||||
}
|
||||
return base
|
||||
|
||||
# AC4: continued provider events post a recurrence comment on the linked
|
||||
# Gitea issue. The durable incident_links row is already written above, so
|
||||
# a comment failure never rolls back or blocks the mapping — the next scan
|
||||
# retries while the link stays authoritative.
|
||||
if outcome == OUTCOME_UPDATED and recurrence is not None:
|
||||
recurred, why = recurrence
|
||||
if not recurred:
|
||||
base["recurrence_comment"] = {"posted": False, "reason": why}
|
||||
elif comment_issue_fn is None:
|
||||
base["recurrence_comment"] = {
|
||||
"posted": False,
|
||||
"reason": (
|
||||
"no comment_issue_fn supplied; recurrence comment withheld "
|
||||
"(link remains durable)"
|
||||
),
|
||||
"recurrence_basis": why,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
comment_res = comment_issue_fn(
|
||||
issue_number,
|
||||
build_recurrence_comment_body(inc, existing, reason=why),
|
||||
inc.gitea_org,
|
||||
inc.gitea_repo,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - never break the link write
|
||||
base["recurrence_comment"] = {
|
||||
"posted": False,
|
||||
"reason": (
|
||||
f"recurrence comment failed: {redact_text(exc)} "
|
||||
"(link remains durable)"
|
||||
),
|
||||
"recurrence_basis": why,
|
||||
}
|
||||
else:
|
||||
posted = (
|
||||
bool(comment_res.get("success"))
|
||||
if isinstance(comment_res, dict)
|
||||
else bool(comment_res)
|
||||
)
|
||||
base["recurrence_comment"] = {
|
||||
"posted": posted,
|
||||
"recurrence_basis": why,
|
||||
"gitea_issue_number": issue_number,
|
||||
"comment_id": (
|
||||
comment_res.get("comment_id")
|
||||
if isinstance(comment_res, dict)
|
||||
else None
|
||||
),
|
||||
}
|
||||
if posted:
|
||||
base["gitea_mutated"] = True
|
||||
elif isinstance(comment_res, dict):
|
||||
base["recurrence_comment"]["reasons"] = [
|
||||
redact_text(r) for r in (comment_res.get("reasons") or [])
|
||||
]
|
||||
|
||||
base["success"] = True
|
||||
base["performed"] = True
|
||||
base["db_mutated"] = True
|
||||
|
||||
@@ -85,6 +85,15 @@ def _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
||||
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(
|
||||
*,
|
||||
issue_number: int,
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
"""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.
|
||||
|
||||
#768 extends the head requirement from strict equality to "equal, or a strict
|
||||
descendant". Equality alone made remediation after a session death unreachable:
|
||||
recovery needs a clean worktree, the only sanctioned way to clean one without
|
||||
discarding work is to commit, and committing advances the head past the value
|
||||
recorded at lock time. A commit that strictly descends from the recorded head,
|
||||
on the same branch, in the same worktree, by the same claimant, preserves
|
||||
everything equality protected — the recorded head is still reachable, still an
|
||||
ancestor, still unmodified — so it is accepted, and nothing else is. The
|
||||
descendant fact is observed server-side by
|
||||
``issue_lock_worktree.read_head_ancestry`` and handed in as ``head_ancestry``;
|
||||
no caller can assert it.
|
||||
|
||||
#772 adds the remaining uncovered quadrant: a claim that was never published at
|
||||
all. Two recovery modes now exist, and they require different evidence because
|
||||
they are answering the same question against different available facts:
|
||||
|
||||
``published_owning_pr``
|
||||
The branch exists on the remote. Ownership is proven by comparing the local
|
||||
head against the remote/PR head — equal (#753) or a strict descendant
|
||||
(#768). This is the pre-existing behavior and is unchanged.
|
||||
|
||||
``unpublished_claim``
|
||||
The branch is absent from the remote and no PR claims it, so there is no
|
||||
head to compare against; that absence is the defining fact, not a degraded
|
||||
published case. Ownership is instead proven by the durable lock record
|
||||
(issue, branch, worktree, claimant, profile, dead PID) plus the local HEAD
|
||||
strictly descending from the base the branch was cut from, observed
|
||||
server-side by ``issue_lock_worktree.read_recorded_base`` and re-checked
|
||||
through ``base_ancestry``.
|
||||
|
||||
They cannot share one head-comparison implementation: the published path's
|
||||
comparison target does not exist in the unpublished case, and inventing one
|
||||
(defaulting to the base, say) would silently weaken the published path from
|
||||
"matches what was actually pushed" to "descends from some base". The modes are
|
||||
therefore selected by observed publication state and never by a caller — and
|
||||
critically, the absence of a remote head is never itself treated as permission:
|
||||
every identity, profile, branch, worktree, cleanliness, liveness, and competing
|
||||
-claim check still applies in full.
|
||||
"""
|
||||
|
||||
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")
|
||||
|
||||
# How the clean local head relates to the head recorded at lock time (#768).
|
||||
HEAD_RELATION_EQUAL = "equal"
|
||||
HEAD_RELATION_STRICT_DESCENDANT = "strict_descendant"
|
||||
# #772: an unpublished claim has no recorded head to compare against at all, so
|
||||
# its head is measured against the base the branch was cut from instead.
|
||||
HEAD_RELATION_DESCENDS_FROM_BASE = "descends_from_recorded_base"
|
||||
|
||||
# Which body of evidence a recovery was decided on (#772 AC10). These are not
|
||||
# interchangeable: a published claim proves ownership against a remote/PR head,
|
||||
# an unpublished one against the recorded base plus durable lock state. They
|
||||
# cannot share a single head-comparison implementation because the unpublished
|
||||
# case has no head to compare — that absence is the defining fact, not a
|
||||
# degraded version of the published case.
|
||||
RECOVERY_MODE_PUBLISHED_OWNING_PR = "published_owning_pr"
|
||||
RECOVERY_MODE_UNPUBLISHED_CLAIM = "unpublished_claim"
|
||||
|
||||
|
||||
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_strict_descendant(
|
||||
head_ancestry: Mapping[str, Any] | None,
|
||||
*,
|
||||
recorded_head: str,
|
||||
local_head: str,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Is ``local_head`` a proven strict descendant of ``recorded_head`` (#768)?
|
||||
|
||||
``head_ancestry`` is the server-side git observation from
|
||||
``issue_lock_worktree.read_head_ancestry``. Its own ``ancestor_sha`` /
|
||||
``descendant_sha`` are re-checked against the heads this assessment is
|
||||
actually reasoning about, so a probe taken for some other pair of commits —
|
||||
stale, mismatched, or hand-built — can never authorize a waiver.
|
||||
|
||||
Returns ``(proven, notes)``. Notes name the exact missing element so a
|
||||
refused caller sees why, never a bare "unproven".
|
||||
"""
|
||||
if not isinstance(head_ancestry, Mapping):
|
||||
return False, [
|
||||
"no server-derived ancestry observation was available; a local head "
|
||||
"that differs from the recorded head cannot be accepted"
|
||||
]
|
||||
|
||||
notes: list[str] = []
|
||||
probe_ancestor = _text(head_ancestry.get("ancestor_sha"))
|
||||
probe_descendant = _text(head_ancestry.get("descendant_sha"))
|
||||
if probe_ancestor != recorded_head or probe_descendant != local_head:
|
||||
return False, [
|
||||
f"ancestry observation covers {probe_ancestor or 'unknown'} -> "
|
||||
f"{probe_descendant or 'unknown'}, not the heads under assessment "
|
||||
f"({recorded_head} -> {local_head})"
|
||||
]
|
||||
if not head_ancestry.get("probe_ok"):
|
||||
notes.extend(
|
||||
list(head_ancestry.get("reasons") or [])
|
||||
or ["ancestry probe did not complete; ancestry unproven"]
|
||||
)
|
||||
return False, notes
|
||||
if not head_ancestry.get("ancestor_present"):
|
||||
return False, [
|
||||
f"recorded head {recorded_head} is no longer reachable; a rewritten "
|
||||
"or force-moved head cannot be recovered"
|
||||
]
|
||||
if not head_ancestry.get("is_strict_descendant"):
|
||||
notes.extend(
|
||||
list(head_ancestry.get("reasons") or [])
|
||||
or [
|
||||
f"local head {local_head} is not a strict descendant of the "
|
||||
f"recorded head {recorded_head}"
|
||||
]
|
||||
)
|
||||
return False, notes
|
||||
|
||||
proof = _text(head_ancestry.get("proof")) or (
|
||||
f"{recorded_head} is an ancestor of {local_head}"
|
||||
)
|
||||
return True, [
|
||||
f"local head {local_head} strictly descends from recorded head "
|
||||
f"{recorded_head} ({proof})"
|
||||
]
|
||||
|
||||
|
||||
def _assess_base_descendancy(
|
||||
base_ancestry: Mapping[str, Any] | None,
|
||||
*,
|
||||
recorded_base: str,
|
||||
local_head: str,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Is ``local_head`` a proven strict descendant of ``recorded_base`` (#772)?
|
||||
|
||||
The unpublished-claim analogue of ``_assess_strict_descendant``. The
|
||||
comparison target is the base the branch was cut from — observed server-side
|
||||
by ``issue_lock_worktree.read_recorded_base`` — rather than a remote or PR
|
||||
head, because an unpublished claim has neither.
|
||||
|
||||
The probe's own endpoints are re-checked against the values under
|
||||
assessment, so an observation taken for some other pair of commits cannot
|
||||
authorize recovery. Equality is refused: a HEAD that merely equals its base
|
||||
carries no committed work, and that is the ordinary base-equivalent case the
|
||||
normal lock path already handles.
|
||||
"""
|
||||
if not isinstance(base_ancestry, Mapping):
|
||||
return False, [
|
||||
"no server-derived ancestry observation was available; an "
|
||||
"unpublished claim cannot be recovered without proving its HEAD "
|
||||
"descends from the recorded base"
|
||||
]
|
||||
|
||||
probe_ancestor = _text(base_ancestry.get("ancestor_sha"))
|
||||
probe_descendant = _text(base_ancestry.get("descendant_sha"))
|
||||
if probe_ancestor != recorded_base or probe_descendant != local_head:
|
||||
return False, [
|
||||
f"ancestry observation covers {probe_ancestor or 'unknown'} -> "
|
||||
f"{probe_descendant or 'unknown'}, not the commits under assessment "
|
||||
f"({recorded_base} -> {local_head})"
|
||||
]
|
||||
if not base_ancestry.get("probe_ok"):
|
||||
return False, (
|
||||
list(base_ancestry.get("reasons") or [])
|
||||
or ["ancestry probe did not complete; ancestry unproven"]
|
||||
)
|
||||
if not base_ancestry.get("ancestor_present"):
|
||||
return False, [
|
||||
f"recorded base {recorded_base} is no longer reachable; a rewritten "
|
||||
"or force-moved base cannot be recovered"
|
||||
]
|
||||
if not base_ancestry.get("is_strict_descendant"):
|
||||
return False, (
|
||||
list(base_ancestry.get("reasons") or [])
|
||||
or [
|
||||
f"local head {local_head} is not a strict descendant of the "
|
||||
f"recorded base {recorded_base}"
|
||||
]
|
||||
)
|
||||
|
||||
proof = _text(base_ancestry.get("proof")) or (
|
||||
f"{recorded_base} is an ancestor of {local_head}"
|
||||
)
|
||||
return True, [
|
||||
f"local head {local_head} strictly descends from recorded base "
|
||||
f"{recorded_base} ({proof})"
|
||||
]
|
||||
|
||||
|
||||
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,
|
||||
head_ancestry: Mapping[str, Any] | None = None,
|
||||
remote_branch_exists: bool | None = None,
|
||||
recorded_base_sha: str | None = None,
|
||||
base_ancestry: Mapping[str, Any] | 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 is the recorded head, or strictly descends it ──
|
||||
# The recorded head is what the remote branch still carries. A local head
|
||||
# equal to it is the #753 case. A local head that strictly descends from it
|
||||
# is the #768 case: the author committed remediation, which is the only way
|
||||
# to reach the clean worktree recovery itself demands.
|
||||
local_head = _text(head_sha)
|
||||
remote_head = _text(remote_head_sha)
|
||||
recorded_base = _text(recorded_base_sha)
|
||||
head_relation: str | None = None
|
||||
ancestry_proof: str | None = None
|
||||
if not local_head:
|
||||
reasons.append("local head SHA could not be determined")
|
||||
|
||||
# #772: which body of evidence applies is decided by observed publication
|
||||
# state, never by a caller. ``remote_branch_exists is False`` is a positive
|
||||
# server-side observation that the branch is absent from the remote — it is
|
||||
# not the same as "the head lookup failed", which must still fail closed.
|
||||
unpublished = remote_branch_exists is False and not remote_head
|
||||
recovery_mode = (
|
||||
RECOVERY_MODE_UNPUBLISHED_CLAIM if unpublished
|
||||
else RECOVERY_MODE_PUBLISHED_OWNING_PR
|
||||
)
|
||||
evidence["recovery_mode"] = recovery_mode
|
||||
evidence["remote_branch_exists"] = remote_branch_exists
|
||||
|
||||
if unpublished:
|
||||
# No remote branch: ownership is measured against the recorded base.
|
||||
# An open PR here is contradictory — a PR cannot exist without a remote
|
||||
# branch — so it is a mismatch, never a thing to reconcile.
|
||||
if _text(pr_head_sha) or pr_number is not None:
|
||||
reasons.append(
|
||||
f"branch '{locked_branch}' is absent from the remote yet PR "
|
||||
f"#{pr_number} claims it; publication state is contradictory"
|
||||
)
|
||||
if not recorded_base:
|
||||
reasons.append(
|
||||
f"recorded base for branch '{locked_branch}' could not be "
|
||||
"determined; an unpublished claim cannot be recovered without it"
|
||||
)
|
||||
if local_head and recorded_base:
|
||||
descends, notes = _assess_base_descendancy(
|
||||
base_ancestry,
|
||||
recorded_base=recorded_base,
|
||||
local_head=local_head,
|
||||
)
|
||||
if descends:
|
||||
head_relation = HEAD_RELATION_DESCENDS_FROM_BASE
|
||||
ancestry_proof = notes[0] if notes else None
|
||||
else:
|
||||
reasons.extend(notes)
|
||||
else:
|
||||
if not remote_head:
|
||||
reasons.append(
|
||||
f"remote head for branch '{locked_branch}' could not be determined"
|
||||
)
|
||||
if local_head and remote_head:
|
||||
if local_head == remote_head:
|
||||
head_relation = HEAD_RELATION_EQUAL
|
||||
else:
|
||||
descends, notes = _assess_strict_descendant(
|
||||
head_ancestry,
|
||||
recorded_head=remote_head,
|
||||
local_head=local_head,
|
||||
)
|
||||
if descends:
|
||||
head_relation = HEAD_RELATION_STRICT_DESCENDANT
|
||||
ancestry_proof = notes[0] if notes else None
|
||||
else:
|
||||
reasons.append(
|
||||
f"local head {local_head} does not match remote branch head "
|
||||
f"{remote_head}"
|
||||
)
|
||||
reasons.extend(notes)
|
||||
evidence["recorded_base"] = recorded_base or None
|
||||
evidence["local_head"] = local_head or None
|
||||
evidence["remote_head"] = remote_head or None
|
||||
# ``recorded_head`` is the head recovery is being measured against;
|
||||
# ``accepted_head`` is the head this recovery actually adopts. They differ
|
||||
# only in the descendant case, and downstream gates need both (#768 AC2/AC7).
|
||||
evidence["recorded_head"] = remote_head or None
|
||||
evidence["accepted_head"] = local_head or None
|
||||
evidence["head_relation"] = head_relation
|
||||
evidence["ancestry_proof"] = ancestry_proof
|
||||
|
||||
pr_head = _text(pr_head_sha)
|
||||
if pr_head:
|
||||
evidence["pr_head"] = pr_head
|
||||
evidence["pr_number"] = pr_number
|
||||
# In unpublished mode the presence of any PR was already refused above as
|
||||
# contradictory; re-stating it as a head mismatch would only obscure why.
|
||||
if not unpublished and local_head and pr_head != local_head:
|
||||
# A descendant recovery has not been published yet, so the open PR
|
||||
# legitimately still points at the recorded head. Any other
|
||||
# disagreement is a real mismatch.
|
||||
if not (
|
||||
head_relation == HEAD_RELATION_STRICT_DESCENDANT
|
||||
and remote_head
|
||||
and pr_head == remote_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)
|
||||
|
||||
# No disposition may be granted without a proven head relation. Every path
|
||||
# above that leaves it unset also records a reason, so this is a belt-and-
|
||||
# braces guard against a future path forgetting one (#772 AC4).
|
||||
if head_relation is None:
|
||||
return _result(
|
||||
REFUSED,
|
||||
False,
|
||||
["head relation to the recorded head or base was never proven"],
|
||||
evidence,
|
||||
)
|
||||
|
||||
proof = [
|
||||
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"
|
||||
]
|
||||
if recovery_mode == RECOVERY_MODE_UNPUBLISHED_CLAIM:
|
||||
proof.append(
|
||||
f"branch '{locked_branch}' has no remote head and no open PR; "
|
||||
f"ownership proven against recorded base {recorded_base}"
|
||||
)
|
||||
if (
|
||||
head_relation
|
||||
in (HEAD_RELATION_STRICT_DESCENDANT, HEAD_RELATION_DESCENDS_FROM_BASE)
|
||||
and ancestry_proof
|
||||
):
|
||||
proof.append(ancestry_proof)
|
||||
return _result(RECOVERY_SANCTIONED, True, proof, 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 heads
|
||||
the assessor accepted. 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.
|
||||
|
||||
#768: a descendant recovery carries two heads. ``head_sha`` stays the head
|
||||
the open PR currently shows (the recorded head, since the remediation is not
|
||||
published yet) and ``accepted_head`` is the local descendant that
|
||||
publication will move it to. Downstream gates accept either, so the
|
||||
exemption survives the very push it exists to permit.
|
||||
"""
|
||||
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"))
|
||||
recorded_head = _text(evidence.get("recorded_head")) or remote_head
|
||||
accepted_head = _text(evidence.get("accepted_head")) or local_head
|
||||
relation = _text(evidence.get("head_relation")) or HEAD_RELATION_EQUAL
|
||||
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 relation == HEAD_RELATION_EQUAL:
|
||||
if pr_head != local_head or pr_head != remote_head:
|
||||
return None
|
||||
elif relation == HEAD_RELATION_STRICT_DESCENDANT:
|
||||
# The PR must still be at the recorded head, and the accepted head must
|
||||
# actually be a different commit — otherwise this is not a descendant.
|
||||
if not recorded_head or pr_head != recorded_head:
|
||||
return None
|
||||
if not accepted_head or accepted_head == recorded_head:
|
||||
return None
|
||||
if accepted_head != local_head:
|
||||
return None
|
||||
else:
|
||||
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,
|
||||
"recorded_head": recorded_head or None,
|
||||
"accepted_head": accepted_head or None,
|
||||
"head_relation": relation,
|
||||
}
|
||||
|
||||
|
||||
def recovered_owning_pr_from_lock(
|
||||
lock_record: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Rebuild owning-PR recovery evidence from a persisted lock (#768 AC2).
|
||||
|
||||
``gitea_lock_issue`` holds the live assessment only for the duration of the
|
||||
lock call. The commit, push, create-PR, and duplicate-assessment gates run
|
||||
later, in their own calls, and re-derive ownership from scratch — so an open
|
||||
PR that recovery already proved belongs to this author reappears there as
|
||||
competing duplicate work.
|
||||
|
||||
This reads the same proof back out of the durable ``dead_session_recovery``
|
||||
block that only the server writes, on a lock the caller must already own.
|
||||
It is a re-read of server-derived state, not a new assertion: a caller that
|
||||
could forge this could equally forge the lock file itself, which every other
|
||||
ownership gate already treats as authoritative.
|
||||
"""
|
||||
if not isinstance(lock_record, Mapping):
|
||||
return None
|
||||
record = lock_record.get("dead_session_recovery")
|
||||
if not isinstance(record, Mapping) or not record.get("recovered"):
|
||||
return None
|
||||
|
||||
branch_name = _text(record.get("branch_name")) or _text(
|
||||
lock_record.get("branch_name")
|
||||
)
|
||||
pr_head = _text(record.get("pr_head"))
|
||||
recorded_head = _text(record.get("recorded_head")) or _text(
|
||||
record.get("remote_head")
|
||||
)
|
||||
accepted_head = _text(record.get("accepted_head")) or _text(
|
||||
record.get("local_head")
|
||||
)
|
||||
relation = _text(record.get("head_relation")) or HEAD_RELATION_EQUAL
|
||||
raw_pr_number = record.get("pr_number")
|
||||
raw_issue_number = lock_record.get("issue_number")
|
||||
|
||||
if raw_pr_number is None or raw_issue_number is None:
|
||||
return None
|
||||
if not branch_name or not pr_head:
|
||||
return None
|
||||
if relation == HEAD_RELATION_EQUAL:
|
||||
if accepted_head and accepted_head != pr_head:
|
||||
return None
|
||||
elif relation == HEAD_RELATION_STRICT_DESCENDANT:
|
||||
if not recorded_head or pr_head != recorded_head:
|
||||
return None
|
||||
if not accepted_head or accepted_head == recorded_head:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
pr_number = int(raw_pr_number)
|
||||
issue_number = int(raw_issue_number)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"pr_number": pr_number,
|
||||
"branch_name": branch_name,
|
||||
"head_sha": pr_head,
|
||||
"recorded_head": recorded_head or None,
|
||||
"accepted_head": accepted_head or None,
|
||||
"head_relation": relation,
|
||||
}
|
||||
|
||||
|
||||
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).
|
||||
|
||||
#768 AC7: a granted recovery records, atomically with the lock itself, both
|
||||
session identities, the head it was measured against, the head it adopted,
|
||||
how those two relate, and the ancestry proof — so a descendant recovery can
|
||||
be audited after the fact without re-running any probe.
|
||||
"""
|
||||
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"),
|
||||
"recovery_mode": evidence.get("recovery_mode"),
|
||||
"remote_branch_exists": evidence.get("remote_branch_exists"),
|
||||
"recorded_base": evidence.get("recorded_base"),
|
||||
"local_head": evidence.get("local_head"),
|
||||
"remote_head": evidence.get("remote_head"),
|
||||
"recorded_head": evidence.get("recorded_head"),
|
||||
"accepted_head": evidence.get("accepted_head"),
|
||||
"head_relation": evidence.get("head_relation"),
|
||||
"ancestry_proof": evidence.get("ancestry_proof"),
|
||||
"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)"
|
||||
)
|
||||
+45
-2
@@ -148,8 +148,37 @@ def save_lock_file(path: str, data: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session."""
|
||||
def lock_generation(lock: dict[str, Any] | None) -> int:
|
||||
"""Monotonic write counter for a durable lock record (#772 AC5).
|
||||
|
||||
Absent or unusable values read as ``0`` so a lock written before generations
|
||||
existed still participates in compare-and-swap: its first recovery expects
|
||||
``0`` and writes ``1``.
|
||||
"""
|
||||
if not isinstance(lock, dict):
|
||||
return 0
|
||||
try:
|
||||
return int(lock.get("lock_generation") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def bind_session_lock(
|
||||
lock_data: dict[str, Any],
|
||||
lock_dir: str | None = None,
|
||||
*,
|
||||
expected_generation: int | None = None,
|
||||
) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session.
|
||||
|
||||
``expected_generation`` turns the write into a compare-and-swap (#772 AC5).
|
||||
Recovery decides it may take over a claim by reading the durable lock, but
|
||||
that read and this write are separate steps; without a CAS two replacement
|
||||
sessions can both observe the same dead owner, both pass assessment, and
|
||||
both write — the second silently clobbering the first. Passing the
|
||||
generation observed at assessment time makes exactly one of them win: the
|
||||
loser's expectation no longer matches and it fails closed.
|
||||
"""
|
||||
remote = str(lock_data.get("remote") or "")
|
||||
org = str(lock_data.get("org") or "")
|
||||
repo = str(lock_data.get("repo") or "")
|
||||
@@ -194,6 +223,20 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
|
||||
)
|
||||
if lease_block:
|
||||
raise RuntimeError(lease_block)
|
||||
# #772 AC5: compare-and-swap inside the same critical section that
|
||||
# already serializes writers, so the check and the write cannot be
|
||||
# separated by another session's successful recovery.
|
||||
current_generation = lock_generation(existing)
|
||||
if (
|
||||
expected_generation is not None
|
||||
and current_generation != expected_generation
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Issue #{issue_number} lock generation changed: expected "
|
||||
f"{expected_generation}, found {current_generation}; another "
|
||||
"session already recovered or replaced this claim (fail closed)"
|
||||
)
|
||||
record["lock_generation"] = current_generation + 1
|
||||
save_lock_file(path, record)
|
||||
save_lock_file(session_pointer_path(root), pointer)
|
||||
except LockContentionError as exc:
|
||||
|
||||
+214
-3
@@ -20,16 +20,208 @@ BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
def resolve_author_worktree_path(
|
||||
explicit: str | None,
|
||||
project_root: str,
|
||||
*,
|
||||
session_lock_worktree: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the author worktree path for lock/PR gates."""
|
||||
"""Resolve the author worktree path for lock/PR gates.
|
||||
|
||||
#618: prefer explicit path, then env, then the active issue lock worktree.
|
||||
Does not invent a branches/ worktree. Falling back to *project_root* is
|
||||
retained only for lock-time bootstrap when the process itself is already
|
||||
under branches/ or no binding exists yet (callers still fail closed via
|
||||
preflight / durable resolution before mutation).
|
||||
"""
|
||||
path = (explicit or "").strip()
|
||||
if not path:
|
||||
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
|
||||
if not path:
|
||||
path = (os.environ.get("GITEA_ACTIVE_WORKTREE") or "").strip()
|
||||
if not path:
|
||||
path = (session_lock_worktree or "").strip()
|
||||
if not path:
|
||||
path = project_root
|
||||
return os.path.realpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def read_head_ancestry(
|
||||
worktree_path: str,
|
||||
*,
|
||||
ancestor_sha: str | None,
|
||||
descendant_sha: str | None,
|
||||
) -> dict:
|
||||
"""Observe whether ``descendant_sha`` strictly descends from ``ancestor_sha`` (#768).
|
||||
|
||||
Server-side git observation for dead-session lock recovery. The recovering
|
||||
author's only reachable clean-worktree state is one commit *ahead* of the
|
||||
head recorded at lock time, so recovery needs to know whether that commit
|
||||
extends the recorded head or replaces it.
|
||||
|
||||
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
|
||||
field is read from git in the declared worktree — nothing here is supplied
|
||||
by, or reachable from, an MCP caller (#768 AC6).
|
||||
|
||||
``ancestor_present`` proves the recorded head is still reachable, which is
|
||||
what separates an honest fast-forward from a rewritten or force-moved
|
||||
history: a rewritten recorded head leaves the object graph and the probe
|
||||
fails closed.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
ancestor = (ancestor_sha or "").strip()
|
||||
descendant = (descendant_sha or "").strip()
|
||||
result: dict = {
|
||||
"ancestor_sha": ancestor or None,
|
||||
"descendant_sha": descendant or None,
|
||||
"probe_ok": False,
|
||||
"ancestor_present": False,
|
||||
"descendant_present": False,
|
||||
"is_ancestor": False,
|
||||
"is_strict_descendant": False,
|
||||
"proof": None,
|
||||
"reasons": [],
|
||||
}
|
||||
if not path or not ancestor or not descendant:
|
||||
result["reasons"].append(
|
||||
"ancestry probe requires a worktree path and both commit SHAs"
|
||||
)
|
||||
return result
|
||||
|
||||
def _present(sha: str) -> bool:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return res.returncode == 0
|
||||
|
||||
try:
|
||||
result["ancestor_present"] = _present(ancestor)
|
||||
result["descendant_present"] = _present(descendant)
|
||||
except OSError as exc: # git unavailable — fail closed, never assume
|
||||
result["reasons"].append(f"ancestry probe could not run: {exc}")
|
||||
return result
|
||||
|
||||
if not result["ancestor_present"]:
|
||||
result["reasons"].append(
|
||||
f"recorded head {ancestor} is not reachable in '{path}'; history may "
|
||||
"have been rewritten or force-moved"
|
||||
)
|
||||
if not result["descendant_present"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} is not reachable in '{path}'"
|
||||
)
|
||||
if not (result["ancestor_present"] and result["descendant_present"]):
|
||||
return result
|
||||
|
||||
probe = subprocess.run(
|
||||
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
# 0 = is an ancestor, 1 = is not. Anything else is a failed probe, not a "no".
|
||||
if probe.returncode not in (0, 1):
|
||||
result["reasons"].append(
|
||||
f"ancestry probe failed with exit {probe.returncode}; ancestry unproven"
|
||||
)
|
||||
return result
|
||||
|
||||
result["probe_ok"] = True
|
||||
result["is_ancestor"] = probe.returncode == 0
|
||||
result["is_strict_descendant"] = result["is_ancestor"] and ancestor != descendant
|
||||
result["proof"] = (
|
||||
f"git -C <worktree> merge-base --is-ancestor {ancestor} {descendant} "
|
||||
f"-> exit {probe.returncode}"
|
||||
)
|
||||
if not result["is_ancestor"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} does not descend from recorded head {ancestor}"
|
||||
)
|
||||
elif not result["is_strict_descendant"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} equals the recorded head; no descendant "
|
||||
"recovery is involved"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_recorded_base(
|
||||
worktree_path: str,
|
||||
*,
|
||||
head_sha: str | None,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Observe the base commit an unpublished claim was branched from (#772).
|
||||
|
||||
A published claim records its base implicitly: the remote branch head is the
|
||||
thing recovery measures against. An unpublished claim has no remote ref, so
|
||||
the base must be observed here, server-side, as the merge-base between the
|
||||
worktree HEAD and the base branch it was cut from.
|
||||
|
||||
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
|
||||
field is read from git in the declared worktree — nothing is supplied by, or
|
||||
reachable from, an MCP caller, so a caller cannot nominate a base that would
|
||||
make unrelated history look like a descendant (#772 AC1/AC4).
|
||||
|
||||
A HEAD with no common ancestor in any base branch yields ``probe_ok`` with no
|
||||
``base_sha``: unrelated history is reported as exactly that, never as a base.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
head = (head_sha or "").strip()
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
candidates = [*extra_bases, *sorted(bases)]
|
||||
result: dict = {
|
||||
"base_branch": None,
|
||||
"base_sha": None,
|
||||
"head_sha": head or None,
|
||||
"probe_ok": False,
|
||||
"candidates": candidates,
|
||||
"reasons": [],
|
||||
}
|
||||
if not path or not head:
|
||||
result["reasons"].append(
|
||||
"recorded-base probe requires a worktree path and a HEAD sha"
|
||||
)
|
||||
return result
|
||||
|
||||
probed_any = False
|
||||
for candidate in candidates:
|
||||
name = (candidate or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
probe = subprocess.run(
|
||||
["git", "-C", path, "merge-base", name, head],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode not in (0, 1):
|
||||
# 0 = merge base found, 1 = no common ancestor. Anything else is a
|
||||
# failed probe (missing ref, broken repo) — try the next candidate.
|
||||
continue
|
||||
probed_any = True
|
||||
merge_base = (probe.stdout or "").strip()
|
||||
if probe.returncode == 0 and merge_base:
|
||||
result["base_branch"] = name
|
||||
result["base_sha"] = merge_base
|
||||
result["probe_ok"] = True
|
||||
return result
|
||||
|
||||
result["probe_ok"] = probed_any
|
||||
if probed_any:
|
||||
result["reasons"].append(
|
||||
f"HEAD {head} shares no common ancestor with any of "
|
||||
f"{_base_list(bases)}; history is unrelated to this repository's base"
|
||||
)
|
||||
else:
|
||||
result["reasons"].append(
|
||||
f"recorded-base probe could not run against any of {_base_list(bases)} "
|
||||
f"in '{path}'"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_worktree_git_state(
|
||||
worktree_path: str,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
@@ -92,8 +284,19 @@ def assess_issue_lock_worktree(
|
||||
inspected_git_root: str | None = None,
|
||||
base_branch: str | None = None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
recovery_sanctioned: bool = False,
|
||||
) -> 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
|
||||
reasons: list[str] = []
|
||||
path = (worktree_path or "").strip()
|
||||
@@ -111,7 +314,11 @@ def assess_issue_lock_worktree(
|
||||
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(
|
||||
"issue lock worktree must be base-equivalent to one of "
|
||||
f"{_base_list(bases)} before implementation work; inspected "
|
||||
@@ -139,6 +346,7 @@ def assess_issue_lock_worktree(
|
||||
inspected_git_root=inspected_git_root,
|
||||
base_branch=base_branch,
|
||||
base_equivalent=base_equivalent,
|
||||
recovery_sanctioned=recovery_sanctioned,
|
||||
)
|
||||
|
||||
|
||||
@@ -197,6 +405,7 @@ def _assessment(
|
||||
inspected_git_root: str | None = None,
|
||||
base_branch: str | None = None,
|
||||
base_equivalent: bool | None = None,
|
||||
recovery_sanctioned: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
@@ -208,6 +417,8 @@ def _assessment(
|
||||
"dirty_files": dirty_files,
|
||||
"base_branch": base_branch,
|
||||
"base_equivalent": base_equivalent,
|
||||
"recovery_sanctioned": recovery_sanctioned,
|
||||
"base_equivalence_waived": bool(recovery_sanctioned),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Mapping
|
||||
|
||||
import issue_claim_heartbeat as claim_hb
|
||||
|
||||
@@ -27,6 +27,128 @@ def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
||||
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
|
||||
# #768: a descendant recovery is measured against the head the PR still
|
||||
# shows, then publishes the local descendant — so the live PR head is the
|
||||
# recorded head before that push and the accepted head after it. Both are
|
||||
# server-derived and name the same owned PR, so both are accepted; anything
|
||||
# else still fails closed.
|
||||
token_accepted = str(recovered_owning_pr.get("accepted_head") or "").strip()
|
||||
acceptable_heads = [head for head in (token_head, token_accepted) if head]
|
||||
if not acceptable_heads or not only_sha or only_sha not in acceptable_heads:
|
||||
notes.append(
|
||||
f"open PR #{only_number} head {only_sha or 'unknown'} does not "
|
||||
f"match the recovered head {token_head or 'unknown'}"
|
||||
+ (
|
||||
f" or the accepted head {token_accepted}"
|
||||
if token_accepted and token_accepted != token_head
|
||||
else ""
|
||||
)
|
||||
+ " (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 {only_sha}); not duplicate work"
|
||||
]
|
||||
|
||||
|
||||
def _matching_branches(
|
||||
issue_number: int,
|
||||
branch_names: list[str],
|
||||
@@ -52,8 +174,15 @@ def assess_work_issue_duplicate_gate(
|
||||
claim_entry: dict | None = None,
|
||||
locked_branch: str | None = None,
|
||||
phase: str = PHASE_LOCK,
|
||||
recovered_owning_pr: Mapping[str, Any] | None = None,
|
||||
) -> 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] = []
|
||||
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
||||
prs = list(open_prs or [])
|
||||
@@ -61,12 +190,23 @@ def assess_work_issue_duplicate_gate(
|
||||
pattern = _issue_pattern(issue_number)
|
||||
|
||||
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:
|
||||
reasons.append(
|
||||
f"open PR #{linked.get('number')} already covers issue "
|
||||
f"#{issue_number} (fail closed)"
|
||||
owning_pr_exempted, exemption_notes = _assess_owning_pr_exemption(
|
||||
issue_number,
|
||||
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(
|
||||
issue_number, branches, locked_branch=locked_branch
|
||||
@@ -122,6 +262,9 @@ def assess_work_issue_duplicate_gate(
|
||||
"phase": phase,
|
||||
"outcome": outcome,
|
||||
"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,
|
||||
"claim_status": status or None,
|
||||
"reasons": reasons,
|
||||
|
||||
@@ -166,3 +166,128 @@ def format_parity(assessment: dict) -> str:
|
||||
if not assessment.get("determinable"):
|
||||
return "parity indeterminate (baseline or current HEAD unknown)"
|
||||
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
|
||||
|
||||
+43
-15
@@ -19,6 +19,32 @@ print_banner() {
|
||||
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
|
||||
}
|
||||
|
||||
show_workflow_dashboard_help() {
|
||||
printf '\n--- Workflow dashboard (queue, leases, next safe action) ---\n\n'
|
||||
printf 'Read-only operational view (#605). Does NOT assign work.\n'
|
||||
printf 'Exclusive assignment still requires gitea_allocate_next_work.\n\n'
|
||||
printf 'Canonical MCP tool (any healthy Gitea namespace with gitea.read):\n\n'
|
||||
printf ' gitea_workflow_dashboard(\n'
|
||||
printf ' remote=\"prgs\",\n'
|
||||
printf ' org=\"Scaled-Tech-Consulting\",\n'
|
||||
printf ' repo=\"Gitea-Tools\",\n'
|
||||
printf ' )\n\n'
|
||||
printf 'Returns machine-readable sections:\n'
|
||||
printf ' - open_pr_queue / open_issue_queue\n'
|
||||
printf ' - active_leases_by_role / stale_or_expired_leases\n'
|
||||
printf ' - terminal_review_lock\n'
|
||||
printf ' - blocked_items (never presented as safe)\n'
|
||||
printf ' - review_ready_prs / merge_ready_prs / author_remediation\n'
|
||||
printf ' - discussion_issues / controller_needed\n'
|
||||
printf ' - next_safe_by_role + primary_next_safe_action with exact prompts\n'
|
||||
printf ' - human_summary (copy-friendly multi-line text)\n\n'
|
||||
printf 'Safety:\n'
|
||||
printf ' - Never suggests blocked or terminal-locked items as safe.\n'
|
||||
printf ' - Incomplete inventory fails closed (no safe suggestions).\n'
|
||||
printf ' - This menu entry is documentation only; it does not call Gitea.\n'
|
||||
pause
|
||||
}
|
||||
|
||||
show_root_checkout_health() {
|
||||
printf '\n--- Project status / root checkout health ---\n\n'
|
||||
printf 'Current directory: %s\n' "$(pwd)"
|
||||
@@ -241,25 +267,27 @@ main_menu() {
|
||||
while true; do
|
||||
print_banner
|
||||
printf ' 1) Project status / root checkout health\n'
|
||||
printf ' 2) Author workflow prompts\n'
|
||||
printf ' 3) Reviewer workflow prompts\n'
|
||||
printf ' 4) Merger workflow prompts\n'
|
||||
printf ' 5) Reconciler workflow prompts\n'
|
||||
printf ' 6) Onboarding new project to this MCP workflow\n'
|
||||
printf ' 7) Proxmox deployment menu placeholder\n'
|
||||
printf ' 8) Create Proxmox LXC placeholder\n'
|
||||
printf ' 9) Run tests\n'
|
||||
printf ' 2) Workflow dashboard (queue, leases, next safe action)\n'
|
||||
printf ' 3) Author workflow prompts\n'
|
||||
printf ' 4) Reviewer workflow prompts\n'
|
||||
printf ' 5) Merger workflow prompts\n'
|
||||
printf ' 6) Reconciler workflow prompts\n'
|
||||
printf ' 7) Onboarding new project to this MCP workflow\n'
|
||||
printf ' 8) Proxmox deployment menu placeholder\n'
|
||||
printf ' 9) Create Proxmox LXC placeholder\n'
|
||||
printf ' t) Run tests\n'
|
||||
printf ' 0) Exit\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1) show_root_checkout_health ;;
|
||||
2) show_author_prompts ;;
|
||||
3) show_reviewer_prompts ;;
|
||||
4) show_merger_prompts ;;
|
||||
5) show_reconciler_prompts ;;
|
||||
6) show_onboarding_prompt ;;
|
||||
7|8) show_proxmox_placeholder ;;
|
||||
9) run_tests ;;
|
||||
2) show_workflow_dashboard_help ;;
|
||||
3) show_author_prompts ;;
|
||||
4) show_reviewer_prompts ;;
|
||||
5) show_merger_prompts ;;
|
||||
6) show_reconciler_prompts ;;
|
||||
7) show_onboarding_prompt ;;
|
||||
8|9) show_proxmox_placeholder ;;
|
||||
t|T|tests) run_tests ;;
|
||||
0) printf 'Goodbye.\n'; exit 0 ;;
|
||||
*) printf 'Invalid choice.\n'; pause ;;
|
||||
esac
|
||||
|
||||
+185
-2
@@ -23,6 +23,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
@@ -38,6 +39,9 @@ REASON_CONFIG_ERROR = "config_error"
|
||||
REASON_INTERNAL_ERROR = "internal_error"
|
||||
REASON_UPSTREAM_UNAVAILABLE = "upstream_unavailable"
|
||||
REASON_HTTP_ERROR = "http_error"
|
||||
# Typed structured errors for previously-opaque raw mutation failures.
|
||||
REASON_PREFLIGHT_ORDER = "preflight_order_violation"
|
||||
REASON_MALFORMED_RESPONSE = "malformed_response"
|
||||
|
||||
ERROR_CLASS_AUTHENTICATION = "authentication"
|
||||
ERROR_CLASS_AUTHORIZATION = "authorization"
|
||||
@@ -45,6 +49,7 @@ ERROR_CLASS_NETWORK = "network"
|
||||
ERROR_CLASS_CONFIGURATION = "configuration"
|
||||
ERROR_CLASS_INTERNAL = "internal"
|
||||
ERROR_CLASS_UPSTREAM = "upstream"
|
||||
ERROR_CLASS_PRECONDITION = "precondition"
|
||||
|
||||
# Fixed, secret-free operator messages. Never interpolate HTTP bodies,
|
||||
# Keychain contents, tokens, or arbitrary exception text.
|
||||
@@ -62,12 +67,128 @@ FIXED_MESSAGES: dict[str, str] = {
|
||||
REASON_INTERNAL_ERROR: "Internal tool error",
|
||||
REASON_UPSTREAM_UNAVAILABLE: "Gitea upstream unavailable",
|
||||
REASON_HTTP_ERROR: "Gitea HTTP request failed",
|
||||
REASON_PREFLIGHT_ORDER: (
|
||||
"Mutation blocked: pre-flight order violation (fail closed)"
|
||||
),
|
||||
REASON_MALFORMED_RESPONSE: "Malformed response from Gitea",
|
||||
}
|
||||
|
||||
# Error class for a self-declared safe reason code (``gitea_reason_code``).
|
||||
_REASON_ERROR_CLASS: dict[str, str] = {
|
||||
REASON_AUTH_FAILED: ERROR_CLASS_AUTHENTICATION,
|
||||
REASON_AUTH_INVALID_TOKEN: ERROR_CLASS_AUTHENTICATION,
|
||||
REASON_AUTHZ_INSUFFICIENT_SCOPE: ERROR_CLASS_AUTHORIZATION,
|
||||
REASON_AUTHZ_DENIED: ERROR_CLASS_AUTHORIZATION,
|
||||
REASON_NETWORK_ERROR: ERROR_CLASS_NETWORK,
|
||||
REASON_CONFIG_ERROR: ERROR_CLASS_CONFIGURATION,
|
||||
REASON_UPSTREAM_UNAVAILABLE: ERROR_CLASS_UPSTREAM,
|
||||
REASON_HTTP_ERROR: ERROR_CLASS_INTERNAL,
|
||||
REASON_PREFLIGHT_ORDER: ERROR_CLASS_PRECONDITION,
|
||||
REASON_MALFORMED_RESPONSE: ERROR_CLASS_INTERNAL,
|
||||
REASON_INTERNAL_ERROR: ERROR_CLASS_INTERNAL,
|
||||
}
|
||||
|
||||
# Bounds for the safe diagnostic fields added to the ``internal_error`` path.
|
||||
_DETAIL_LIMIT = 200
|
||||
_CLASS_LIMIT = 120
|
||||
_STAGE_LIMIT = 64
|
||||
|
||||
# Absolute-path prefixes stripped from diagnostic detail (never leak layout).
|
||||
_ABS_PATH_RE = re.compile(r"/(?:Users|home|private|var|tmp|opt|etc|root)/[^\s'\"]*")
|
||||
_DETAIL_SECRET_PREFIXES = ("token ", "Basic ", "Bearer ", "Authorization: ")
|
||||
_SECRET_KEY = (
|
||||
r"token|password|passwd|secret|authorization|api[_-]?key|"
|
||||
r"access[_-]?token|refresh[_-]?token|session|cookie"
|
||||
)
|
||||
_SECRET_JSON_RE = re.compile(
|
||||
r'"(' + _SECRET_KEY + r')"\s*:\s*"[^"]*"', re.IGNORECASE
|
||||
)
|
||||
_SECRET_KV_RE = re.compile(
|
||||
r"\b(" + _SECRET_KEY + r")\s*=\s*[^\s&\"']+", re.IGNORECASE
|
||||
)
|
||||
|
||||
_MUTATION_STAGE_ATTR = "_gitea_mutation_stage"
|
||||
_SELF_DECLARED_REASON_ATTR = "gitea_reason_code"
|
||||
|
||||
_INSTALL_FLAG = "_gitea_auth_boundary_installed"
|
||||
_ORIGINAL_ATTR = "_gitea_auth_boundary_original"
|
||||
|
||||
|
||||
def _safe_str(exc: BaseException) -> str:
|
||||
"""``str(exc)`` that never raises (a poisoned ``__str__`` must not escape)."""
|
||||
try:
|
||||
return str(exc)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _safe_exception_class(exc: BaseException) -> str:
|
||||
"""Fully-qualified type identifier for *exc* — never instance/message text."""
|
||||
try:
|
||||
t = type(exc)
|
||||
mod = getattr(t, "__module__", "") or ""
|
||||
name = getattr(t, "__qualname__", None) or getattr(t, "__name__", "") or ""
|
||||
ident = f"{mod}.{name}" if mod else name
|
||||
return ident[:_CLASS_LIMIT]
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _redact_detail(text: Any, limit: int = _DETAIL_LIMIT) -> str:
|
||||
"""Strictly redact free-form exception text for safe diagnostics.
|
||||
|
||||
Removes token/Authorization credentials, raw URLs and hostnames (via the
|
||||
shared audit redactor), and absolute filesystem paths, then collapses
|
||||
whitespace and truncates. Never raises; on any failure returns "".
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
out = str(text)
|
||||
# Drop credential-prefixed runs (token/Basic/Bearer/Authorization).
|
||||
for prefix in _DETAIL_SECRET_PREFIXES:
|
||||
idx = 0
|
||||
while True:
|
||||
i = out.find(prefix, idx)
|
||||
if i == -1:
|
||||
break
|
||||
j = i + len(prefix)
|
||||
while j < len(out) and not out[j].isspace():
|
||||
j += 1
|
||||
out = out[:i] + prefix + "[REDACTED]" + out[j:]
|
||||
idx = i + len(prefix) + len("[REDACTED]")
|
||||
# Secret VALUES carried in JSON ("key":"value") or kv (key=value) form,
|
||||
# even short ones (e.g. a password), keyed by a sensitive field name.
|
||||
out = _SECRET_JSON_RE.sub(r'"\1":"[REDACTED]"', out)
|
||||
out = _SECRET_KV_RE.sub(r"\1=[REDACTED]", out)
|
||||
# URLs / query secrets / hostnames.
|
||||
try:
|
||||
import gitea_audit
|
||||
|
||||
out = gitea_audit.redact_urls(out)
|
||||
except Exception:
|
||||
pass
|
||||
# Absolute filesystem paths (workspace layout is not for LLM output).
|
||||
out = _ABS_PATH_RE.sub("[PATH]", out)
|
||||
# Any bare hostname the URL redactor missed (defence in depth).
|
||||
out = re.sub(r"\b[\w.-]+\.(?:cc|net|com|org|io|dev|local)\b", "[HOST]", out)
|
||||
out = " ".join(out.split())
|
||||
return out[:limit]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _safe_mutation_stage(exc: BaseException) -> str | None:
|
||||
"""Return the bounded, redacted mutation stage tagged on *exc* (or None)."""
|
||||
try:
|
||||
raw = getattr(exc, _MUTATION_STAGE_ATTR, None)
|
||||
if not raw:
|
||||
return None
|
||||
return _redact_detail(raw, limit=_STAGE_LIMIT) or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def fixed_message(reason_code: str) -> str:
|
||||
"""Return the fixed sanitized message for *reason_code* (fail closed)."""
|
||||
return FIXED_MESSAGES.get(reason_code, FIXED_MESSAGES[REASON_INTERNAL_ERROR])
|
||||
@@ -154,9 +275,34 @@ def classify_exception(exc: BaseException) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _self_declared_classification(exc: BaseException) -> dict[str, Any] | None:
|
||||
"""Honor a safe ``gitea_reason_code`` attribute set by server-side raisers.
|
||||
|
||||
Converts a raw exception that self-declares a **known** reason code into a
|
||||
typed structured error (fixed message) instead of an opaque internal_error.
|
||||
Unknown / non-string / secret values are ignored (fail closed to internal).
|
||||
"""
|
||||
reason = getattr(exc, _SELF_DECLARED_REASON_ATTR, None)
|
||||
if not isinstance(reason, str) or reason not in FIXED_MESSAGES:
|
||||
return None
|
||||
if reason == REASON_INTERNAL_ERROR:
|
||||
return None
|
||||
return {
|
||||
"reason_code": reason,
|
||||
"error_class": _REASON_ERROR_CLASS.get(reason, ERROR_CLASS_INTERNAL),
|
||||
"http_status": None,
|
||||
"message": fixed_message(reason),
|
||||
"transport_survives": True,
|
||||
}
|
||||
|
||||
|
||||
def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
|
||||
import gitea_auth
|
||||
|
||||
declared = _self_declared_classification(exc)
|
||||
if declared is not None:
|
||||
return declared
|
||||
|
||||
if isinstance(exc, gitea_auth.GiteaAuthError):
|
||||
code = getattr(exc, "reason_code", None) or REASON_AUTH_INVALID_TOKEN
|
||||
if code not in (
|
||||
@@ -233,13 +379,23 @@ def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
|
||||
return _classify_exception_impl(cause)
|
||||
|
||||
# No message-substring authentication heuristics (reviewer finding #3).
|
||||
return {
|
||||
# Unexpected failure → internal_error, now carrying SAFE diagnostics so the
|
||||
# failure is actionable: a type identifier + strictly-redacted detail +
|
||||
# optional mutation-stage tag. The operator ``message`` stays the fixed
|
||||
# constant; none of these fields carry secrets/bodies/paths/env.
|
||||
result = {
|
||||
"reason_code": REASON_INTERNAL_ERROR,
|
||||
"error_class": ERROR_CLASS_INTERNAL,
|
||||
"http_status": None,
|
||||
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||
"transport_survives": True,
|
||||
"exception_class": _safe_exception_class(exc),
|
||||
"detail": _redact_detail(_safe_str(exc)),
|
||||
}
|
||||
stage = _safe_mutation_stage(exc)
|
||||
if stage:
|
||||
result["mutation_stage"] = stage
|
||||
return result
|
||||
|
||||
|
||||
def build_structured_error_payload(
|
||||
@@ -277,6 +433,19 @@ def build_structured_error_payload(
|
||||
payload["tool"] = tool_name[:120]
|
||||
if profile_name and isinstance(profile_name, str):
|
||||
payload["profile"] = profile_name[:80]
|
||||
# Safe diagnostics — internal_error path only. These make an otherwise
|
||||
# opaque crash actionable without leaking secrets. Re-derived here from
|
||||
# the fixed message gate above; typed reasons never carry them.
|
||||
if payload["reason_code"] == REASON_INTERNAL_ERROR:
|
||||
exc_cls = classification.get("exception_class")
|
||||
if isinstance(exc_cls, str) and exc_cls:
|
||||
payload["exception_class"] = exc_cls[:_CLASS_LIMIT]
|
||||
detail = classification.get("detail")
|
||||
if isinstance(detail, str):
|
||||
payload["detail"] = _redact_detail(detail)
|
||||
stage = classification.get("mutation_stage")
|
||||
if isinstance(stage, str) and stage:
|
||||
payload["mutation_stage"] = stage[:_STAGE_LIMIT]
|
||||
return payload
|
||||
except Exception:
|
||||
return {
|
||||
@@ -316,7 +485,21 @@ def log_sanitized_daemon_reason(
|
||||
status = classification.get("http_status")
|
||||
if isinstance(status, int):
|
||||
parts.append(f"http_status={status}")
|
||||
# Intentionally no detail= / message= field — secrets lived there.
|
||||
# Safe diagnostics — internal_error path ONLY. Typed reasons still emit
|
||||
# no detail= (secrets lived there). class/detail/stage are re-redacted
|
||||
# here as defence in depth.
|
||||
if reason == REASON_INTERNAL_ERROR:
|
||||
exc_cls = classification.get("exception_class")
|
||||
if isinstance(exc_cls, str) and exc_cls:
|
||||
parts.append(f"exception_class={exc_cls[:_CLASS_LIMIT]}")
|
||||
stage = classification.get("mutation_stage")
|
||||
if isinstance(stage, str) and stage:
|
||||
parts.append(
|
||||
f"mutation_stage={_redact_detail(stage, limit=_STAGE_LIMIT)}"
|
||||
)
|
||||
detail = classification.get("detail")
|
||||
if isinstance(detail, str) and detail:
|
||||
parts.append(f"detail={_redact_detail(detail)}")
|
||||
line = " ".join(parts)
|
||||
stream.write(line + "\n")
|
||||
if hasattr(stream, "flush"):
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Documented-vs-registered MCP tool inventory guard (#781).
|
||||
|
||||
The workflow documentation named a ``gitea_edit_issue`` tool that no namespace
|
||||
had ever registered. Nothing compared the two lists, so an actor could plan a
|
||||
mutation against a tool that did not exist and only discover it at execution
|
||||
time — after the work was already scoped around it.
|
||||
|
||||
This module is that comparison, in two directions:
|
||||
|
||||
- :func:`assess_inventory_drift` compares the canonical inventory documented in
|
||||
``docs/mcp-tool-inventory.md`` against the tools actually registered on the
|
||||
MCP server. Either list drifting fails the guard, so a new tool must be
|
||||
documented and a removed tool must be undocumented in the same change.
|
||||
- :func:`assess_doc_references` catches the original defect directly: any tool
|
||||
name a workflow/skill document tells an actor to call must be registered.
|
||||
|
||||
Module and script names legitimately appear in the same prose (``gitea_auth``,
|
||||
``offline_mcp_runner``), so :data:`NON_TOOL_IDENTIFIERS` names the known
|
||||
non-tool identifiers explicitly rather than loosening the pattern.
|
||||
|
||||
This module performs no I/O — callers own reading the files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
#: Canonical documented inventory, relative to the repository root.
|
||||
INVENTORY_DOC_PATH = "docs/mcp-tool-inventory.md"
|
||||
|
||||
#: Delimiters around the generated inventory list in the doc.
|
||||
INVENTORY_BEGIN_MARKER = "<!-- BEGIN REGISTERED TOOL INVENTORY -->"
|
||||
INVENTORY_END_MARKER = "<!-- END REGISTERED TOOL INVENTORY -->"
|
||||
|
||||
#: Backticked identifiers that look like tool names but are modules/scripts.
|
||||
#: Every entry is a real file in this repository, not an MCP tool.
|
||||
NON_TOOL_IDENTIFIERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"gitea_auth",
|
||||
"gitea_config",
|
||||
"gitea_mcp_server",
|
||||
"mcp_server",
|
||||
"offline_mcp_helper",
|
||||
"offline_mcp_runner",
|
||||
}
|
||||
)
|
||||
|
||||
#: Prefixes that mark an identifier as a candidate MCP tool name.
|
||||
TOOL_NAME_PREFIXES: tuple[str, ...] = ("gitea_", "mcp_")
|
||||
|
||||
_INVENTORY_ENTRY = re.compile(r"^-\s+`([A-Za-z_][A-Za-z0-9_]*)`")
|
||||
_BACKTICKED = re.compile(r"`([A-Za-z_][A-Za-z0-9_]*)`")
|
||||
|
||||
|
||||
def parse_documented_inventory(text: str) -> list[str]:
|
||||
"""Return the tool names listed between the inventory markers.
|
||||
|
||||
Raises ``ValueError`` when the markers are missing or out of order, so a
|
||||
mangled document fails the guard instead of silently documenting nothing.
|
||||
"""
|
||||
start = text.find(INVENTORY_BEGIN_MARKER)
|
||||
end = text.find(INVENTORY_END_MARKER)
|
||||
if start == -1 or end == -1 or end < start:
|
||||
raise ValueError(
|
||||
f"{INVENTORY_DOC_PATH} must contain "
|
||||
f"'{INVENTORY_BEGIN_MARKER}' followed by "
|
||||
f"'{INVENTORY_END_MARKER}' (fail closed)."
|
||||
)
|
||||
block = text[start + len(INVENTORY_BEGIN_MARKER) : end]
|
||||
names: list[str] = []
|
||||
for line in block.splitlines():
|
||||
match = _INVENTORY_ENTRY.match(line.strip())
|
||||
if match:
|
||||
names.append(match.group(1))
|
||||
return names
|
||||
|
||||
|
||||
def looks_like_tool_name(identifier: str) -> bool:
|
||||
"""Return whether a backticked identifier is a candidate tool name."""
|
||||
if identifier in NON_TOOL_IDENTIFIERS:
|
||||
return False
|
||||
return identifier.startswith(TOOL_NAME_PREFIXES)
|
||||
|
||||
|
||||
def extract_tool_references(text: str) -> set[str]:
|
||||
"""Return candidate tool names a document tells an actor to call."""
|
||||
return {
|
||||
name
|
||||
for name in _BACKTICKED.findall(text)
|
||||
if looks_like_tool_name(name)
|
||||
}
|
||||
|
||||
|
||||
def assess_inventory_drift(
|
||||
documented: Iterable[str],
|
||||
registered: Iterable[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Compare the documented inventory against the registered tool set."""
|
||||
documented_list = list(documented)
|
||||
documented_set = set(documented_list)
|
||||
registered_set = set(registered)
|
||||
|
||||
duplicates = sorted(
|
||||
{name for name in documented_list if documented_list.count(name) > 1}
|
||||
)
|
||||
documented_not_registered = sorted(documented_set - registered_set)
|
||||
registered_not_documented = sorted(registered_set - documented_set)
|
||||
unsorted = documented_list != sorted(documented_list)
|
||||
|
||||
reasons: list[str] = []
|
||||
if documented_not_registered:
|
||||
reasons.append(
|
||||
"documented but not registered: "
|
||||
+ ", ".join(documented_not_registered)
|
||||
)
|
||||
if registered_not_documented:
|
||||
reasons.append(
|
||||
"registered but not documented: "
|
||||
+ ", ".join(registered_not_documented)
|
||||
)
|
||||
if duplicates:
|
||||
reasons.append("listed more than once: " + ", ".join(duplicates))
|
||||
if unsorted:
|
||||
reasons.append("inventory entries are not in sorted order")
|
||||
|
||||
in_sync = not reasons
|
||||
return {
|
||||
"in_sync": in_sync,
|
||||
"documented_count": len(documented_set),
|
||||
"registered_count": len(registered_set),
|
||||
"documented_not_registered": documented_not_registered,
|
||||
"registered_not_documented": registered_not_documented,
|
||||
"duplicates": duplicates,
|
||||
"sorted": not unsorted,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if in_sync
|
||||
else (
|
||||
f"Update {INVENTORY_DOC_PATH} so the block between the "
|
||||
"inventory markers lists exactly the registered tools, sorted, "
|
||||
"one '- `tool_name`' entry per line."
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_doc_references(
|
||||
references: Mapping[str, Iterable[str]],
|
||||
registered: Iterable[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Verify every tool a document names is actually registered.
|
||||
|
||||
*references* maps a document path to the candidate tool names it mentions.
|
||||
"""
|
||||
registered_set = set(registered)
|
||||
unregistered: list[dict[str, Any]] = []
|
||||
checked = 0
|
||||
for path, names in sorted(references.items()):
|
||||
for name in sorted(set(names)):
|
||||
checked += 1
|
||||
if name not in registered_set:
|
||||
unregistered.append({"document": path, "tool": name})
|
||||
|
||||
clean = not unregistered
|
||||
reasons = [
|
||||
f"{entry['document']} documents '{entry['tool']}', "
|
||||
"which no namespace registers"
|
||||
for entry in unregistered
|
||||
]
|
||||
return {
|
||||
"clean": clean,
|
||||
"checked_count": checked,
|
||||
"unregistered": unregistered,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if clean
|
||||
else (
|
||||
"Either register the named tool with @mcp.tool() or correct the "
|
||||
"document. Documentation must never name a tool an actor cannot "
|
||||
"reach. If the identifier is a module or script rather than a "
|
||||
"tool, add it to mcp_tool_inventory.NON_TOOL_IDENTIFIERS."
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def render_inventory_block(registered: Iterable[str]) -> str:
|
||||
"""Render the marker-delimited inventory block for the documentation."""
|
||||
lines = [INVENTORY_BEGIN_MARKER, ""]
|
||||
lines.extend(f"- `{name}`" for name in sorted(set(registered)))
|
||||
lines.extend(["", INVENTORY_END_MARKER])
|
||||
return "\n".join(lines)
|
||||
@@ -20,6 +20,7 @@ SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||
SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease"
|
||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||
SOURCE_RELEASE_MERGER = "gitea_release_merger_pr_lease"
|
||||
|
||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||
SOURCE_ADOPT,
|
||||
@@ -30,6 +31,21 @@ SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||
|
||||
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
|
||||
|
||||
# #742: owner-session terminal finalization of a merger-held lease. Append-only
|
||||
# marker; ledger history is never edited or deleted.
|
||||
MERGER_FINALIZATION_MARKER = "<!-- mcp-merger-lease-final:v1 -->"
|
||||
OUTCOME_RELEASED = "released"
|
||||
OUTCOME_ABANDONED = "abandoned"
|
||||
MERGER_FINALIZATION_OUTCOMES = frozenset({OUTCOME_RELEASED, OUTCOME_ABANDONED})
|
||||
DEFAULT_MERGER_FINALIZATION_REASON = "merge-not-performed"
|
||||
|
||||
# Provenance sources whose in-session lease is merger-owned and therefore
|
||||
# finalizable by its owning merger session.
|
||||
MERGER_OWNED_PROVENANCE_SOURCES = frozenset({
|
||||
SOURCE_ACQUIRE_MERGER,
|
||||
SOURCE_ADOPT,
|
||||
})
|
||||
|
||||
|
||||
def format_adoption_body(
|
||||
*,
|
||||
@@ -97,6 +113,7 @@ def build_lease_provenance(
|
||||
adopted_from_profile: str | None = None,
|
||||
adopted_from_reviewer_identity: str | None = None,
|
||||
adoption_reason: str | None = None,
|
||||
native_token_fingerprint: str | None = None,
|
||||
recorded_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
recorded_at = recorded_at or datetime.now(timezone.utc)
|
||||
@@ -117,9 +134,76 @@ def build_lease_provenance(
|
||||
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
|
||||
if adoption_reason:
|
||||
proof["adoption_reason"] = adoption_reason
|
||||
if native_token_fingerprint:
|
||||
proof["native_token_fingerprint"] = native_token_fingerprint
|
||||
return proof
|
||||
|
||||
|
||||
def assess_acquired_merger_lease_integrity(
|
||||
session: dict[str, Any] | None,
|
||||
) -> list[str]:
|
||||
"""Ownership/integrity reasons blocking a ``SOURCE_ACQUIRE_MERGER`` lease (#742).
|
||||
|
||||
A merger lease minted by ``gitea_acquire_merger_pr_lease`` authorizes an
|
||||
irreversible merge, so it is sanctioned only when the in-session record is
|
||||
complete and self-consistent: comment marker, exact session identity, merger
|
||||
profile/role, repository, PR number, and pinned candidate head. Any missing
|
||||
or contradictory field fails closed — an incomplete record is not proof.
|
||||
"""
|
||||
if not session:
|
||||
return ["no in-session lease recorded"]
|
||||
provenance = session.get("lease_provenance") or {}
|
||||
if not isinstance(provenance, dict):
|
||||
provenance = {}
|
||||
reasons: list[str] = []
|
||||
|
||||
provenance_comment_id = provenance.get("comment_id")
|
||||
session_comment_id = session.get("comment_id")
|
||||
if not (provenance_comment_id or session_comment_id):
|
||||
reasons.append(
|
||||
"acquired merger lease has no comment marker id (comment-backed "
|
||||
"proof required)"
|
||||
)
|
||||
elif (
|
||||
provenance_comment_id is not None
|
||||
and session_comment_id is not None
|
||||
and provenance_comment_id != session_comment_id
|
||||
):
|
||||
reasons.append(
|
||||
"acquired merger lease provenance comment_id does not match the "
|
||||
"session lease comment_id"
|
||||
)
|
||||
|
||||
if not (session.get("session_id") or "").strip():
|
||||
reasons.append("acquired merger lease has no session_id")
|
||||
if not (session.get("reviewer_identity") or "").strip():
|
||||
reasons.append("acquired merger lease has no holder identity")
|
||||
|
||||
profile = (session.get("profile") or "").strip()
|
||||
if not profile:
|
||||
reasons.append("acquired merger lease has no profile")
|
||||
elif "merger" not in profile.lower():
|
||||
reasons.append(
|
||||
f"acquired merger lease profile '{profile}' is not a merger profile "
|
||||
"(merger-only; fail closed)"
|
||||
)
|
||||
|
||||
if not (session.get("repo") or "").strip():
|
||||
reasons.append("acquired merger lease has no repository")
|
||||
|
||||
pr_number = session.get("pr_number")
|
||||
if not isinstance(pr_number, int) or isinstance(pr_number, bool) or pr_number <= 0:
|
||||
reasons.append("acquired merger lease has no valid PR number")
|
||||
|
||||
if not leases._normalize_sha(session.get("candidate_head")):
|
||||
reasons.append(
|
||||
"acquired merger lease has no pinned candidate_head (exact-head "
|
||||
"scoping required)"
|
||||
)
|
||||
|
||||
return reasons
|
||||
|
||||
|
||||
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
||||
if not session:
|
||||
return False
|
||||
@@ -131,6 +215,8 @@ def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
||||
return bool(provenance.get("comment_id")) and bool(
|
||||
provenance.get("adopted_from_session_id")
|
||||
)
|
||||
if source == SOURCE_ACQUIRE_MERGER:
|
||||
return not assess_acquired_merger_lease_integrity(session)
|
||||
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
|
||||
return bool(session.get("comment_id") or provenance.get("comment_id"))
|
||||
return False
|
||||
@@ -168,6 +254,8 @@ def describe_session_lease_proof(
|
||||
if source == SOURCE_ADOPT and sanctioned:
|
||||
kind = "sanctioned_adoption"
|
||||
reason = reason or DEFAULT_ADOPTION_REASON
|
||||
elif source == SOURCE_ACQUIRE_MERGER and sanctioned:
|
||||
kind = "sanctioned_acquire_merger"
|
||||
elif source == SOURCE_ACQUIRE and sanctioned:
|
||||
kind = "sanctioned_acquire"
|
||||
elif source == SOURCE_HEARTBEAT and sanctioned:
|
||||
@@ -216,6 +304,7 @@ _SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
|
||||
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
||||
r"lease_proof_source\s*[:=]\s*gitea_acquire_merger_pr_lease|"
|
||||
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
||||
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire_merger|"
|
||||
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
||||
r"adoption_comment_id\s*[:=]|"
|
||||
r"sanctioned_adoption|"
|
||||
@@ -247,6 +336,267 @@ def assess_manual_lease_proof_handoff(report_text: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def format_merger_finalization_body(
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
issue_number: int | None,
|
||||
merger_identity: str,
|
||||
merger_profile: str,
|
||||
merger_session_id: str,
|
||||
worktree: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
outcome: str,
|
||||
reason: str,
|
||||
lease_comment_id: int | None,
|
||||
finalized_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Render the append-only terminal marker for a merger-owned lease (#742).
|
||||
|
||||
The body carries a standard lease marker in a terminal phase, so the
|
||||
existing newest-wins ledger (#577) ends the lease without editing or
|
||||
deleting any prior comment.
|
||||
"""
|
||||
finalized_at = finalized_at or datetime.now(timezone.utc)
|
||||
finalized_text = finalized_at.astimezone(timezone.utc).replace(
|
||||
microsecond=0
|
||||
).isoformat().replace("+00:00", "Z")
|
||||
lease_body = leases.format_lease_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number,
|
||||
reviewer_identity=merger_identity,
|
||||
profile=merger_profile,
|
||||
session_id=merger_session_id,
|
||||
worktree=worktree,
|
||||
phase=outcome,
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
last_activity=finalized_at,
|
||||
blocker=reason,
|
||||
)
|
||||
lines = [
|
||||
MERGER_FINALIZATION_MARKER,
|
||||
f"finalized_at: {finalized_text}",
|
||||
f"finalized_by_identity: {merger_identity}",
|
||||
f"finalized_by_profile: {merger_profile}",
|
||||
f"finalized_by_session_id: {merger_session_id}",
|
||||
f"finalization_outcome: {outcome}",
|
||||
f"finalization_reason: {reason}",
|
||||
f"finalized_lease_comment_id: {lease_comment_id or 'none'}",
|
||||
lease_body,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def is_merger_finalization_comment(body: str) -> bool:
|
||||
return MERGER_FINALIZATION_MARKER in (body or "")
|
||||
|
||||
|
||||
def assess_merger_lease_finalization(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
session: dict[str, Any] | None,
|
||||
actor_identity: str,
|
||||
actor_profile: str,
|
||||
actor_session_id: str | None,
|
||||
repo: str,
|
||||
worktree: str,
|
||||
candidate_head: str | None,
|
||||
live_head_sha: str | None = None,
|
||||
outcome: str = OUTCOME_RELEASED,
|
||||
reason: str | None = None,
|
||||
runtime_token_fingerprint: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
target_branch: str = "master",
|
||||
target_branch_sha: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether a merger may terminally finalize its own lease (#742).
|
||||
|
||||
Owner-session only: the caller must hold the exact comment-backed merger
|
||||
lease it is finalizing. Foreign sessions, reviewer profiles, and mismatched
|
||||
repository/PR/head/token-fingerprint callers fail closed. Already-terminal
|
||||
leases return ``already_terminal`` so repeat calls are idempotent and post
|
||||
no second marker.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
outcome = (outcome or "").strip().lower()
|
||||
finalization_reason = (reason or "").strip() or DEFAULT_MERGER_FINALIZATION_REASON
|
||||
|
||||
if outcome not in MERGER_FINALIZATION_OUTCOMES:
|
||||
reasons.append(
|
||||
f"outcome '{outcome or 'none'}' is not a terminal merger "
|
||||
f"finalization outcome ({sorted(MERGER_FINALIZATION_OUTCOMES)})"
|
||||
)
|
||||
|
||||
if "merger" not in (actor_profile or "").lower():
|
||||
reasons.append(
|
||||
f"profile '{actor_profile or 'unknown'}' is not a merger profile; "
|
||||
"merger lease finalization is merger-only (fail closed). Reviewer "
|
||||
"sessions use gitea_release_reviewer_pr_lease."
|
||||
)
|
||||
|
||||
session = session or None
|
||||
provenance = (session or {}).get("lease_provenance") or {}
|
||||
if not isinstance(provenance, dict):
|
||||
provenance = {}
|
||||
source = (provenance.get("source") or "").strip()
|
||||
|
||||
if not session:
|
||||
reasons.append(
|
||||
"no in-session merger lease recorded; only the owning session may "
|
||||
"finalize a merger lease"
|
||||
)
|
||||
elif source not in MERGER_OWNED_PROVENANCE_SOURCES:
|
||||
reasons.append(
|
||||
f"in-session lease provenance '{source or 'none'}' is not a "
|
||||
"merger-owned lease; refusing to finalize"
|
||||
)
|
||||
elif not is_sanctioned_session_lease(session):
|
||||
reasons.extend(
|
||||
assess_acquired_merger_lease_integrity(session)
|
||||
if source == SOURCE_ACQUIRE_MERGER
|
||||
else ["in-session merger lease lacks sanctioned provenance"]
|
||||
)
|
||||
|
||||
pinned = leases._normalize_sha(candidate_head)
|
||||
live = leases._normalize_sha(live_head_sha)
|
||||
if not pinned:
|
||||
reasons.append(
|
||||
"candidate_head is required for merger lease finalization "
|
||||
"(exact-head scoping; fail closed)"
|
||||
)
|
||||
if live and pinned and live != pinned:
|
||||
reasons.append(
|
||||
"candidate_head does not match live PR head (fail closed)"
|
||||
)
|
||||
|
||||
if session:
|
||||
session_sid = (session.get("session_id") or "").strip()
|
||||
actor_sid = (actor_session_id or "").strip()
|
||||
if not actor_sid or session_sid != actor_sid:
|
||||
reasons.append(
|
||||
"session_id does not match the in-session merger lease owner; "
|
||||
"foreign-session release is not permitted (fail closed)"
|
||||
)
|
||||
if session.get("pr_number") != pr_number:
|
||||
reasons.append(
|
||||
f"in-session merger lease is for PR #{session.get('pr_number')}, "
|
||||
f"not #{pr_number}"
|
||||
)
|
||||
session_repo = (session.get("repo") or "").strip()
|
||||
if session_repo and session_repo != (repo or "").strip():
|
||||
reasons.append(
|
||||
f"in-session merger lease repository '{session_repo}' does not "
|
||||
f"match '{repo}' (fail closed)"
|
||||
)
|
||||
session_head = leases._normalize_sha(session.get("candidate_head"))
|
||||
if pinned and session_head and session_head != pinned:
|
||||
reasons.append(
|
||||
"in-session merger lease candidate_head does not match the "
|
||||
"supplied candidate_head (fail closed)"
|
||||
)
|
||||
session_identity = (session.get("reviewer_identity") or "").strip()
|
||||
if session_identity and session_identity != (actor_identity or "").strip():
|
||||
reasons.append(
|
||||
"authenticated identity does not match the in-session merger "
|
||||
"lease holder (fail closed)"
|
||||
)
|
||||
session_profile = (session.get("profile") or "").strip()
|
||||
if session_profile and session_profile != (actor_profile or "").strip():
|
||||
reasons.append(
|
||||
"active profile does not match the in-session merger lease "
|
||||
"profile (fail closed)"
|
||||
)
|
||||
recorded_fingerprint = (
|
||||
provenance.get("native_token_fingerprint") or ""
|
||||
).strip()
|
||||
live_fingerprint = (runtime_token_fingerprint or "").strip()
|
||||
if (
|
||||
recorded_fingerprint
|
||||
and live_fingerprint
|
||||
and recorded_fingerprint != live_fingerprint
|
||||
):
|
||||
reasons.append(
|
||||
"native runtime token fingerprint does not match the one "
|
||||
"recorded when the merger lease was acquired (fail closed)"
|
||||
)
|
||||
|
||||
lease_comment_id = (session or {}).get("comment_id") or provenance.get("comment_id")
|
||||
entries = leases._lease_entries(comments, pr_number=pr_number)
|
||||
if session and lease_comment_id is not None:
|
||||
if not any(entry.get("comment_id") == lease_comment_id for entry in entries):
|
||||
reasons.append(
|
||||
f"lease marker comment {lease_comment_id} is not present on PR "
|
||||
f"#{pr_number} (fail closed)"
|
||||
)
|
||||
|
||||
active = leases.find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
already_terminal = False
|
||||
if active:
|
||||
owner_session = (active.get("session_id") or "").strip()
|
||||
if owner_session and owner_session != (actor_session_id or "").strip():
|
||||
reasons.append(
|
||||
f"active PR lease is owned by session_id={owner_session}; a "
|
||||
"merger may only finalize its own lease (fail closed)"
|
||||
)
|
||||
else:
|
||||
newest = entries[-1] if entries else None
|
||||
newest_phase = ((newest or {}).get("phase") or "").strip().lower()
|
||||
if newest and newest_phase in leases._TERMINAL_PHASES:
|
||||
already_terminal = True
|
||||
elif not entries:
|
||||
reasons.append(
|
||||
f"no comment-backed lease marker found on PR #{pr_number}"
|
||||
)
|
||||
|
||||
finalize_allowed = not reasons and not already_terminal
|
||||
body = None
|
||||
if finalize_allowed and session:
|
||||
body = format_merger_finalization_body(
|
||||
repo=(session.get("repo") or repo),
|
||||
pr_number=pr_number,
|
||||
issue_number=(
|
||||
issue_number
|
||||
if issue_number is not None
|
||||
else session.get("issue_number")
|
||||
),
|
||||
merger_identity=actor_identity,
|
||||
merger_profile=actor_profile,
|
||||
merger_session_id=(actor_session_id or ""),
|
||||
worktree=worktree or (session.get("worktree") or ""),
|
||||
candidate_head=pinned,
|
||||
target_branch=(
|
||||
session.get("target_branch") or target_branch or "master"
|
||||
),
|
||||
target_branch_sha=(
|
||||
session.get("target_branch_sha") or target_branch_sha
|
||||
),
|
||||
outcome=outcome,
|
||||
reason=finalization_reason,
|
||||
lease_comment_id=lease_comment_id,
|
||||
finalized_at=now,
|
||||
)
|
||||
|
||||
return {
|
||||
"finalize_allowed": finalize_allowed,
|
||||
"already_terminal": already_terminal,
|
||||
"reasons": reasons,
|
||||
"outcome": outcome,
|
||||
"finalization_reason": finalization_reason,
|
||||
"active_lease": active,
|
||||
"finalization_body": body,
|
||||
"lease_comment_id": lease_comment_id,
|
||||
"candidate_head": pinned,
|
||||
}
|
||||
|
||||
|
||||
def assess_adopt_merger_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Mutation-budget classification for auto-mode attempts (#617).
|
||||
|
||||
Mutation budget must count only *server-side* Gitea state changes. A tool call
|
||||
that fails closed before the Gitea API is reached changed nothing on the
|
||||
server, so it must not consume the budget that protects against repeated real
|
||||
mutations.
|
||||
|
||||
The classifier separates four outcome classes plus an explicit ambiguous class:
|
||||
|
||||
``local_validator_rejection``
|
||||
A canonical-content validator (for example the ``[THREAD STATE LEDGER]`` or
|
||||
``## Canonical Issue State`` blocks) rejected the payload before any API
|
||||
call. No server-side state exists.
|
||||
|
||||
``capability_gate_rejection``
|
||||
A profile/permission gate refused the operation before any API call.
|
||||
|
||||
``transport_failure_before_api``
|
||||
The request never reached the Gitea API (transport/EOF/connection error).
|
||||
|
||||
``server_side_mutation``
|
||||
The API succeeded and returned proof of durable state (comment id, review
|
||||
id, merge commit, label result, or an issue/PR state change).
|
||||
|
||||
``ambiguous_requires_readback``
|
||||
The API *was* reached but the result carries no usable proof either way.
|
||||
This fails closed: the attempt is treated as budget-consuming until a
|
||||
read-after-write check proves otherwise, so #617 never weakens the guard
|
||||
that prevents repeated real mutations.
|
||||
|
||||
Only ``server_side_mutation`` consumes budget outright. Every attempt — failed
|
||||
or not — is still recorded in the local attempt ledger so a final report can
|
||||
show local failed attempts, blocked API attempts, and successful server-side
|
||||
mutations separately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
LOCAL_VALIDATOR_REJECTION = "local_validator_rejection"
|
||||
CAPABILITY_GATE_REJECTION = "capability_gate_rejection"
|
||||
TRANSPORT_FAILURE_BEFORE_API = "transport_failure_before_api"
|
||||
SERVER_SIDE_MUTATION = "server_side_mutation"
|
||||
AMBIGUOUS_REQUIRES_READBACK = "ambiguous_requires_readback"
|
||||
|
||||
CLASSIFICATIONS = (
|
||||
LOCAL_VALIDATOR_REJECTION,
|
||||
CAPABILITY_GATE_REJECTION,
|
||||
TRANSPORT_FAILURE_BEFORE_API,
|
||||
SERVER_SIDE_MUTATION,
|
||||
AMBIGUOUS_REQUIRES_READBACK,
|
||||
)
|
||||
|
||||
#: Result fields that prove durable server-side state was created.
|
||||
MUTATION_PROOF_FIELDS = (
|
||||
"comment_id",
|
||||
"review_id",
|
||||
"merge_commit_sha",
|
||||
"label_result",
|
||||
"state_change",
|
||||
"created_pr_number",
|
||||
)
|
||||
|
||||
#: Classes that never consume server-side mutation budget.
|
||||
PRE_API_CLASSIFICATIONS = (
|
||||
LOCAL_VALIDATOR_REJECTION,
|
||||
CAPABILITY_GATE_REJECTION,
|
||||
TRANSPORT_FAILURE_BEFORE_API,
|
||||
)
|
||||
|
||||
FINAL_REPORT_REQUIRED_FIELDS = (
|
||||
"local_failed_attempts",
|
||||
"blocked_api_attempts",
|
||||
"successful_server_mutations",
|
||||
)
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return (value or "").strip() if isinstance(value, str) else str(value or "").strip()
|
||||
|
||||
|
||||
def _proof_fields_present(result: dict) -> list[str]:
|
||||
"""Return the mutation-proof fields carrying a usable value."""
|
||||
present: list[str] = []
|
||||
for field in MUTATION_PROOF_FIELDS:
|
||||
value = result.get(field)
|
||||
if value is None or value is False:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
present.append(field)
|
||||
return present
|
||||
|
||||
|
||||
def _decision(
|
||||
classification: str,
|
||||
*,
|
||||
budget_consumed: bool,
|
||||
requires_readback: bool,
|
||||
reasons: list[str],
|
||||
proof_fields: list[str],
|
||||
api_called: bool | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"classification": classification,
|
||||
"budget_consumed": budget_consumed,
|
||||
"requires_readback": requires_readback,
|
||||
"pre_api": classification in PRE_API_CLASSIFICATIONS,
|
||||
"api_called": api_called,
|
||||
"proof_fields": proof_fields,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def classify_mutation_attempt(result: dict | None) -> dict:
|
||||
"""Classify one mutation attempt and decide whether it consumes budget.
|
||||
|
||||
``result`` is the raw dict a Gitea MCP tool returned. The caller does not
|
||||
pre-interpret it: classification is driven by the explicit ``api_called``
|
||||
signal plus the proof fields the tool reports.
|
||||
"""
|
||||
data = dict(result or {})
|
||||
success = bool(data.get("success"))
|
||||
proof_fields = _proof_fields_present(data)
|
||||
api_called = data.get("api_called")
|
||||
|
||||
# An unambiguous success carrying durable proof is a real mutation however
|
||||
# the attempt was labelled upstream.
|
||||
if success and proof_fields:
|
||||
return _decision(
|
||||
SERVER_SIDE_MUTATION,
|
||||
budget_consumed=True,
|
||||
requires_readback=False,
|
||||
reasons=[
|
||||
"API reported success with durable proof field(s): "
|
||||
+ ", ".join(proof_fields)
|
||||
],
|
||||
proof_fields=proof_fields,
|
||||
api_called=True,
|
||||
)
|
||||
|
||||
if api_called is False:
|
||||
# Nothing reached the server; pick the precise pre-API class.
|
||||
if data.get("transport_error") or data.get("transport_failed"):
|
||||
return _decision(
|
||||
TRANSPORT_FAILURE_BEFORE_API,
|
||||
budget_consumed=False,
|
||||
requires_readback=False,
|
||||
reasons=["transport failed before the Gitea API was reached"],
|
||||
proof_fields=[],
|
||||
api_called=False,
|
||||
)
|
||||
if data.get("permission_report") or data.get("capability_blocked"):
|
||||
return _decision(
|
||||
CAPABILITY_GATE_REJECTION,
|
||||
budget_consumed=False,
|
||||
requires_readback=False,
|
||||
reasons=["capability/permission gate refused before any API call"],
|
||||
proof_fields=[],
|
||||
api_called=False,
|
||||
)
|
||||
return _decision(
|
||||
LOCAL_VALIDATOR_REJECTION,
|
||||
budget_consumed=False,
|
||||
requires_readback=False,
|
||||
reasons=[
|
||||
"local validator rejected the payload before any API call; "
|
||||
"no server-side state was created"
|
||||
],
|
||||
proof_fields=[],
|
||||
api_called=False,
|
||||
)
|
||||
|
||||
if api_called is True:
|
||||
if success:
|
||||
reason = (
|
||||
"API reported success but returned no durable proof field; "
|
||||
"read-after-write verification required before counting budget"
|
||||
)
|
||||
else:
|
||||
reason = (
|
||||
"API was reached and the outcome carries no durable proof; "
|
||||
"read-after-write verification required before counting budget"
|
||||
)
|
||||
return _decision(
|
||||
AMBIGUOUS_REQUIRES_READBACK,
|
||||
budget_consumed=True,
|
||||
requires_readback=True,
|
||||
reasons=[reason],
|
||||
proof_fields=proof_fields,
|
||||
api_called=True,
|
||||
)
|
||||
|
||||
# ``api_called`` was not reported at all. Fail closed rather than assuming
|
||||
# nothing happened.
|
||||
return _decision(
|
||||
AMBIGUOUS_REQUIRES_READBACK,
|
||||
budget_consumed=True,
|
||||
requires_readback=True,
|
||||
reasons=[
|
||||
"attempt did not report 'api_called'; cannot prove the request "
|
||||
"stopped before the Gitea API, so the attempt fails closed"
|
||||
],
|
||||
proof_fields=proof_fields,
|
||||
api_called=None,
|
||||
)
|
||||
|
||||
|
||||
def record_attempt(
|
||||
ledger: list[dict] | None,
|
||||
result: dict | None,
|
||||
*,
|
||||
operation: str = "",
|
||||
timestamp: str | None = None,
|
||||
) -> dict:
|
||||
"""Append one classified attempt to the local ledger and return the entry.
|
||||
|
||||
Every attempt is recorded, including the ones that consume no budget: the
|
||||
point of #617 is that failed local attempts stay visible without being
|
||||
miscounted as Gitea mutations.
|
||||
"""
|
||||
entries = ledger if isinstance(ledger, list) else []
|
||||
entry = {
|
||||
"operation": _clean(operation),
|
||||
"timestamp": _clean(timestamp) or datetime.now(timezone.utc).isoformat(),
|
||||
**classify_mutation_attempt(result),
|
||||
}
|
||||
entries.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def summarize_attempt_ledger(ledger: list[dict] | None) -> dict:
|
||||
"""Summarize a ledger into the categories a final report must show."""
|
||||
entries = [e for e in (ledger or []) if isinstance(e, dict)]
|
||||
|
||||
def _count(*classifications: str) -> int:
|
||||
return sum(1 for e in entries if e.get("classification") in classifications)
|
||||
|
||||
return {
|
||||
"total_attempts": len(entries),
|
||||
"local_failed_attempts": _count(LOCAL_VALIDATOR_REJECTION),
|
||||
"blocked_api_attempts": _count(
|
||||
CAPABILITY_GATE_REJECTION, TRANSPORT_FAILURE_BEFORE_API
|
||||
),
|
||||
"successful_server_mutations": _count(SERVER_SIDE_MUTATION),
|
||||
"ambiguous_attempts": _count(AMBIGUOUS_REQUIRES_READBACK),
|
||||
"budget_consumed": sum(1 for e in entries if e.get("budget_consumed")),
|
||||
"requires_readback": any(e.get("requires_readback") for e in entries),
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
def assess_final_report_mutation_accounting(
|
||||
report: dict | None,
|
||||
ledger: list[dict] | None,
|
||||
) -> dict:
|
||||
"""Fail closed when a report's mutation accounting contradicts the ledger."""
|
||||
data = dict(report or {})
|
||||
summary = summarize_attempt_ledger(ledger)
|
||||
reasons: list[str] = []
|
||||
|
||||
for field in FINAL_REPORT_REQUIRED_FIELDS:
|
||||
if field not in data:
|
||||
reasons.append(f"final report omits required field '{field}'")
|
||||
continue
|
||||
claimed = data.get(field)
|
||||
actual = summary[field]
|
||||
if claimed != actual:
|
||||
reasons.append(
|
||||
f"final report claims {field}={claimed} but the attempt ledger "
|
||||
f"shows {actual}"
|
||||
)
|
||||
|
||||
if summary["requires_readback"] and not data.get("readback_verified"):
|
||||
reasons.append(
|
||||
"ledger contains an ambiguous attempt; final report must record "
|
||||
"'readback_verified' proof before claiming mutation accounting"
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"reasons": reasons,
|
||||
"ledger_summary": {k: v for k, v in summary.items() if k != "entries"},
|
||||
}
|
||||
+169
-36
@@ -55,18 +55,26 @@ def resolve_namespace_workspace(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
demotions: list[str] | None = None,
|
||||
verify_paths: bool = False,
|
||||
durable_author_result: dict | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*.
|
||||
|
||||
With *verify_paths*, env-sourced candidates whose path no longer exists
|
||||
are demoted (#702): a binding to a deleted worktree can never name a
|
||||
valid task workspace, so resolution falls through to the next candidate.
|
||||
Explicit arguments are never demoted — a caller-declared path must fail
|
||||
loudly downstream rather than silently rebind. Demotion notes are
|
||||
appended to *demotions* when provided. Runtime-context and mutation
|
||||
are demoted (#702) for non-author roles: a binding to a deleted worktree
|
||||
can never name a valid task workspace, so resolution falls through to the
|
||||
next candidate. Explicit arguments are never demoted — a caller-declared
|
||||
path must fail loudly downstream rather than silently rebind. Demotion
|
||||
notes are appended to *demotions* when provided.
|
||||
|
||||
Author role (#618): never demotes a missing configured binding to the
|
||||
control checkout. When *verify_paths* is true, resolution goes through
|
||||
:func:`author_mutation_worktree.resolve_durable_author_worktree` so
|
||||
mutations either use an explicit validated worktree, derive from the
|
||||
active author issue lock, or fail closed. Runtime-context and mutation
|
||||
guards resolve through :func:`resolve_namespace_mutation_context`, which
|
||||
always verifies.
|
||||
"""
|
||||
@@ -74,6 +82,32 @@ def resolve_namespace_workspace(
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||
|
||||
# #618: durable author resolution — no silent control/master fallback.
|
||||
if role == "author" and verify_paths:
|
||||
durable = durable_author_result
|
||||
if durable is None:
|
||||
durable = amw.resolve_durable_author_worktree(
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
# Path selection only here; full validation is re-run in
|
||||
# resolve_namespace_mutation_context with the canonical root.
|
||||
validate=False,
|
||||
)
|
||||
workspace = durable.get("workspace_path") or os.path.realpath(
|
||||
process_project_root
|
||||
)
|
||||
source = durable.get("workspace_binding_source") or "no author worktree binding"
|
||||
if demotions is not None and durable.get("bound_worktree_missing"):
|
||||
demotions.append(
|
||||
f"{source} '{workspace}' not demoted: {amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
||||
)
|
||||
return workspace, source
|
||||
|
||||
for candidate, source, env_sourced in (
|
||||
(worktree_path, "worktree_path argument", False),
|
||||
(worktree, "worktree argument", False),
|
||||
@@ -83,6 +117,11 @@ def resolve_namespace_workspace(
|
||||
f"{role_env_key} environment variable", True),
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree", False),
|
||||
# Author lock derivation is handled by the durable path above when
|
||||
# verify_paths is true; when verify_paths is false, surface the lock
|
||||
# path as a non-demoted candidate so tooling can inspect it.
|
||||
(session_lock_worktree if role == "author" else None,
|
||||
"active author issue lock worktree", False),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if not text:
|
||||
@@ -107,24 +146,67 @@ def resolve_namespace_mutation_context(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
configured_canonical_root: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||
"""Shared workspace resolution for runtime_context and mutation guards.
|
||||
|
||||
When *configured_canonical_root* is supplied (a cross-repository namespace
|
||||
bound to an external target repository, #706), the canonical repository root
|
||||
is that configured target rather than the MCP install checkout. This keeps
|
||||
the branches-only / worktree-membership guards (#274) evaluating against the
|
||||
repository the namespace actually mutates. Without it the single-repo
|
||||
default is preserved: the canonical root follows the process checkout.
|
||||
|
||||
Author role (#618): uses durable worktree resolution (explicit path, env,
|
||||
or active issue lock) and never silently falls back to the control checkout.
|
||||
"""
|
||||
demotions: list[str] = []
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
env_map = env if env is not None else os.environ
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
configured = (configured_canonical_root or "").strip()
|
||||
if configured:
|
||||
canonical_root = os.path.realpath(configured)
|
||||
else:
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
|
||||
durable: dict | None = None
|
||||
if role == "author":
|
||||
durable = amw.resolve_durable_author_worktree(
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_root,
|
||||
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
canonical_repo_root=canonical_root,
|
||||
profile_name=profile_name,
|
||||
validate=True,
|
||||
)
|
||||
workspace = durable["workspace_path"]
|
||||
binding_source = durable["workspace_binding_source"]
|
||||
if durable.get("bound_worktree_missing"):
|
||||
demotions.append(
|
||||
f"{binding_source} '{workspace}' not demoted: "
|
||||
f"{amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
||||
)
|
||||
else:
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=workspace,
|
||||
@@ -132,8 +214,7 @@ def resolve_namespace_mutation_context(
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
result = {
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
@@ -142,6 +223,17 @@ def resolve_namespace_mutation_context(
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
if durable is not None:
|
||||
result["author_worktree_resolution"] = durable
|
||||
result["bound_worktree_missing"] = bool(durable.get("bound_worktree_missing"))
|
||||
result["path_exists"] = durable.get("path_exists")
|
||||
result["in_git_worktree_list"] = durable.get("in_git_worktree_list")
|
||||
result["inspected_git_root"] = durable.get("inspected_git_root")
|
||||
result["author_worktree_block"] = bool(durable.get("block"))
|
||||
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
|
||||
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
|
||||
result["operator_recovery"] = durable.get("operator_recovery")
|
||||
return result
|
||||
|
||||
|
||||
def assess_foreign_role_worktree_pollution(
|
||||
@@ -218,10 +310,29 @@ def format_namespace_workspace_binding_error(
|
||||
reasons: list[str] | None = None,
|
||||
ignored_bindings: list[str] | None = None,
|
||||
dirty_files: list[str] | None = None,
|
||||
operator_recovery: str | None = None,
|
||||
) -> str:
|
||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||
role = normalize_role_kind(role_kind)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
reason_list = list(reasons or [])
|
||||
# #618: prefer the durable author missing-worktree message when present.
|
||||
if role == "author" and any(
|
||||
amw.BOUND_WORKTREE_MISSING_MESSAGE in r for r in reason_list
|
||||
):
|
||||
return amw.format_bound_worktree_missing_error(
|
||||
{
|
||||
"reasons": reason_list,
|
||||
"binding_source": binding_source,
|
||||
"configured_path": workspace_path,
|
||||
"role_kind": role,
|
||||
"operator_recovery": operator_recovery
|
||||
or amw.OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
}
|
||||
)
|
||||
try:
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
except OSError:
|
||||
workspace = workspace_path
|
||||
parts = [
|
||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||
f"resolved workspace '{workspace}' via {binding_source}."
|
||||
@@ -236,15 +347,18 @@ def format_namespace_workspace_binding_error(
|
||||
+ ", ".join(dirty_files)
|
||||
+ "."
|
||||
)
|
||||
if reasons:
|
||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
if reason_list:
|
||||
parts.append("Details: " + "; ".join(reason_list) + ".")
|
||||
if operator_recovery:
|
||||
parts.append(f"Operator recovery: {operator_recovery}")
|
||||
else:
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@@ -256,8 +370,10 @@ def assess_namespace_mutation_workspace(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
configured_canonical_root: str | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||
ctx = resolve_namespace_mutation_context(
|
||||
@@ -267,7 +383,9 @@ def assess_namespace_mutation_workspace(
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
configured_canonical_root=configured_canonical_root,
|
||||
)
|
||||
mutation_workspace = ctx["workspace_path"]
|
||||
binding_source = ctx["workspace_binding_source"]
|
||||
@@ -290,14 +408,23 @@ def assess_namespace_mutation_workspace(
|
||||
)
|
||||
|
||||
reasons = list(metadata.get("reasons") or [])
|
||||
operator_recovery = ctx.get("operator_recovery")
|
||||
if role == "author":
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
# #618 durable resolution already validated existence, membership,
|
||||
# branches/, lock ownership, and traversal safety when present.
|
||||
durable_reasons = list(ctx.get("author_worktree_reasons") or [])
|
||||
if durable_reasons:
|
||||
reasons.extend(durable_reasons)
|
||||
elif ctx.get("author_worktree_block"):
|
||||
reasons.append(amw.BOUND_WORKTREE_MISSING_MESSAGE)
|
||||
else:
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
elif (
|
||||
role == "reviewer"
|
||||
and mutation_workspace == process_root
|
||||
@@ -330,4 +457,10 @@ def assess_namespace_mutation_workspace(
|
||||
"metadata_only": metadata.get("metadata_only", False),
|
||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"bound_worktree_missing": bool(ctx.get("bound_worktree_missing")),
|
||||
"path_exists": ctx.get("path_exists"),
|
||||
"in_git_worktree_list": ctx.get("in_git_worktree_list"),
|
||||
"inspected_git_root": ctx.get("inspected_git_root"),
|
||||
"operator_recovery": operator_recovery,
|
||||
"blocker_kind": ctx.get("author_worktree_blocker_kind"),
|
||||
}
|
||||
@@ -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"
|
||||
_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:
|
||||
text = (value or "").strip().lower()
|
||||
@@ -120,6 +300,7 @@ def assess_pr_sync_status(
|
||||
"branch_protection_requires_current_base": requires_current,
|
||||
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
||||
"checks_status": checks,
|
||||
"checks_required": bool(checks_required),
|
||||
"active_locks_and_leases": {
|
||||
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
|
||||
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
|
||||
@@ -258,21 +439,41 @@ def assess_pr_sync_status(
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
# ── Checks gate for merge_now ────────────────────────────────────────
|
||||
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
|
||||
if checks in ("pending", "running", "queued"):
|
||||
# ── Checks gate for merge_now (#751) ─────────────────────────────────
|
||||
# ``checks_required`` is derived from the live branch-protection policy by
|
||||
# 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})")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
if checks in ("failure", "failed", "error", "cancelled"):
|
||||
elif checks in _CTX_FAILURE:
|
||||
reasons.append(f"required checks failed (status={checks})")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
# unknown — fail closed when checks_required
|
||||
if checks == "unknown":
|
||||
elif checks == CHECKS_MISSING_REQUIRED:
|
||||
reasons.append(
|
||||
"branch protection configures required status context(s) but no "
|
||||
"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)")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
else:
|
||||
# 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 ────────────────────────────────────
|
||||
# Includes: current with approval; outdated when update is NOT required.
|
||||
|
||||
+56
-4
@@ -20,7 +20,11 @@ _FIELD_RE = re.compile(
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"})
|
||||
# Must mirror reviewer_pr_lease._TERMINAL_PHASES: both modules read the same
|
||||
# append-only lease markers, so a phase that is terminal in one and active in
|
||||
# the other yields two conflicting truths for the same comment (#742 review
|
||||
# 460). "abandoned" is the owner-session merger finalization outcome.
|
||||
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked", "abandoned"})
|
||||
_ACTIVE_REVIEWER_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
@@ -32,7 +36,8 @@ _TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
|
||||
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
||||
|
||||
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:
|
||||
@@ -153,25 +158,72 @@ def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _reviewer_chain_key(lease: dict) -> tuple | None:
|
||||
"""Identity of the lease chain a reviewer marker belongs to (#742).
|
||||
|
||||
A chain is one session's claim → heartbeat → terminal sequence, keyed by
|
||||
repository, PR, candidate head, session id, identity, and profile. Returns
|
||||
None when any component is missing: an incomplete or malformed marker has
|
||||
no provable chain, so it can neither be cancelled by nor cancel anything.
|
||||
"""
|
||||
raw = lease.get("raw_fields") or {}
|
||||
repo = (raw.get("repo") or "").strip().lower()
|
||||
session_id = (lease.get("session_id") or "").strip()
|
||||
identity = (lease.get("reviewer_identity") or "").strip()
|
||||
profile = (lease.get("profile") or "").strip()
|
||||
head = lease.get("candidate_head")
|
||||
pr_number = lease.get("pr_number")
|
||||
if not (repo and session_id and identity and profile and head and pr_number):
|
||||
return None
|
||||
return (repo, pr_number, head, session_id, identity, profile)
|
||||
|
||||
|
||||
def _chain_terminated_after(entries: list[dict], index: int) -> bool:
|
||||
"""True when a later marker terminates the chain of ``entries[index]``.
|
||||
|
||||
Append-only newest-wins (#577 semantics, chain-scoped for #742): a terminal
|
||||
marker ends only its *own* claim, so a foreign, forged, or malformed
|
||||
terminal marker cannot cancel another session's valid active lease.
|
||||
"""
|
||||
key = _reviewer_chain_key(entries[index])
|
||||
if key is None:
|
||||
return False
|
||||
for later in entries[index + 1:]:
|
||||
if (later.get("phase") or "").strip().lower() not in _TERMINAL_REVIEWER_PHASES:
|
||||
continue
|
||||
if _reviewer_chain_key(later) == key:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_active_reviewer_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired reviewer lease for *pr_number*, if any."""
|
||||
"""Return the newest unexpired, non-terminated reviewer lease for *pr_number*.
|
||||
|
||||
Walking backward past a terminal marker used to resurrect the older claim of
|
||||
the very chain that marker ended, so a released/abandoned finalization still
|
||||
read as an active lease here while ``reviewer_pr_lease`` reported it ended
|
||||
(#742). A claim is now skipped when a later marker terminates its own chain.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "reviewer"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
for index in range(len(candidates) - 1, -1, -1):
|
||||
lease = candidates[index]
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_REVIEWER_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_REVIEWER_PHASES or phase:
|
||||
if _chain_terminated_after(candidates, index):
|
||||
continue
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
+50
-8
@@ -16,7 +16,10 @@ _FIELD_RE = re.compile(
|
||||
)
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_TERMINAL_PHASES = frozenset({"done", "released", "blocked"})
|
||||
# "abandoned" is the owner-session merger finalization outcome (#742). Without
|
||||
# it here, an abandoned marker would fall through to the generic non-empty
|
||||
# phase branch and keep re-arming the lease as active.
|
||||
_TERMINAL_PHASES = frozenset({"done", "released", "blocked", "abandoned"})
|
||||
_ACTIVE_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
@@ -26,9 +29,19 @@ _ACTIVE_PHASES = frozenset({
|
||||
"adopted",
|
||||
})
|
||||
|
||||
DEFAULT_LEASE_TTL_MINUTES = 120
|
||||
STALE_WARNING_MINUTES = 30
|
||||
RECLAIMABLE_MINUTES = 60
|
||||
# Reviewer and merger PR leases use a short *sliding* window (#747): a lease
|
||||
# expires 10 minutes after its last heartbeat, and every heartbeat slides the
|
||||
# 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
|
||||
|
||||
@@ -77,10 +90,19 @@ def format_lease_body(
|
||||
target_branch_sha: str | None,
|
||||
last_activity: datetime | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
ttl_minutes: int = LEASE_TTL_MINUTES,
|
||||
blocker: str = "none",
|
||||
) -> 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)
|
||||
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(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
@@ -166,8 +188,30 @@ def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
|
||||
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:
|
||||
"""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)
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_PHASES:
|
||||
@@ -177,8 +221,6 @@ def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str
|
||||
minutes = _minutes_since_activity(lease, now=now)
|
||||
if minutes is None:
|
||||
return "active"
|
||||
if minutes >= RECLAIMABLE_MINUTES:
|
||||
return "reclaimable"
|
||||
if minutes >= STALE_WARNING_MINUTES:
|
||||
return "stale_warning"
|
||||
return "active"
|
||||
|
||||
+14
-2
@@ -76,7 +76,6 @@ AUTHOR_TASKS = frozenset({
|
||||
"create_pr",
|
||||
"comment_pr",
|
||||
"address_pr_change_requests",
|
||||
"delete_branch",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
"reconcile_landed_pr",
|
||||
@@ -84,6 +83,15 @@ AUTHOR_TASKS = frozenset({
|
||||
|
||||
RECONCILER_TASKS = frozenset({
|
||||
"cleanup_merged_pr_branch",
|
||||
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
|
||||
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
|
||||
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
|
||||
"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",
|
||||
"reconcile-landed-pr",
|
||||
@@ -99,7 +107,8 @@ TASK_REQUIRED_ROLE = {
|
||||
"create_pr": "author",
|
||||
"comment_pr": "author",
|
||||
"address_pr_change_requests": "author",
|
||||
"delete_branch": "author",
|
||||
# #729: reconciler-owned; see RECONCILER_TASKS and task_capability_map.
|
||||
"delete_branch": "reconciler",
|
||||
"review_pr": "reviewer",
|
||||
"merge_pr": "merger",
|
||||
"blind_pr_queue_review": "reviewer",
|
||||
@@ -114,6 +123,9 @@ TASK_REQUIRED_ROLE = {
|
||||
"reconcile_already_landed": "reconciler",
|
||||
"reconcile-landed-pr": "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.
|
||||
"reconcile_close_landed_pr": "reconciler",
|
||||
"reconcile_close_landed_issue": "reconciler",
|
||||
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/promote-stable-runtime [--root <path>] [--promoted <sha>] \
|
||||
[--source-branch <branch>] [--source-pr <n>] \
|
||||
[--restart-method <text>] [--rollback <text>] \
|
||||
[--health-check-proof <text>] \
|
||||
[--identity-proof <text>] [--profile-proof <text>] \
|
||||
[--workspace-proof <text>] \
|
||||
[--mutation-capability-proof <text>]
|
||||
|
||||
Emit and validate a stable-control-runtime promotion record (#615).
|
||||
|
||||
This helper is READ-ONLY. It never fetches, merges, restarts, reloads, or kills
|
||||
anything: promotion itself is an operator action documented in
|
||||
docs/stable-runtime-promotion-runbook.md. The helper reads the current runtime
|
||||
state, assembles the required record, validates it with
|
||||
stable_control_runtime.assess_promotion_record(), and prints it for the operator
|
||||
to act on and archive.
|
||||
|
||||
Defaults:
|
||||
--root the repository root containing this script
|
||||
--promoted HEAD of that root
|
||||
|
||||
Exit status is non-zero when the assembled record is incomplete, so a promotion
|
||||
cannot be recorded without its proof fields.
|
||||
|
||||
Example:
|
||||
scripts/promote-stable-runtime \
|
||||
--source-branch feat/issue-615-runtime-mode-enforcement \
|
||||
--source-pr 770 \
|
||||
--restart-method "IDE client reconnect (/mcp)" \
|
||||
--rollback "git -C <root> merge --ff-only <previous-sha>; reconnect client" \
|
||||
--health-check-proof "gitea_assess_mcp_namespace_health: all four healthy" \
|
||||
--identity-proof "gitea_whoami per namespace" \
|
||||
--profile-proof "gitea_get_runtime_context per namespace" \
|
||||
--workspace-proof "process root == canonical root; clean" \
|
||||
--mutation-capability-proof "gitea_resolve_task_capability: allowed"
|
||||
EOF
|
||||
}
|
||||
|
||||
ROOT=""
|
||||
PROMOTED=""
|
||||
SOURCE_BRANCH=""
|
||||
SOURCE_PR=""
|
||||
RESTART_METHOD=""
|
||||
ROLLBACK=""
|
||||
HEALTH_PROOF=""
|
||||
IDENTITY_PROOF=""
|
||||
PROFILE_PROOF=""
|
||||
WORKSPACE_PROOF=""
|
||||
CAPABILITY_PROOF=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--root) ROOT="${2:-}"; shift 2 ;;
|
||||
--promoted) PROMOTED="${2:-}"; shift 2 ;;
|
||||
--source-branch) SOURCE_BRANCH="${2:-}"; shift 2 ;;
|
||||
--source-pr) SOURCE_PR="${2:-}"; shift 2 ;;
|
||||
--restart-method) RESTART_METHOD="${2:-}"; shift 2 ;;
|
||||
--rollback) ROLLBACK="${2:-}"; shift 2 ;;
|
||||
--health-check-proof) HEALTH_PROOF="${2:-}"; shift 2 ;;
|
||||
--identity-proof) IDENTITY_PROOF="${2:-}"; shift 2 ;;
|
||||
--profile-proof) PROFILE_PROOF="${2:-}"; shift 2 ;;
|
||||
--workspace-proof) WORKSPACE_PROOF="${2:-}"; shift 2 ;;
|
||||
--mutation-capability-proof) CAPABILITY_PROOF="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="${ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
||||
|
||||
if ! git -C "$ROOT" rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
echo "error: '$ROOT' is not a git checkout; cannot read runtime SHAs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREVIOUS="${GITEA_MCP_PREVIOUS_RUNTIME_SHA:-}"
|
||||
if [[ -z "$PREVIOUS" ]]; then
|
||||
# The runtime the operator is replacing. Best-effort: the commit master
|
||||
# pointed at before the fast-forward, recorded in the reflog.
|
||||
PREVIOUS="$(git -C "$ROOT" rev-parse 'master@{1}' 2>/dev/null || true)"
|
||||
fi
|
||||
PROMOTED="${PROMOTED:-$(git -C "$ROOT" rev-parse HEAD)}"
|
||||
BRANCH="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
DIRTY="$(git -C "$ROOT" status --porcelain | wc -l | tr -d ' ')"
|
||||
STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
if [[ -z "$WORKSPACE_PROOF" ]]; then
|
||||
WORKSPACE_PROOF="root=$ROOT branch=$BRANCH dirty_files=$DIRTY"
|
||||
fi
|
||||
if [[ -z "$ROLLBACK" && -n "$PREVIOUS" ]]; then
|
||||
ROLLBACK="git -C $ROOT merge --ff-only $PREVIOUS (or checkout $PREVIOUS), then reload the runtime by the same sanctioned method and re-prove every namespace"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
# Stable control runtime promotion record (#615)
|
||||
# Generated $STAMP by scripts/promote-stable-runtime (read-only)
|
||||
|
||||
previous_runtime_sha: ${PREVIOUS:-<MISSING: record the SHA the runtime served before promotion>}
|
||||
promoted_runtime_sha: ${PROMOTED}
|
||||
source_branch: ${SOURCE_BRANCH:-<MISSING: pass --source-branch>}
|
||||
source_pr: ${SOURCE_PR:-<MISSING: pass --source-pr>}
|
||||
restart_method: ${RESTART_METHOD:-<MISSING: pass --restart-method>}
|
||||
health_check_proof: ${HEALTH_PROOF:-<MISSING: pass --health-check-proof>}
|
||||
identity_proof: ${IDENTITY_PROOF:-<MISSING: pass --identity-proof>}
|
||||
profile_proof: ${PROFILE_PROOF:-<MISSING: pass --profile-proof>}
|
||||
workspace_proof: ${WORKSPACE_PROOF}
|
||||
mutation_capability_proof: ${CAPABILITY_PROOF:-<MISSING: pass --mutation-capability-proof>}
|
||||
rollback_instructions: ${ROLLBACK:-<MISSING: pass --rollback>}
|
||||
EOF
|
||||
|
||||
if [[ "$DIRTY" != "0" ]]; then
|
||||
{
|
||||
echo
|
||||
echo "WARNING: the stable checkout has $DIRTY dirty file(s); a dirty stable"
|
||||
echo " runtime is itself a mutation blocker (dirty_stable_runtime_checkout)."
|
||||
} >&2
|
||||
fi
|
||||
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
RECORD_JSON="$(
|
||||
ROOT="$ROOT" \
|
||||
PREVIOUS="$PREVIOUS" PROMOTED="$PROMOTED" \
|
||||
SOURCE_BRANCH="$SOURCE_BRANCH" SOURCE_PR="$SOURCE_PR" \
|
||||
RESTART_METHOD="$RESTART_METHOD" HEALTH_PROOF="$HEALTH_PROOF" \
|
||||
IDENTITY_PROOF="$IDENTITY_PROOF" PROFILE_PROOF="$PROFILE_PROOF" \
|
||||
WORKSPACE_PROOF="$WORKSPACE_PROOF" CAPABILITY_PROOF="$CAPABILITY_PROOF" \
|
||||
ROLLBACK="$ROLLBACK" \
|
||||
"$PYTHON_BIN" -c '
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"previous_runtime_sha": os.environ.get("PREVIOUS", ""),
|
||||
"promoted_runtime_sha": os.environ.get("PROMOTED", ""),
|
||||
"source_branch": os.environ.get("SOURCE_BRANCH", ""),
|
||||
"source_pr": os.environ.get("SOURCE_PR", ""),
|
||||
"restart_method": os.environ.get("RESTART_METHOD", ""),
|
||||
"health_check_proof": os.environ.get("HEALTH_PROOF", ""),
|
||||
"identity_proof": os.environ.get("IDENTITY_PROOF", ""),
|
||||
"profile_proof": os.environ.get("PROFILE_PROOF", ""),
|
||||
"workspace_proof": os.environ.get("WORKSPACE_PROOF", ""),
|
||||
"mutation_capability_proof": os.environ.get("CAPABILITY_PROOF", ""),
|
||||
"rollback_instructions": os.environ.get("ROLLBACK", ""),
|
||||
}))
|
||||
'
|
||||
)"
|
||||
|
||||
RECORD_JSON="$RECORD_JSON" ROOT="$ROOT" "$PYTHON_BIN" -c '
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.environ["ROOT"])
|
||||
import stable_control_runtime as scr
|
||||
|
||||
record = json.loads(os.environ["RECORD_JSON"])
|
||||
assessment = scr.assess_promotion_record(record)
|
||||
print()
|
||||
print("validation:", json.dumps(assessment, indent=2))
|
||||
print()
|
||||
if not assessment["valid"]:
|
||||
print("Promotion record is INCOMPLETE - do not archive it as a promotion.")
|
||||
sys.exit(1)
|
||||
print("Promotion record is complete. Archive it on the tracking issue.")
|
||||
'
|
||||
@@ -0,0 +1,907 @@
|
||||
"""Self-propagating canonical handoffs through final controller closure (#626).
|
||||
|
||||
#494-#507 defined the canonical ledger, next-action comments, comment
|
||||
validation, the controller acceptance gate, and the Canonical Thread Handoff
|
||||
(CTH) shape. What none of them enforce is the *chain*: that every actor
|
||||
consumes exactly one canonical handoff, performs exactly one authorized role,
|
||||
records the result durably in Gitea, and emits the next complete handoff until
|
||||
the controller records final closure.
|
||||
|
||||
This module owns that systemic gap:
|
||||
|
||||
* one canonical cross-role handoff schema (:data:`HANDOFF_FIELDS`);
|
||||
* a fail-closed validator that rejects incomplete handoffs;
|
||||
* live-state recovery so a receiving actor never trusts an inherited handoff;
|
||||
* role-limited continuation;
|
||||
* mandatory durable posting into Gitea;
|
||||
* the ``merged-awaiting-controller`` boundary and controller accept/reject
|
||||
continuation;
|
||||
* workflow-failure escalation into separate durable issues, with duplicate
|
||||
handling;
|
||||
* terminal closure that must *not* emit an unnecessary next prompt.
|
||||
|
||||
Everything here is pure assessment: no network calls, no mutation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
MARKER = "<!-- sph:v1 -->"
|
||||
HANDOFF_HEADING = "Canonical Handoff"
|
||||
|
||||
#: Canonical workflow states a handoff may declare.
|
||||
WORKFLOW_STATES: tuple[str, ...] = (
|
||||
"needs-author",
|
||||
"needs-review",
|
||||
"approved-awaiting-merge",
|
||||
"merged-awaiting-controller",
|
||||
"blocked",
|
||||
"complete",
|
||||
)
|
||||
|
||||
TERMINAL_STATES = frozenset({"complete"})
|
||||
|
||||
#: The single role authorized to act on each workflow state.
|
||||
NEXT_ACTOR_BY_STATE: dict[str, str] = {
|
||||
"needs-author": "author",
|
||||
"needs-review": "reviewer",
|
||||
"approved-awaiting-merge": "merger",
|
||||
"merged-awaiting-controller": "controller",
|
||||
"blocked": "operator",
|
||||
"complete": "none",
|
||||
}
|
||||
|
||||
WORKFLOW_ROLES = frozenset(
|
||||
{"author", "reviewer", "merger", "controller", "operator", "reconciler"}
|
||||
)
|
||||
|
||||
#: What each receiving role is authorized to do when it consumes a handoff.
|
||||
ROLE_ALLOWED_ACTIONS: dict[str, tuple[str, ...]] = {
|
||||
"author": ("implement", "commit", "push", "create_pr", "comment"),
|
||||
"reviewer": ("review", "approve", "request_changes", "comment"),
|
||||
"merger": ("verify_approval_parity", "merge", "comment"),
|
||||
"controller": ("accept", "reject", "reopen", "close_issue", "comment"),
|
||||
"operator": ("repair_infrastructure", "comment"),
|
||||
"reconciler": ("close_superseded_pr", "cleanup_branch", "comment"),
|
||||
}
|
||||
|
||||
ROLE_FORBIDDEN_ACTIONS: dict[str, tuple[str, ...]] = {
|
||||
"author": ("approve", "request_changes", "merge", "close_issue"),
|
||||
"reviewer": ("merge", "commit", "push", "create_pr"),
|
||||
"merger": ("approve", "commit", "push", "create_pr"),
|
||||
"controller": ("approve", "merge", "commit", "push"),
|
||||
"operator": ("approve", "merge", "close_issue"),
|
||||
"reconciler": ("approve", "merge", "commit", "push", "create_pr"),
|
||||
}
|
||||
|
||||
#: Ordered canonical handoff fields. Every one of them is required; the
|
||||
#: fields in :data:`NONE_ALLOWED_FIELDS` may legitimately carry ``none``.
|
||||
HANDOFF_FIELDS: tuple[str, ...] = (
|
||||
"REPOSITORY",
|
||||
"ISSUE",
|
||||
"PR",
|
||||
"WORKFLOW_STATE",
|
||||
"HEAD_SHA",
|
||||
"BASE_BRANCH",
|
||||
"BASE_OR_MERGE_SHA",
|
||||
"ACTING_ROLE",
|
||||
"ACTING_IDENTITY",
|
||||
"COMPLETED_ACTIONS",
|
||||
"VALIDATION_EVIDENCE",
|
||||
"MUTATION_LEDGER",
|
||||
"BLOCKERS",
|
||||
"NEXT_ACTOR",
|
||||
"NEXT_ACTION",
|
||||
"PROHIBITED_ACTIONS",
|
||||
"NEXT_PROMPT",
|
||||
"WORKFLOW_FAILURE_ISSUES",
|
||||
"LAST_UPDATED",
|
||||
)
|
||||
|
||||
NONE_ALLOWED_FIELDS = frozenset(
|
||||
{
|
||||
"PR",
|
||||
"HEAD_SHA",
|
||||
"BASE_OR_MERGE_SHA",
|
||||
"BLOCKERS",
|
||||
"WORKFLOW_FAILURE_ISSUES",
|
||||
"NEXT_PROMPT",
|
||||
"NEXT_ACTION",
|
||||
}
|
||||
)
|
||||
|
||||
#: States where no PR or head SHA exists yet, so ``none`` is legitimate.
|
||||
_PRE_PR_STATES = frozenset({"needs-author", "blocked"})
|
||||
|
||||
_PLACEHOLDERS = frozenset({"", "none", "n/a", "na", "tbd", "todo", "unknown", "?"})
|
||||
|
||||
#: A next prompt short enough to be a stub cannot be "ready to run".
|
||||
MIN_NEXT_PROMPT_CHARS = 40
|
||||
|
||||
_FIELD_LINE_RE = re.compile(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", re.MULTILINE)
|
||||
_HEADING_RE = re.compile(r"^##\s*Canonical Handoff\s*$", re.IGNORECASE | re.MULTILINE)
|
||||
_EXTERNAL_CHAT_RE = re.compile(
|
||||
r"\b(?:previous chat|prior conversation|earlier conversation|see (?:the )?chat|"
|
||||
r"chat history|paste (?:this )?(?:from|into) chatgpt|ask the operator to paste)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
LIVE_DETECTION_KINDS: tuple[str, ...] = (
|
||||
"changed_pr_head",
|
||||
"stale_approval",
|
||||
"issue_closed",
|
||||
"issue_reopened",
|
||||
"pr_merged",
|
||||
"pr_closed_unmerged",
|
||||
"stale_lease",
|
||||
"foreign_lease",
|
||||
"missing_worktree",
|
||||
"dirty_worktree",
|
||||
"namespace_mismatch",
|
||||
"stale_runtime",
|
||||
"changed_base",
|
||||
"conflicting_canonical_comments",
|
||||
)
|
||||
|
||||
CONTROLLER_DECISIONS = frozenset(
|
||||
{
|
||||
"accept",
|
||||
"request_tests",
|
||||
"request_proof",
|
||||
"request_corrections",
|
||||
"reopen",
|
||||
"return_to_actor",
|
||||
}
|
||||
)
|
||||
|
||||
CONTROLLER_CLOSURE_PROOF_FIELDS = (
|
||||
"acceptance_criteria_satisfied",
|
||||
"cleanup_complete",
|
||||
"canonical_final_state_posted",
|
||||
"issue_closed_through_workflow",
|
||||
)
|
||||
|
||||
WORKFLOW_FAILURE_FIELDS = (
|
||||
"classification",
|
||||
"linked_issue",
|
||||
"temporary_impact",
|
||||
"next_valid_actor",
|
||||
"recovery_prompt",
|
||||
)
|
||||
|
||||
|
||||
def _is_placeholder(value: Any) -> bool:
|
||||
return str(value or "").strip().lower() in _PLACEHOLDERS
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
text = str(value).strip() if value is not None else ""
|
||||
return text or "none"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rendering / parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_self_propagating_handoff(**values: Any) -> str:
|
||||
"""Render a canonical cross-role handoff block.
|
||||
|
||||
Raises ``ValueError`` for an unknown workflow state so a malformed handoff
|
||||
can never be produced by the sanctioned renderer.
|
||||
"""
|
||||
state = str(values.get("WORKFLOW_STATE", values.get("workflow_state", ""))).strip()
|
||||
if state not in WORKFLOW_STATES:
|
||||
raise ValueError(
|
||||
f"unknown workflow state '{state}'; expected one of {list(WORKFLOW_STATES)}"
|
||||
)
|
||||
lines = [MARKER, f"## {HANDOFF_HEADING}", "", "```text"]
|
||||
for name in HANDOFF_FIELDS:
|
||||
raw = values.get(name, values.get(name.lower()))
|
||||
lines.append(f"{name}: {_clean(raw)}")
|
||||
lines.append("```")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_self_propagating_handoff(text: str) -> dict[str, str] | None:
|
||||
"""Parse a canonical handoff block, or ``None`` when absent."""
|
||||
body = text or ""
|
||||
if MARKER not in body and not _HEADING_RE.search(body):
|
||||
return None
|
||||
fields = {
|
||||
match.group(1): match.group(2).strip()
|
||||
for match in _FIELD_LINE_RE.finditer(body)
|
||||
}
|
||||
if not fields:
|
||||
return None
|
||||
return fields
|
||||
|
||||
|
||||
def handoff_present(text: str) -> bool:
|
||||
"""Whether *text* carries a canonical handoff block at all."""
|
||||
return parse_self_propagating_handoff(text) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handoff validation (AC: a validator rejects incomplete handoffs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_self_propagating_handoff(text: str) -> dict[str, Any]:
|
||||
"""Fail closed unless *text* carries one complete canonical handoff."""
|
||||
fields = parse_self_propagating_handoff(text)
|
||||
if fields is None:
|
||||
return {
|
||||
"valid": False,
|
||||
"block": True,
|
||||
"present": False,
|
||||
"fields": {},
|
||||
"missing_fields": list(HANDOFF_FIELDS),
|
||||
"workflow_state": None,
|
||||
"next_actor": None,
|
||||
"terminal": False,
|
||||
"reasons": ["report or comment carries no canonical handoff block"],
|
||||
"safe_next_action": (
|
||||
"add a canonical handoff block with all "
|
||||
f"{len(HANDOFF_FIELDS)} fields before posting"
|
||||
),
|
||||
}
|
||||
|
||||
reasons: list[str] = []
|
||||
state = (fields.get("WORKFLOW_STATE") or "").strip()
|
||||
terminal = state in TERMINAL_STATES
|
||||
|
||||
if state not in WORKFLOW_STATES:
|
||||
reasons.append(
|
||||
f"unknown WORKFLOW_STATE '{state or 'missing'}'; "
|
||||
f"expected one of {list(WORKFLOW_STATES)}"
|
||||
)
|
||||
|
||||
missing = [name for name in HANDOFF_FIELDS if name not in fields]
|
||||
reasons.extend(f"handoff missing field: {name}" for name in missing)
|
||||
|
||||
for name in HANDOFF_FIELDS:
|
||||
if name in missing:
|
||||
continue
|
||||
value = fields.get(name, "")
|
||||
if not _is_placeholder(value):
|
||||
continue
|
||||
if name in NONE_ALLOWED_FIELDS:
|
||||
continue
|
||||
# A terminated chain names no next actor by design.
|
||||
if name == "NEXT_ACTOR" and terminal:
|
||||
continue
|
||||
reasons.append(f"handoff field {name} must be concrete, got '{value or ''}'")
|
||||
|
||||
if state and state not in _PRE_PR_STATES and state in WORKFLOW_STATES:
|
||||
for name in ("PR", "HEAD_SHA"):
|
||||
if name not in missing and _is_placeholder(fields.get(name)):
|
||||
reasons.append(
|
||||
f"handoff field {name} must be concrete in state '{state}'"
|
||||
)
|
||||
|
||||
if state == "blocked" and _is_placeholder(fields.get("BLOCKERS")):
|
||||
reasons.append("state 'blocked' requires a concrete BLOCKERS entry")
|
||||
|
||||
declared_actor = (fields.get("NEXT_ACTOR") or "").strip().lower()
|
||||
expected_actor = NEXT_ACTOR_BY_STATE.get(state)
|
||||
if expected_actor and declared_actor != expected_actor:
|
||||
reasons.append(
|
||||
f"NEXT_ACTOR '{declared_actor or 'missing'}' does not match state "
|
||||
f"'{state}', which authorizes '{expected_actor}'"
|
||||
)
|
||||
|
||||
next_prompt = (fields.get("NEXT_PROMPT") or "").strip()
|
||||
next_action = (fields.get("NEXT_ACTION") or "").strip()
|
||||
if terminal:
|
||||
# A completed workflow terminates; it must not manufacture more work.
|
||||
if not _is_placeholder(next_prompt):
|
||||
reasons.append(
|
||||
"terminal state 'complete' must not carry a NEXT_PROMPT; "
|
||||
"the chain ends at controller closure"
|
||||
)
|
||||
if not _is_placeholder(next_action):
|
||||
reasons.append(
|
||||
"terminal state 'complete' must not carry a NEXT_ACTION"
|
||||
)
|
||||
else:
|
||||
if _is_placeholder(next_prompt):
|
||||
reasons.append(
|
||||
"non-terminal handoff requires a complete ready-to-run NEXT_PROMPT"
|
||||
)
|
||||
elif len(next_prompt) < MIN_NEXT_PROMPT_CHARS:
|
||||
reasons.append(
|
||||
"NEXT_PROMPT is too short to be ready-to-run "
|
||||
f"({len(next_prompt)} < {MIN_NEXT_PROMPT_CHARS} characters)"
|
||||
)
|
||||
if _is_placeholder(next_action):
|
||||
reasons.append("non-terminal handoff requires a concrete NEXT_ACTION")
|
||||
|
||||
acting_role = (fields.get("ACTING_ROLE") or "").strip().lower()
|
||||
if acting_role and acting_role not in WORKFLOW_ROLES:
|
||||
reasons.append(
|
||||
f"unknown ACTING_ROLE '{acting_role}'; expected one of "
|
||||
f"{sorted(WORKFLOW_ROLES)}"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"valid": not block,
|
||||
"block": block,
|
||||
"present": True,
|
||||
"fields": fields,
|
||||
"missing_fields": missing,
|
||||
"workflow_state": state or None,
|
||||
"next_actor": declared_actor or None,
|
||||
"terminal": terminal,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"complete every canonical handoff field before posting"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_thread_recoverability(text: str) -> dict[str, Any]:
|
||||
"""The next actor must recover from the thread alone — never outside chat."""
|
||||
assessment = assess_self_propagating_handoff(text)
|
||||
if assessment["block"]:
|
||||
return {
|
||||
"recoverable": False,
|
||||
"block": True,
|
||||
"reasons": assessment["reasons"],
|
||||
"safe_next_action": assessment["safe_next_action"],
|
||||
}
|
||||
|
||||
fields = assessment["fields"]
|
||||
reasons: list[str] = []
|
||||
if assessment["terminal"]:
|
||||
return {
|
||||
"recoverable": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
prompt = fields.get("NEXT_PROMPT", "")
|
||||
repository = fields.get("REPOSITORY", "").strip()
|
||||
issue = fields.get("ISSUE", "").strip().lstrip("#")
|
||||
|
||||
if repository and repository.lower() not in prompt.lower():
|
||||
reasons.append("NEXT_PROMPT must name the repository it applies to")
|
||||
if issue and issue not in prompt:
|
||||
reasons.append(f"NEXT_PROMPT must name issue {issue}")
|
||||
if _EXTERNAL_CHAT_RE.search(prompt):
|
||||
reasons.append(
|
||||
"NEXT_PROMPT must not depend on outside chat history; the issue or "
|
||||
"PR thread, workflow docs, and live repository state must suffice"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"recoverable": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"rewrite NEXT_PROMPT so it is self-contained" if block else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# live-state recovery (AC: head changes invalidate stale review/merge handoffs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detection(kind: str, detail: str) -> dict[str, str]:
|
||||
return {"kind": kind, "detail": detail}
|
||||
|
||||
|
||||
def assess_handoff_live_state(
|
||||
*,
|
||||
handoff: str | Mapping[str, str],
|
||||
live: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Re-derive workflow truth from live state instead of trusting *handoff*.
|
||||
|
||||
*live* carries observed facts; absent keys are simply not checked, but any
|
||||
fact that contradicts the inherited handoff fails closed.
|
||||
"""
|
||||
if isinstance(handoff, Mapping):
|
||||
fields = dict(handoff)
|
||||
else:
|
||||
parsed = parse_self_propagating_handoff(handoff or "")
|
||||
if parsed is None:
|
||||
return {
|
||||
"block": True,
|
||||
"detections": [_detection("missing_handoff", "no canonical handoff")],
|
||||
"kinds": ["missing_handoff"],
|
||||
"reasons": ["no canonical handoff to reconcile against live state"],
|
||||
"recovered_state": None,
|
||||
"safe_next_action": "post a canonical handoff before continuing",
|
||||
}
|
||||
fields = parsed
|
||||
|
||||
state = (fields.get("WORKFLOW_STATE") or "").strip()
|
||||
next_actor = (fields.get("NEXT_ACTOR") or "").strip().lower()
|
||||
detections: list[dict[str, str]] = []
|
||||
recovered_state: str | None = None
|
||||
|
||||
handoff_head = (fields.get("HEAD_SHA") or "").strip()
|
||||
live_head = str(live.get("pr_head_sha") or "").strip()
|
||||
head_changed = bool(
|
||||
live_head and handoff_head and not _is_placeholder(handoff_head)
|
||||
and live_head != handoff_head
|
||||
)
|
||||
if head_changed:
|
||||
detections.append(
|
||||
_detection(
|
||||
"changed_pr_head",
|
||||
f"handoff pinned {handoff_head}, live head is {live_head}",
|
||||
)
|
||||
)
|
||||
if next_actor in {"reviewer", "merger"}:
|
||||
recovered_state = "needs-review"
|
||||
|
||||
approved_head = str(live.get("approved_head_sha") or "").strip()
|
||||
if approved_head and live_head and approved_head != live_head:
|
||||
detections.append(
|
||||
_detection(
|
||||
"stale_approval",
|
||||
f"approval recorded at {approved_head}, live head is {live_head}",
|
||||
)
|
||||
)
|
||||
if next_actor == "merger":
|
||||
recovered_state = "needs-review"
|
||||
|
||||
issue_state = str(live.get("issue_state") or "").strip().lower()
|
||||
if issue_state == "closed" and state not in TERMINAL_STATES:
|
||||
detections.append(
|
||||
_detection("issue_closed", "linked issue is closed but handoff is not complete")
|
||||
)
|
||||
if issue_state == "open" and state in TERMINAL_STATES:
|
||||
detections.append(
|
||||
_detection("issue_reopened", "handoff claims complete but the issue is open")
|
||||
)
|
||||
recovered_state = "needs-author"
|
||||
|
||||
pr_state = str(live.get("pr_state") or "").strip().lower()
|
||||
if pr_state == "merged" and state in {
|
||||
"needs-author",
|
||||
"needs-review",
|
||||
"approved-awaiting-merge",
|
||||
}:
|
||||
detections.append(
|
||||
_detection("pr_merged", "PR is already merged; controller boundary applies")
|
||||
)
|
||||
recovered_state = "merged-awaiting-controller"
|
||||
if pr_state == "closed" and state not in TERMINAL_STATES:
|
||||
detections.append(
|
||||
_detection("pr_closed_unmerged", "PR is closed without merge")
|
||||
)
|
||||
|
||||
lease = live.get("lease") or {}
|
||||
if isinstance(lease, Mapping) and lease:
|
||||
lease_status = str(lease.get("status") or "").strip().lower()
|
||||
if lease_status and lease_status != "active":
|
||||
detections.append(
|
||||
_detection("stale_lease", f"lease status is '{lease_status}'")
|
||||
)
|
||||
lease_session = str(lease.get("session_id") or "").strip()
|
||||
actor_session = str(live.get("actor_session_id") or "").strip()
|
||||
if lease_session and actor_session and lease_session != actor_session:
|
||||
detections.append(
|
||||
_detection(
|
||||
"foreign_lease",
|
||||
"lease is owned by another session; never adopt it implicitly",
|
||||
)
|
||||
)
|
||||
|
||||
worktree = live.get("worktree") or {}
|
||||
if isinstance(worktree, Mapping) and worktree:
|
||||
if worktree.get("present") is False:
|
||||
detections.append(_detection("missing_worktree", "bound worktree is absent"))
|
||||
if worktree.get("dirty") is True:
|
||||
detections.append(
|
||||
_detection("dirty_worktree", "bound worktree carries uncommitted changes")
|
||||
)
|
||||
|
||||
namespace_role = str(live.get("namespace_role") or "").strip().lower()
|
||||
if namespace_role and next_actor and next_actor != "none":
|
||||
if namespace_role != next_actor:
|
||||
detections.append(
|
||||
_detection(
|
||||
"namespace_mismatch",
|
||||
f"live namespace role '{namespace_role}' cannot act as '{next_actor}'",
|
||||
)
|
||||
)
|
||||
|
||||
if live.get("runtime_stale") is True:
|
||||
detections.append(
|
||||
_detection("stale_runtime", "serving runtime is stale; reconnect required")
|
||||
)
|
||||
|
||||
handoff_base = (fields.get("BASE_BRANCH") or "").strip()
|
||||
live_base = str(live.get("base_branch") or "").strip()
|
||||
if handoff_base and live_base and not _is_placeholder(handoff_base):
|
||||
if handoff_base != live_base:
|
||||
detections.append(
|
||||
_detection(
|
||||
"changed_base",
|
||||
f"handoff base '{handoff_base}' but live base '{live_base}'",
|
||||
)
|
||||
)
|
||||
|
||||
if live.get("conflicting_canonical_comments") is True:
|
||||
detections.append(
|
||||
_detection(
|
||||
"conflicting_canonical_comments",
|
||||
"thread carries contradictory canonical comments",
|
||||
)
|
||||
)
|
||||
|
||||
kinds = [item["kind"] for item in detections]
|
||||
reasons = [f"{item['kind']}: {item['detail']}" for item in detections]
|
||||
block = bool(detections)
|
||||
return {
|
||||
"block": block,
|
||||
"detections": detections,
|
||||
"kinds": kinds,
|
||||
"reasons": reasons,
|
||||
"recovered_state": recovered_state,
|
||||
"safe_next_action": (
|
||||
"post a corrected canonical handoff for the recovered live state "
|
||||
"before acting"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# role-limited continuation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_role_continuation(
|
||||
*,
|
||||
handoff: str | Mapping[str, str],
|
||||
actor_role: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Only the role the current state authorizes may continue the chain."""
|
||||
if isinstance(handoff, Mapping):
|
||||
fields = dict(handoff)
|
||||
else:
|
||||
fields = parse_self_propagating_handoff(handoff or "") or {}
|
||||
|
||||
role = (actor_role or "").strip().lower()
|
||||
state = (fields.get("WORKFLOW_STATE") or "").strip()
|
||||
expected = NEXT_ACTOR_BY_STATE.get(state)
|
||||
reasons: list[str] = []
|
||||
|
||||
if not fields:
|
||||
reasons.append("no canonical handoff to continue from")
|
||||
if role not in WORKFLOW_ROLES:
|
||||
reasons.append(f"unknown actor role '{actor_role}'")
|
||||
if expected is None and fields:
|
||||
reasons.append(f"unknown workflow state '{state}'")
|
||||
elif expected == "none":
|
||||
reasons.append(
|
||||
"workflow state 'complete' is terminal; no further role may continue"
|
||||
)
|
||||
elif expected and role != expected:
|
||||
reasons.append(
|
||||
f"state '{state}' authorizes '{expected}', not '{role}'"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"allowed": not block,
|
||||
"block": block,
|
||||
"expected_actor": expected,
|
||||
"actor_role": role,
|
||||
"allowed_actions": () if block else ROLE_ALLOWED_ACTIONS.get(role, ()),
|
||||
"forbidden_actions": ROLE_FORBIDDEN_ACTIONS.get(role, ()),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
f"hand off to '{expected}'" if block and expected else
|
||||
"stop; the workflow is complete" if expected == "none" else
|
||||
"proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# durable posting (AC: a chat-only report is never sufficient)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_durable_state_update(
|
||||
*,
|
||||
handoff_text: str,
|
||||
posted_comment_id: Any = None,
|
||||
canonical_state_posted: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""A successful actor session must leave the handoff in Gitea, not chat."""
|
||||
reasons: list[str] = []
|
||||
assessment = assess_self_propagating_handoff(handoff_text)
|
||||
if assessment["block"]:
|
||||
reasons.extend(assessment["reasons"])
|
||||
if not posted_comment_id:
|
||||
reasons.append(
|
||||
"canonical handoff was not posted to Gitea; a chat-only report is "
|
||||
"not durable workflow state"
|
||||
)
|
||||
if not canonical_state_posted:
|
||||
reasons.append(
|
||||
"canonical issue/PR state and thread ledger were not updated"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"durable": not block,
|
||||
"block": block,
|
||||
"posted_comment_id": posted_comment_id,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"post the canonical handoff and state update to Gitea before "
|
||||
"ending the session"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge -> controller boundary and controller continuation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_merge_completion_transition(
|
||||
*,
|
||||
merge_succeeded: bool,
|
||||
controller_auto_accept: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""A merged PR is not accepted work until the controller says so."""
|
||||
if not merge_succeeded:
|
||||
return {
|
||||
"next_state": "approved-awaiting-merge",
|
||||
"next_actor": "merger",
|
||||
"next_prompt_required": True,
|
||||
"reasons": ["merge did not succeed; the merger retains the work item"],
|
||||
}
|
||||
if controller_auto_accept:
|
||||
return {
|
||||
"next_state": "complete",
|
||||
"next_actor": "none",
|
||||
"next_prompt_required": False,
|
||||
"reasons": ["configured workflow authorizes automatic acceptance on merge"],
|
||||
}
|
||||
return {
|
||||
"next_state": "merged-awaiting-controller",
|
||||
"next_actor": "controller",
|
||||
"next_prompt_required": True,
|
||||
"reasons": [
|
||||
"merge succeeded; acceptance requires the authorized controller"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def assess_controller_decision(
|
||||
*,
|
||||
decision: str,
|
||||
closure_proof: Mapping[str, Any] | None = None,
|
||||
return_to: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Controller acceptance or rejection produces the next or final state."""
|
||||
normalized = (decision or "").strip().lower()
|
||||
if normalized not in CONTROLLER_DECISIONS:
|
||||
return {
|
||||
"block": True,
|
||||
"next_state": None,
|
||||
"next_actor": None,
|
||||
"next_prompt_required": True,
|
||||
"reasons": [
|
||||
f"unknown controller decision '{decision}'; expected one of "
|
||||
f"{sorted(CONTROLLER_DECISIONS)}"
|
||||
],
|
||||
"safe_next_action": "record a supported controller decision",
|
||||
}
|
||||
|
||||
if normalized == "accept":
|
||||
proof = dict(closure_proof or {})
|
||||
missing = [
|
||||
name
|
||||
for name in CONTROLLER_CLOSURE_PROOF_FIELDS
|
||||
if proof.get(name) is not True
|
||||
]
|
||||
if missing:
|
||||
return {
|
||||
"block": True,
|
||||
"next_state": "merged-awaiting-controller",
|
||||
"next_actor": "controller",
|
||||
"next_prompt_required": True,
|
||||
"reasons": [
|
||||
"controller acceptance missing closure proof: " + ", ".join(missing)
|
||||
],
|
||||
"safe_next_action": (
|
||||
"satisfy and record every closure proof field before closing"
|
||||
),
|
||||
}
|
||||
return {
|
||||
"block": False,
|
||||
"next_state": "complete",
|
||||
"next_actor": "none",
|
||||
"next_prompt_required": False,
|
||||
"reasons": ["controller accepted; workflow chain terminates"],
|
||||
"safe_next_action": "post the final canonical state and stop",
|
||||
}
|
||||
|
||||
if normalized == "return_to_actor":
|
||||
target = (return_to or "").strip().lower()
|
||||
state_by_actor = {
|
||||
"author": "needs-author",
|
||||
"reviewer": "needs-review",
|
||||
"merger": "approved-awaiting-merge",
|
||||
}
|
||||
if target not in state_by_actor:
|
||||
return {
|
||||
"block": True,
|
||||
"next_state": None,
|
||||
"next_actor": None,
|
||||
"next_prompt_required": True,
|
||||
"reasons": [
|
||||
f"return_to_actor requires a target in {sorted(state_by_actor)}"
|
||||
],
|
||||
"safe_next_action": "name the actor the work returns to",
|
||||
}
|
||||
return {
|
||||
"block": False,
|
||||
"next_state": state_by_actor[target],
|
||||
"next_actor": target,
|
||||
"next_prompt_required": True,
|
||||
"reasons": [f"controller returned the work item to '{target}'"],
|
||||
"safe_next_action": f"post a complete handoff for '{target}'",
|
||||
}
|
||||
|
||||
# request_tests / request_proof / request_corrections / reopen
|
||||
return {
|
||||
"block": False,
|
||||
"next_state": "needs-author",
|
||||
"next_actor": "author",
|
||||
"next_prompt_required": True,
|
||||
"reasons": [f"controller decision '{normalized}' returns the work to the author"],
|
||||
"safe_next_action": "post a complete author handoff describing what is required",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# workflow-failure escalation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_workflow_failure_escalation(
|
||||
*,
|
||||
failures: Sequence[Mapping[str, Any]] | None,
|
||||
active_issue_number: int | str | None,
|
||||
existing_failure_issues: Iterable[Mapping[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Tooling defects hit while working an issue become separate durable work."""
|
||||
entries = list(failures or [])
|
||||
known = {
|
||||
str((item.get("signature") or "")).strip().lower(): item.get("number")
|
||||
for item in (existing_failure_issues or [])
|
||||
if str((item.get("signature") or "")).strip()
|
||||
}
|
||||
active = str(active_issue_number or "").strip().lstrip("#")
|
||||
|
||||
reasons: list[str] = []
|
||||
reused: list[dict[str, Any]] = []
|
||||
seen_signatures: dict[str, str] = {}
|
||||
|
||||
for index, failure in enumerate(entries):
|
||||
label = str(failure.get("signature") or f"failure[{index}]")
|
||||
missing = [
|
||||
name
|
||||
for name in WORKFLOW_FAILURE_FIELDS
|
||||
if _is_placeholder(failure.get(name))
|
||||
]
|
||||
if missing:
|
||||
reasons.append(
|
||||
f"{label}: workflow failure missing " + ", ".join(missing)
|
||||
)
|
||||
|
||||
linked = str(failure.get("linked_issue") or "").strip().lstrip("#")
|
||||
if linked and active and linked == active:
|
||||
reasons.append(
|
||||
f"{label}: workflow defects must not be folded into the active "
|
||||
f"work item #{active}; file a separate durable issue"
|
||||
)
|
||||
|
||||
signature = str(failure.get("signature") or "").strip().lower()
|
||||
if not signature:
|
||||
continue
|
||||
if signature in known:
|
||||
expected = str(known[signature] or "").strip().lstrip("#")
|
||||
if linked and expected and linked != expected:
|
||||
reasons.append(
|
||||
f"{label}: duplicate workflow-failure issue #{linked}; "
|
||||
f"reuse the existing issue #{expected}"
|
||||
)
|
||||
else:
|
||||
reused.append({"signature": signature, "issue": expected})
|
||||
if signature in seen_signatures:
|
||||
reasons.append(
|
||||
f"{label}: duplicate workflow-failure signature reported twice "
|
||||
"in one session"
|
||||
)
|
||||
else:
|
||||
seen_signatures[signature] = linked
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"escalated": not block,
|
||||
"block": block,
|
||||
"failure_count": len(entries),
|
||||
"reused_issues": reused,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"file or reference one durable issue per distinct workflow failure"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# final-report integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_final_report_self_propagating_handoff(report_text: str) -> dict[str, Any]:
|
||||
"""#626 gate for final reports.
|
||||
|
||||
Applicability mirrors the #495 canonical-state gate: once a report adopts
|
||||
the protocol — by carrying the marker, the ``Canonical Handoff`` heading,
|
||||
or a ``WORKFLOW_STATE`` line — the full schema is enforced. Reports that
|
||||
predate the protocol are untouched here; the workflow schemas require the
|
||||
block going forward.
|
||||
"""
|
||||
text = report_text or ""
|
||||
applicable = (
|
||||
MARKER in text
|
||||
or bool(_HEADING_RE.search(text))
|
||||
or bool(re.search(r"^WORKFLOW_STATE\s*:", text, re.MULTILINE))
|
||||
)
|
||||
if not applicable:
|
||||
return {
|
||||
"applicable": False,
|
||||
"valid": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
assessment = assess_self_propagating_handoff(text)
|
||||
recoverability = assess_thread_recoverability(text)
|
||||
reasons = list(assessment["reasons"])
|
||||
if not assessment["block"]:
|
||||
reasons.extend(recoverability.get("reasons") or [])
|
||||
block = bool(assessment["block"] or recoverability.get("block"))
|
||||
return {
|
||||
"applicable": True,
|
||||
"valid": not block,
|
||||
"block": block,
|
||||
"workflow_state": assessment.get("workflow_state"),
|
||||
"next_actor": assessment.get("next_actor"),
|
||||
"terminal": assessment.get("terminal"),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
assessment["safe_next_action"]
|
||||
if assessment["block"]
|
||||
else recoverability.get("safe_next_action", "proceed")
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
"""Sentry → Gitea incident bridge (#607).
|
||||
|
||||
Reads unresolved issues/events from a **self-hosted** Sentry, normalizes them
|
||||
into #612 observations, and reconciles them into durable Gitea issues.
|
||||
|
||||
Hard rules (inherited from #612 and restated here):
|
||||
* Gitea owns workflow state; Sentry is observability **input only**.
|
||||
* Raw Sentry incidents are never assignable control-plane ``work_items``.
|
||||
* Dedupe/link/create is delegated to :mod:`incident_bridge` — this module
|
||||
never invents a second linking substrate.
|
||||
* Tokens, DSNs, and raw headers never appear in returns, bodies, or logs.
|
||||
* The watchdog defaults to dry-run; ``apply`` is explicit.
|
||||
|
||||
Network access is injected as ``http_fn`` so the whole surface is testable
|
||||
without a live Sentry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
import incident_bridge
|
||||
import sentry_observability
|
||||
|
||||
PROVIDER = "sentry"
|
||||
|
||||
ENV_BASE_URL = "SENTRY_BASE_URL"
|
||||
ENV_AUTH_TOKEN = "SENTRY_AUTH_TOKEN"
|
||||
ENV_ORG = "SENTRY_ORG"
|
||||
ENV_PROJECT = "SENTRY_PROJECT"
|
||||
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
|
||||
ENV_BRIDGE_ENABLED = "MCP_SENTRY_ISSUE_BRIDGE_ENABLED"
|
||||
ENV_MIN_EVENTS = "MCP_SENTRY_MIN_EVENTS_FOR_ISSUE"
|
||||
ENV_LOOKBACK = "MCP_SENTRY_LOOKBACK"
|
||||
|
||||
DEFAULT_BASE_URL = "https://sentry.prgs.cc"
|
||||
DEFAULT_LOOKBACK = "24h"
|
||||
DEFAULT_MIN_EVENTS = 2
|
||||
DEFAULT_TIMEOUT = 15.0
|
||||
DEFAULT_PAGE_SIZE = 25
|
||||
MAX_PAGE_SIZE = 100
|
||||
DEFAULT_MAX_PAGES = 10
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
# Absolute local paths embedded in free text. Mirrors the shape matched by
|
||||
# sentry_observability's internal path detector; each hit is replaced by the
|
||||
# coarse category from sentry_observability.sanitize_path.
|
||||
_ABS_PATH_RE = re.compile(
|
||||
r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*"
|
||||
)
|
||||
_LOOKBACK_RE = re.compile(r"^\d+[mhd]$")
|
||||
_CURSOR_RE = re.compile(r'cursor="([^"]+)"')
|
||||
_RESULTS_RE = re.compile(r'results="([^"]+)"')
|
||||
_REL_RE = re.compile(r'rel="([^"]+)"')
|
||||
|
||||
# Error kinds surfaced to callers (stable strings; safe to branch on).
|
||||
ERROR_NOT_CONFIGURED = "not_configured"
|
||||
ERROR_MISSING_TOKEN = "missing_token"
|
||||
ERROR_UNAVAILABLE = "sentry_unavailable"
|
||||
ERROR_HTTP = "sentry_http_error"
|
||||
ERROR_INVALID_RESPONSE = "invalid_response"
|
||||
ERROR_BRIDGE_DISABLED = "bridge_disabled"
|
||||
|
||||
# Watchdog per-issue dispositions.
|
||||
ACTION_RECONCILED = "reconciled"
|
||||
ACTION_SKIPPED_THRESHOLD = "skipped_below_event_threshold"
|
||||
ACTION_SKIPPED_STATUS = "skipped_not_unresolved"
|
||||
ACTION_FAILED = "failed"
|
||||
|
||||
|
||||
class SentryApiError(RuntimeError):
|
||||
"""Sentry read failure with a stable, redacted classification."""
|
||||
|
||||
def __init__(self, message: str, *, kind: str, status: int | None = None):
|
||||
super().__init__(incident_bridge.redact_text(message))
|
||||
self.kind = kind
|
||||
self.status = status
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"error_kind": self.kind,
|
||||
"status": self.status,
|
||||
"message": str(self),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SentryBridgeConfig:
|
||||
"""Resolved bridge configuration. Never carries the auth token."""
|
||||
|
||||
base_url: str
|
||||
org: str
|
||||
project: str
|
||||
environment: str | None = None
|
||||
lookback: str = DEFAULT_LOOKBACK
|
||||
min_events_for_issue: int = DEFAULT_MIN_EVENTS
|
||||
bridge_enabled: bool = False
|
||||
timeout: float = DEFAULT_TIMEOUT
|
||||
|
||||
def issues_path(self) -> str:
|
||||
return f"/api/0/projects/{self.org}/{self.project}/issues/"
|
||||
|
||||
def issue_events_path(self, issue_id: str) -> str:
|
||||
return f"/api/0/issues/{issue_id}/events/"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Safe projection. The auth token is never included by construction."""
|
||||
return {
|
||||
"base_url": self.base_url,
|
||||
"org": self.org,
|
||||
"project": self.project,
|
||||
"environment": self.environment,
|
||||
"lookback": self.lookback,
|
||||
"min_events_for_issue": self.min_events_for_issue,
|
||||
"bridge_enabled": self.bridge_enabled,
|
||||
"self_hosted": not self.base_url.rstrip("/").endswith("sentry.io"),
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, env: dict[str, str], default: bool = False) -> bool:
|
||||
raw = (env.get(name) or "").strip().lower()
|
||||
if not raw:
|
||||
return default
|
||||
return raw in _TRUTHY
|
||||
|
||||
|
||||
def _env_int(name: str, env: dict[str, str], default: int) -> int:
|
||||
raw = (env.get(name) or "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
return value if value >= 1 else default
|
||||
|
||||
|
||||
def load_bridge_config(env: dict[str, str] | None = None) -> SentryBridgeConfig:
|
||||
"""Build config from environment. Never reads or returns the token value."""
|
||||
source = dict(env if env is not None else os.environ)
|
||||
base_url = (source.get(ENV_BASE_URL) or DEFAULT_BASE_URL).strip().rstrip("/")
|
||||
lookback = (source.get(ENV_LOOKBACK) or DEFAULT_LOOKBACK).strip()
|
||||
if not _LOOKBACK_RE.match(lookback):
|
||||
lookback = DEFAULT_LOOKBACK
|
||||
environment = (source.get(ENV_ENVIRONMENT) or "").strip() or None
|
||||
return SentryBridgeConfig(
|
||||
base_url=base_url,
|
||||
org=(source.get(ENV_ORG) or "").strip(),
|
||||
project=(source.get(ENV_PROJECT) or "").strip(),
|
||||
environment=environment,
|
||||
lookback=lookback,
|
||||
min_events_for_issue=_env_int(ENV_MIN_EVENTS, source, DEFAULT_MIN_EVENTS),
|
||||
bridge_enabled=_env_bool(ENV_BRIDGE_ENABLED, source, False),
|
||||
)
|
||||
|
||||
|
||||
def config_with_overrides(
|
||||
config: SentryBridgeConfig,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
org: str | None = None,
|
||||
project: str | None = None,
|
||||
lookback: str | None = None,
|
||||
min_events_for_issue: int | None = None,
|
||||
) -> SentryBridgeConfig:
|
||||
"""Return *config* with explicit per-call overrides applied."""
|
||||
overrides: dict[str, Any] = {}
|
||||
if base_url:
|
||||
overrides["base_url"] = str(base_url).strip().rstrip("/")
|
||||
if org:
|
||||
overrides["org"] = str(org).strip()
|
||||
if project:
|
||||
overrides["project"] = str(project).strip()
|
||||
if lookback:
|
||||
candidate = str(lookback).strip()
|
||||
overrides["lookback"] = candidate if _LOOKBACK_RE.match(candidate) else config.lookback
|
||||
if min_events_for_issue is not None:
|
||||
overrides["min_events_for_issue"] = max(1, int(min_events_for_issue))
|
||||
return dataclasses.replace(config, **overrides) if overrides else config
|
||||
|
||||
|
||||
def resolve_token(env: dict[str, str] | None = None) -> str:
|
||||
"""Return the Sentry auth token from env only (never logged or returned)."""
|
||||
source = env if env is not None else os.environ
|
||||
return (source.get(ENV_AUTH_TOKEN) or "").strip()
|
||||
|
||||
|
||||
def assert_configured(config: SentryBridgeConfig, token: str) -> None:
|
||||
"""Fail closed before any network call."""
|
||||
missing = [
|
||||
name
|
||||
for name, value in (
|
||||
(ENV_BASE_URL, config.base_url),
|
||||
(ENV_ORG, config.org),
|
||||
(ENV_PROJECT, config.project),
|
||||
)
|
||||
if not value
|
||||
]
|
||||
if missing:
|
||||
raise SentryApiError(
|
||||
"Sentry bridge is not configured; missing " + ", ".join(sorted(missing)),
|
||||
kind=ERROR_NOT_CONFIGURED,
|
||||
)
|
||||
if not token:
|
||||
raise SentryApiError(
|
||||
f"{ENV_AUTH_TOKEN} is not set; refusing to call Sentry (fail closed)",
|
||||
kind=ERROR_MISSING_TOKEN,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP layer (injectable)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# http_fn(url, headers, timeout) -> (status, body_bytes, response_headers)
|
||||
HttpFn = Callable[[str, dict[str, str], float], "tuple[int, bytes, dict[str, str]]"]
|
||||
|
||||
|
||||
def _default_http_fn(
|
||||
url: str, headers: dict[str, str], timeout: float
|
||||
) -> tuple[int, bytes, dict[str, str]]:
|
||||
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return (
|
||||
int(response.status),
|
||||
response.read(),
|
||||
{k.lower(): v for k, v in response.headers.items()},
|
||||
)
|
||||
except urllib.error.HTTPError as exc: # status is meaningful
|
||||
try:
|
||||
body = exc.read()
|
||||
except Exception: # noqa: BLE001 - body is best-effort only
|
||||
body = b""
|
||||
return (
|
||||
int(exc.code),
|
||||
body,
|
||||
{k.lower(): v for k, v in (exc.headers or {}).items()},
|
||||
)
|
||||
except urllib.error.URLError as exc:
|
||||
raise SentryApiError(
|
||||
f"Sentry unreachable: {exc.reason}", kind=ERROR_UNAVAILABLE
|
||||
) from exc
|
||||
except TimeoutError as exc:
|
||||
raise SentryApiError("Sentry request timed out", kind=ERROR_UNAVAILABLE) from exc
|
||||
|
||||
|
||||
def parse_next_cursor(link_header: str | None) -> str | None:
|
||||
"""Extract the ``rel="next"`` cursor when more results exist."""
|
||||
if not link_header:
|
||||
return None
|
||||
for part in link_header.split(","):
|
||||
rel = _REL_RE.search(part)
|
||||
if not rel or rel.group(1) != "next":
|
||||
continue
|
||||
results = _RESULTS_RE.search(part)
|
||||
if results and results.group(1).lower() != "true":
|
||||
return None
|
||||
cursor = _CURSOR_RE.search(part)
|
||||
if cursor:
|
||||
return cursor.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _get_json(
|
||||
config: SentryBridgeConfig,
|
||||
path: str,
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
token: str,
|
||||
http_fn: HttpFn | None = None,
|
||||
) -> tuple[Any, dict[str, str]]:
|
||||
caller = http_fn or _default_http_fn
|
||||
query = urllib.parse.urlencode(
|
||||
{k: v for k, v in params.items() if v not in (None, "")}
|
||||
)
|
||||
url = f"{config.base_url}{path}"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "gitea-tools-sentry-bridge/1.0",
|
||||
}
|
||||
status, body, response_headers = caller(url, headers, config.timeout)
|
||||
if status in (401, 403):
|
||||
raise SentryApiError(
|
||||
"Sentry rejected the auth token (unauthorized)",
|
||||
kind=ERROR_MISSING_TOKEN,
|
||||
status=status,
|
||||
)
|
||||
if status >= 500:
|
||||
raise SentryApiError(
|
||||
f"Sentry server error (HTTP {status})",
|
||||
kind=ERROR_UNAVAILABLE,
|
||||
status=status,
|
||||
)
|
||||
if status >= 400:
|
||||
raise SentryApiError(
|
||||
f"Sentry request failed (HTTP {status})", kind=ERROR_HTTP, status=status
|
||||
)
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8") or "null")
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SentryApiError(
|
||||
f"Sentry returned an unparseable response: {exc}",
|
||||
kind=ERROR_INVALID_RESPONSE,
|
||||
status=status,
|
||||
) from exc
|
||||
return payload, response_headers
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sanitization
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
"""Redact secrets, then replace embedded local paths with a category token.
|
||||
|
||||
``sentry_observability.sanitize_path`` categorizes a string that *is* a
|
||||
path; it must never be applied to whole free-text fields (it would collapse
|
||||
a title or timestamp to ``"other"``). Here it is applied only to substrings
|
||||
that actually match an absolute path.
|
||||
"""
|
||||
text = incident_bridge.redact_text(value)
|
||||
if not text:
|
||||
return ""
|
||||
return _ABS_PATH_RE.sub(
|
||||
lambda m: f"[path:{sentry_observability.sanitize_path(m.group(0))}]", text
|
||||
)
|
||||
|
||||
|
||||
def sanitize_issue(raw: Any) -> dict[str, Any]:
|
||||
"""Project one raw Sentry issue into a sanitized, LLM-safe summary."""
|
||||
if not isinstance(raw, dict):
|
||||
raise SentryApiError(
|
||||
"Sentry issue payload is not an object", kind=ERROR_INVALID_RESPONSE
|
||||
)
|
||||
issue_id = raw.get("id")
|
||||
if issue_id is None or str(issue_id).strip() == "":
|
||||
raise SentryApiError(
|
||||
"Sentry issue payload is missing 'id'", kind=ERROR_INVALID_RESPONSE
|
||||
)
|
||||
metadata = raw.get("metadata") if isinstance(raw.get("metadata"), dict) else {}
|
||||
try:
|
||||
count = int(raw.get("count"))
|
||||
except (TypeError, ValueError):
|
||||
count = None
|
||||
permalink = _clean(raw.get("permalink"))
|
||||
if "[REDACTED]" in permalink:
|
||||
permalink = ""
|
||||
user_count = raw.get("userCount")
|
||||
return {
|
||||
"id": str(issue_id).strip(),
|
||||
"short_id": _clean(raw.get("shortId")) or None,
|
||||
"title": _clean(raw.get("title"))[:200],
|
||||
"culprit": _clean(raw.get("culprit")) or None,
|
||||
"level": _clean(raw.get("level")) or None,
|
||||
"status": str(raw.get("status") or "unresolved").strip().lower() or "unresolved",
|
||||
"count": count,
|
||||
"user_count": user_count if isinstance(user_count, int) else None,
|
||||
"first_seen": _clean(raw.get("firstSeen")) or None,
|
||||
"last_seen": _clean(raw.get("lastSeen")) or None,
|
||||
"permalink": permalink or None,
|
||||
"metadata_value": _clean(metadata.get("value"))[:500] or None,
|
||||
"metadata_type": _clean(metadata.get("type")) or None,
|
||||
}
|
||||
|
||||
|
||||
def sanitize_event(raw: Any) -> dict[str, Any]:
|
||||
"""Project one raw Sentry event into a sanitized summary."""
|
||||
if not isinstance(raw, dict):
|
||||
raise SentryApiError(
|
||||
"Sentry event payload is not an object", kind=ERROR_INVALID_RESPONSE
|
||||
)
|
||||
tags: dict[str, str] = {}
|
||||
raw_tags = raw.get("tags")
|
||||
if isinstance(raw_tags, list):
|
||||
# Sentry events return tags as [{"key": ..., "value": ...}, ...]
|
||||
tags = incident_bridge.sanitize_tags(
|
||||
{
|
||||
t.get("key"): t.get("value")
|
||||
for t in raw_tags
|
||||
if isinstance(t, dict) and t.get("key")
|
||||
}
|
||||
)
|
||||
elif isinstance(raw_tags, dict):
|
||||
tags = incident_bridge.sanitize_tags(raw_tags)
|
||||
return {
|
||||
"event_id": _clean(raw.get("eventID") or raw.get("id")) or None,
|
||||
"message": _clean(raw.get("message") or raw.get("title"))[:2000] or None,
|
||||
"date_created": _clean(raw.get("dateCreated")) or None,
|
||||
"platform": _clean(raw.get("platform")) or None,
|
||||
"environment": _clean(raw.get("environment")) or None,
|
||||
"release": _clean(raw.get("release")) or None,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Reads
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_issues(
|
||||
config: SentryBridgeConfig,
|
||||
*,
|
||||
token: str,
|
||||
query: str = "is:unresolved",
|
||||
limit: int = DEFAULT_PAGE_SIZE,
|
||||
max_pages: int = DEFAULT_MAX_PAGES,
|
||||
cursor: str | None = None,
|
||||
environment: str | None = None,
|
||||
http_fn: HttpFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List sanitized unresolved Sentry issues, following ``Link`` pagination."""
|
||||
assert_configured(config, token)
|
||||
page_size = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE))
|
||||
pages_allowed = max(1, int(max_pages or 1))
|
||||
|
||||
issues: list[dict[str, Any]] = []
|
||||
next_cursor = cursor
|
||||
pages_fetched = 0
|
||||
for _ in range(pages_allowed):
|
||||
payload, headers = _get_json(
|
||||
config,
|
||||
config.issues_path(),
|
||||
{
|
||||
"query": query,
|
||||
"statsPeriod": config.lookback,
|
||||
"limit": page_size,
|
||||
"cursor": next_cursor,
|
||||
"environment": environment or config.environment,
|
||||
},
|
||||
token=token,
|
||||
http_fn=http_fn,
|
||||
)
|
||||
pages_fetched += 1
|
||||
if payload is None:
|
||||
payload = []
|
||||
if not isinstance(payload, list):
|
||||
raise SentryApiError(
|
||||
"Sentry issue list response was not a JSON array",
|
||||
kind=ERROR_INVALID_RESPONSE,
|
||||
)
|
||||
issues.extend(sanitize_issue(item) for item in payload)
|
||||
next_cursor = parse_next_cursor(headers.get("link"))
|
||||
if not next_cursor:
|
||||
break
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"issues": issues,
|
||||
"count": len(issues),
|
||||
"pages_fetched": pages_fetched,
|
||||
"next_cursor": next_cursor,
|
||||
"inventory_complete": next_cursor is None,
|
||||
"config": config.as_dict(),
|
||||
"query": query,
|
||||
}
|
||||
|
||||
|
||||
def get_issue_events(
|
||||
config: SentryBridgeConfig,
|
||||
issue_id: str,
|
||||
*,
|
||||
token: str,
|
||||
limit: int = 10,
|
||||
http_fn: HttpFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch sanitized recent events plus the latest event for one issue."""
|
||||
assert_configured(config, token)
|
||||
if not str(issue_id or "").strip():
|
||||
raise SentryApiError("issue_id is required", kind=ERROR_INVALID_RESPONSE)
|
||||
issue_key = str(issue_id).strip()
|
||||
|
||||
payload, _ = _get_json(
|
||||
config,
|
||||
config.issue_events_path(issue_key),
|
||||
{"limit": max(1, min(int(limit or 10), MAX_PAGE_SIZE))},
|
||||
token=token,
|
||||
http_fn=http_fn,
|
||||
)
|
||||
if payload is None:
|
||||
payload = []
|
||||
if not isinstance(payload, list):
|
||||
raise SentryApiError(
|
||||
"Sentry event list response was not a JSON array",
|
||||
kind=ERROR_INVALID_RESPONSE,
|
||||
)
|
||||
events = [sanitize_event(item) for item in payload]
|
||||
return {
|
||||
"success": True,
|
||||
"issue_id": issue_key,
|
||||
"events": events,
|
||||
"count": len(events),
|
||||
"latest_event": events[0] if events else None,
|
||||
"config": config.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Observation mapping + policy
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def observation_from_issue(
|
||||
issue: dict[str, Any],
|
||||
config: SentryBridgeConfig,
|
||||
*,
|
||||
gitea_org: str | None = None,
|
||||
gitea_repo: str | None = None,
|
||||
latest_event: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert a sanitized Sentry issue into a #612 observation dict."""
|
||||
tags = dict(latest_event.get("tags") or {}) if isinstance(latest_event, dict) else {}
|
||||
environment = None
|
||||
if isinstance(latest_event, dict):
|
||||
environment = latest_event.get("environment")
|
||||
environment = environment or config.environment
|
||||
|
||||
observation: dict[str, Any] = {
|
||||
"provider": PROVIDER,
|
||||
"provider_base_url": config.base_url,
|
||||
"provider_org": config.org,
|
||||
"provider_project": config.project,
|
||||
"provider_issue_id": issue.get("id"),
|
||||
"provider_short_id": issue.get("short_id"),
|
||||
"provider_permalink": issue.get("permalink"),
|
||||
"title": issue.get("title"),
|
||||
"culprit": issue.get("culprit"),
|
||||
"summary": issue.get("metadata_value") or issue.get("title"),
|
||||
"level": issue.get("level"),
|
||||
"status": issue.get("status") or "unresolved",
|
||||
"event_count": issue.get("count"),
|
||||
"first_seen": issue.get("first_seen"),
|
||||
"last_seen": issue.get("last_seen"),
|
||||
"environment": environment,
|
||||
"tags": tags,
|
||||
}
|
||||
if gitea_org:
|
||||
observation["gitea_org"] = gitea_org
|
||||
if gitea_repo:
|
||||
observation["gitea_repo"] = gitea_repo
|
||||
return observation
|
||||
|
||||
|
||||
def should_bridge_issue(
|
||||
issue: dict[str, Any], config: SentryBridgeConfig
|
||||
) -> tuple[bool, str]:
|
||||
"""Policy gate: is this Sentry issue worth a durable Gitea issue?"""
|
||||
status = str(issue.get("status") or "").strip().lower()
|
||||
if status and status != "unresolved":
|
||||
return False, f"status '{status}' is not unresolved"
|
||||
count = issue.get("count")
|
||||
threshold = int(config.min_events_for_issue or 1)
|
||||
if isinstance(count, int) and count < threshold:
|
||||
return (
|
||||
False,
|
||||
f"event count {count} below {ENV_MIN_EVENTS} threshold {threshold}",
|
||||
)
|
||||
return True, "meets bridge policy"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Watchdog
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def watchdog(
|
||||
db: Any,
|
||||
config: SentryBridgeConfig,
|
||||
*,
|
||||
token: str,
|
||||
apply: bool = False,
|
||||
mappings: Sequence[Any] | None = None,
|
||||
gitea_org: str | None = None,
|
||||
gitea_repo: str | None = None,
|
||||
query: str = "is:unresolved",
|
||||
limit: int = DEFAULT_PAGE_SIZE,
|
||||
max_pages: int = DEFAULT_MAX_PAGES,
|
||||
http_fn: HttpFn | None = None,
|
||||
create_issue_fn: Any = None,
|
||||
comment_issue_fn: Any = None,
|
||||
reconcile_fn: Callable[..., dict[str, Any]] | None = None,
|
||||
fetch_events: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Scan Sentry and reconcile active incidents into Gitea issues.
|
||||
|
||||
Dry-run by default. ``apply=True`` additionally requires the bridge to be
|
||||
explicitly enabled via ``MCP_SENTRY_ISSUE_BRIDGE_ENABLED``.
|
||||
|
||||
``comment_issue_fn`` carries the sanctioned issue-comment route used for
|
||||
AC4 recurrence comments on already-linked issues; dry runs never comment.
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"success": False,
|
||||
"apply": bool(apply),
|
||||
"scanned": 0,
|
||||
"reconciled": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"results": [],
|
||||
"reasons": [],
|
||||
"config": config.as_dict(),
|
||||
"raw_incident_assignable": False,
|
||||
"durable_work_system": "gitea_issues",
|
||||
}
|
||||
|
||||
if apply and not config.bridge_enabled:
|
||||
result["reasons"].append(
|
||||
f"{ENV_BRIDGE_ENABLED} is not enabled; apply refused (fail closed)"
|
||||
)
|
||||
result["error_kind"] = ERROR_BRIDGE_DISABLED
|
||||
return result
|
||||
|
||||
try:
|
||||
listing = list_issues(
|
||||
config,
|
||||
token=token,
|
||||
query=query,
|
||||
limit=limit,
|
||||
max_pages=max_pages,
|
||||
http_fn=http_fn,
|
||||
)
|
||||
except SentryApiError as exc:
|
||||
result["reasons"].append(str(exc))
|
||||
result.update(exc.as_dict())
|
||||
return result
|
||||
|
||||
reconciler = reconcile_fn or incident_bridge.reconcile_incident
|
||||
result["inventory_complete"] = listing.get("inventory_complete", False)
|
||||
result["pages_fetched"] = listing.get("pages_fetched", 0)
|
||||
|
||||
for issue in listing.get("issues", []):
|
||||
result["scanned"] += 1
|
||||
eligible, reason = should_bridge_issue(issue, config)
|
||||
if not eligible:
|
||||
result["skipped"] += 1
|
||||
result["results"].append(
|
||||
{
|
||||
"sentry_issue_id": issue.get("id"),
|
||||
"action": (
|
||||
ACTION_SKIPPED_THRESHOLD
|
||||
if "threshold" in reason
|
||||
else ACTION_SKIPPED_STATUS
|
||||
),
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
latest_event = None
|
||||
if fetch_events:
|
||||
try:
|
||||
events = get_issue_events(
|
||||
config, issue["id"], token=token, limit=1, http_fn=http_fn
|
||||
)
|
||||
latest_event = events.get("latest_event")
|
||||
except SentryApiError as exc:
|
||||
# Event enrichment is best-effort; the issue itself still bridges.
|
||||
result["reasons"].append(
|
||||
f"event fetch failed for {issue.get('id')}: {exc}"
|
||||
)
|
||||
|
||||
observation = observation_from_issue(
|
||||
issue,
|
||||
config,
|
||||
gitea_org=gitea_org,
|
||||
gitea_repo=gitea_repo,
|
||||
latest_event=latest_event,
|
||||
)
|
||||
try:
|
||||
reconciled = reconciler(
|
||||
db,
|
||||
observation=observation,
|
||||
mappings=list(mappings or []),
|
||||
apply=bool(apply),
|
||||
create_issue_fn=create_issue_fn,
|
||||
comment_issue_fn=comment_issue_fn,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - one bad issue must not abort the scan
|
||||
result["failed"] += 1
|
||||
result["results"].append(
|
||||
{
|
||||
"sentry_issue_id": issue.get("id"),
|
||||
"action": ACTION_FAILED,
|
||||
"reason": incident_bridge.redact_text(exc),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
result["reconciled"] += 1
|
||||
result["results"].append(
|
||||
{
|
||||
"sentry_issue_id": issue.get("id"),
|
||||
"action": ACTION_RECONCILED,
|
||||
"outcome": reconciled.get("outcome"),
|
||||
"gitea_issue": reconciled.get("gitea_issue"),
|
||||
"existing_link": reconciled.get("existing_link"),
|
||||
"recurrence_comment": reconciled.get("recurrence_comment"),
|
||||
"reasons": reconciled.get("reasons"),
|
||||
}
|
||||
)
|
||||
|
||||
result["success"] = result["failed"] == 0
|
||||
if not result["results"]:
|
||||
result["reasons"].append("no Sentry issues matched the scan window/policy")
|
||||
return result
|
||||
@@ -0,0 +1,613 @@
|
||||
"""Session-immutable MCP mutation context (#714).
|
||||
|
||||
Pins profile, remote, host, repository, identity, and role for the life of an
|
||||
MCP process (or until an explicit ``gitea_activate_profile`` re-bind).
|
||||
|
||||
Capability resolution and mutation gates must evaluate only the active
|
||||
profile for the requested remote. Silent cross-host / cross-profile
|
||||
substitution is forbidden.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SessionContext:
|
||||
"""One atomic, immutable process-session binding."""
|
||||
|
||||
profile_name: str | None
|
||||
remote: str | None
|
||||
host: str | None
|
||||
identity: str | None
|
||||
repository: str | None
|
||||
org: str | None
|
||||
role_kind: str | None
|
||||
expected_username: str | None
|
||||
source: str
|
||||
pid: int
|
||||
canonical_repository_root: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"profile_name": self.profile_name,
|
||||
"remote": self.remote,
|
||||
"host": self.host,
|
||||
"identity": self.identity,
|
||||
"repository": self.repository,
|
||||
"org": self.org,
|
||||
"role_kind": self.role_kind,
|
||||
"expected_username": self.expected_username,
|
||||
"source": self.source,
|
||||
"pid": self.pid,
|
||||
"canonical_repository_root": self.canonical_repository_root,
|
||||
}
|
||||
|
||||
|
||||
# Process-local only — never a shared file (same rationale as mutation authority).
|
||||
# The frozen value prevents partial mutation, while the lock makes first-bind and
|
||||
# sanctioned rebind atomic across concurrent MCP calls.
|
||||
_SESSION_CONTEXT: _SessionContext | None = None
|
||||
_SESSION_CONTEXT_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _reset_session_context_for_testing() -> None:
|
||||
"""Reset at a pytest test boundary; unavailable to production callers.
|
||||
|
||||
Production sessions transition only through process startup/PID change or
|
||||
the explicit profile-activation rebind. Keeping this helper private and
|
||||
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
|
||||
bypass.
|
||||
"""
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||
raise RuntimeError("session context reset is restricted to pytest boundaries")
|
||||
global _SESSION_CONTEXT
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
_SESSION_CONTEXT = None
|
||||
|
||||
|
||||
def get_session_context() -> dict[str, Any] | None:
|
||||
"""Return a detached snapshot of the bound context, or None if unbound."""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
if _SESSION_CONTEXT is None:
|
||||
return None
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def profile_host(profile: dict | None) -> str | None:
|
||||
"""Hostname from profile base_url, lowercased, or None."""
|
||||
if not profile:
|
||||
return None
|
||||
base = (profile.get("base_url") or "").strip()
|
||||
if not base:
|
||||
return None
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(base)
|
||||
host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||
return host or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def remote_host(remote: str | None, remotes: dict | None) -> str | None:
|
||||
"""Hostname for a known remote key."""
|
||||
if not remote or not remotes:
|
||||
return None
|
||||
entry = remotes.get(remote) or {}
|
||||
return (entry.get("host") or "").strip().lower() or None
|
||||
|
||||
|
||||
def profile_matches_remote(
|
||||
profile: dict | None,
|
||||
remote: str | None,
|
||||
remotes: dict | None,
|
||||
*,
|
||||
contexts: dict | None = None,
|
||||
) -> bool:
|
||||
"""True when *profile* is bound to the same host/context as *remote*."""
|
||||
if not profile or not remote:
|
||||
return False
|
||||
r_host = remote_host(remote, remotes)
|
||||
p_host = profile_host(profile)
|
||||
if r_host and p_host:
|
||||
return r_host == p_host
|
||||
# Fall back to context name heuristics when base_url missing.
|
||||
ctx = (profile.get("context") or "").strip().lower()
|
||||
if not ctx or not contexts:
|
||||
return False
|
||||
ctx_data = contexts.get(ctx) or {}
|
||||
gitea = ctx_data.get("gitea") or {}
|
||||
base = (gitea.get("base_url") or "").strip()
|
||||
if not base or not r_host:
|
||||
return False
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(base)
|
||||
c_host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||
except Exception:
|
||||
return False
|
||||
return bool(c_host) and c_host == r_host
|
||||
|
||||
|
||||
def bind_session_context(
|
||||
*,
|
||||
profile_name: str,
|
||||
remote: str | None,
|
||||
host: str | None,
|
||||
identity: str | None,
|
||||
repository: str | None = None,
|
||||
org: str | None = None,
|
||||
role_kind: str | None = None,
|
||||
expected_username: str | None = None,
|
||||
source: str = "bind",
|
||||
canonical_repository_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically bind/re-bind context (the explicit activation path)."""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
return _bind_session_context_unlocked(
|
||||
profile_name=profile_name,
|
||||
remote=remote,
|
||||
host=host,
|
||||
identity=identity,
|
||||
repository=repository,
|
||||
org=org,
|
||||
role_kind=role_kind,
|
||||
expected_username=expected_username,
|
||||
source=source,
|
||||
canonical_repository_root=canonical_repository_root,
|
||||
)
|
||||
|
||||
|
||||
def _bind_session_context_unlocked(
|
||||
*,
|
||||
profile_name: str,
|
||||
remote: str | None,
|
||||
host: str | None,
|
||||
identity: str | None,
|
||||
repository: str | None,
|
||||
org: str | None,
|
||||
role_kind: str | None,
|
||||
expected_username: str | None,
|
||||
source: str,
|
||||
canonical_repository_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Store a complete immutable context while the caller holds the lock."""
|
||||
global _SESSION_CONTEXT
|
||||
_SESSION_CONTEXT = _SessionContext(
|
||||
profile_name=(profile_name or "").strip() or None,
|
||||
remote=(remote or "").strip() or None,
|
||||
host=(host or "").strip().lower() or None,
|
||||
identity=(identity or "").strip() or None,
|
||||
repository=(repository or "").strip() or None,
|
||||
org=(org or "").strip() or None,
|
||||
role_kind=(role_kind or "").strip() or None,
|
||||
expected_username=(expected_username or "").strip() or None,
|
||||
source=source,
|
||||
pid=os.getpid(),
|
||||
canonical_repository_root=(canonical_repository_root or "").strip() or None,
|
||||
)
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def seed_session_context_if_unbound(
|
||||
*,
|
||||
profile_name: str,
|
||||
remote: str | None,
|
||||
host: str | None,
|
||||
identity: str | None,
|
||||
repository: str | None = None,
|
||||
org: str | None = None,
|
||||
role_kind: str | None = None,
|
||||
expected_username: str | None = None,
|
||||
source: str = "seed",
|
||||
canonical_repository_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically bind only when this process has no current context.
|
||||
|
||||
A changed environment or an interleaved call is not a session boundary and
|
||||
therefore cannot replace an established binding. A newly started/forked
|
||||
process is recognized by PID; explicit ``gitea_activate_profile`` uses
|
||||
:func:`bind_session_context` as its sanctioned logical-session transition.
|
||||
"""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
|
||||
return _bind_session_context_unlocked(
|
||||
profile_name=profile_name,
|
||||
remote=remote,
|
||||
host=host,
|
||||
identity=identity,
|
||||
repository=repository,
|
||||
org=org,
|
||||
role_kind=role_kind,
|
||||
expected_username=expected_username,
|
||||
source=source,
|
||||
canonical_repository_root=canonical_repository_root,
|
||||
)
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def assess_session_context(
|
||||
*,
|
||||
profile_name: str | None,
|
||||
remote: str | None,
|
||||
host: str | None = None,
|
||||
identity: str | None = None,
|
||||
repository: str | None = None,
|
||||
org: str | None = None,
|
||||
expected_username: str | None = None,
|
||||
canonical_repository_root: str | None = None,
|
||||
require_bound: bool = False,
|
||||
require_complete: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Compare live values against the bound session context.
|
||||
|
||||
Returns ``proven`` / ``block`` / ``reasons``. When unbound and
|
||||
``require_bound`` is false, does not block (caller may seed). When
|
||||
unbound and ``require_bound`` is true, fails closed. ``require_complete``
|
||||
additionally fails closed when the binding carries no verified
|
||||
repository/organization identity, so a mutation can never run against an
|
||||
unknown repository (#714).
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
bound = _SESSION_CONTEXT
|
||||
ctx = bound.as_dict() if bound is not None else None
|
||||
if ctx is None or ctx.get("pid") != os.getpid():
|
||||
if require_bound:
|
||||
reasons.append(
|
||||
"session mutation context is unbound; call gitea_whoami or "
|
||||
"gitea_activate_profile before mutating (fail closed)"
|
||||
)
|
||||
return _assessment(False, reasons, ctx)
|
||||
return _assessment(True, reasons, ctx)
|
||||
|
||||
if require_complete and not (ctx.get("repository") and ctx.get("org")):
|
||||
reasons.append(
|
||||
"session repository/organization identity is unverified "
|
||||
f"(repository={ctx.get('repository')!r}, org={ctx.get('org')!r}); "
|
||||
"a mutation cannot proceed without a verified workspace "
|
||||
"repository (fail closed)"
|
||||
)
|
||||
return _assessment(False, reasons, ctx)
|
||||
|
||||
live_profile = (profile_name or "").strip() or None
|
||||
live_remote = (remote or "").strip() or None
|
||||
live_host = (host or "").strip().lower() or None
|
||||
live_identity = (identity or "").strip() or None
|
||||
live_repo = (repository or "").strip() or None
|
||||
live_org = (org or "").strip() or None
|
||||
live_canonical = (canonical_repository_root or "").strip() or None
|
||||
|
||||
if ctx.get("profile_name") and live_profile and live_profile != ctx.get("profile_name"):
|
||||
reasons.append(
|
||||
f"profile drift: live '{live_profile}' != bound "
|
||||
f"'{ctx.get('profile_name')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("remote") and live_remote and live_remote != ctx.get("remote"):
|
||||
reasons.append(
|
||||
f"remote drift: live '{live_remote}' != bound "
|
||||
f"'{ctx.get('remote')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("host") and live_host and live_host != ctx.get("host"):
|
||||
reasons.append(
|
||||
f"host drift: live '{live_host}' != bound "
|
||||
f"'{ctx.get('host')}' (fail closed)"
|
||||
)
|
||||
if (
|
||||
ctx.get("identity")
|
||||
and live_identity
|
||||
and live_identity != ctx.get("identity")
|
||||
):
|
||||
reasons.append(
|
||||
f"identity drift: live '{live_identity}' != bound "
|
||||
f"'{ctx.get('identity')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("repository") and live_repo and live_repo != ctx.get("repository"):
|
||||
reasons.append(
|
||||
f"repository drift: live '{live_repo}' != bound "
|
||||
f"'{ctx.get('repository')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("org") and live_org and live_org != ctx.get("org"):
|
||||
reasons.append(
|
||||
f"org drift: live '{live_org}' != bound "
|
||||
f"'{ctx.get('org')}' (fail closed)"
|
||||
)
|
||||
bound_canonical = ctx.get("canonical_repository_root")
|
||||
if bound_canonical and live_canonical and live_canonical != bound_canonical:
|
||||
reasons.append(
|
||||
f"canonical repository root drift: live '{live_canonical}' != bound "
|
||||
f"'{bound_canonical}' (forged or conflicting cross-repository "
|
||||
"binding, fail closed)"
|
||||
)
|
||||
|
||||
expected = expected_username or ctx.get("expected_username")
|
||||
if expected and live_identity and live_identity != expected:
|
||||
reasons.append(
|
||||
f"identity mismatch: authenticated '{live_identity}' != "
|
||||
f"profile expected '{expected}' (fail closed)"
|
||||
)
|
||||
|
||||
return _assessment(not reasons, reasons, ctx)
|
||||
|
||||
|
||||
def assess_identity_match(
|
||||
*,
|
||||
authenticated: str | None,
|
||||
expected_username: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when profile declares a username that does not match live auth."""
|
||||
reasons: list[str] = []
|
||||
auth = (authenticated or "").strip() or None
|
||||
expected = (expected_username or "").strip() or None
|
||||
if expected and auth and auth != expected:
|
||||
reasons.append(
|
||||
f"identity mismatch: authenticated '{auth}' != "
|
||||
f"profile expected '{expected}' (fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"authenticated": auth,
|
||||
"expected_username": expected,
|
||||
}
|
||||
|
||||
|
||||
_REPO_SLUG_RE = re.compile(r"^\s*(?P<org>[^/\s]+)\s*/\s*(?P<repo>[^/\s]+?)(?:\.git)?\s*$")
|
||||
|
||||
|
||||
def parse_repository_slug(slug: str | None) -> tuple[str, str] | None:
|
||||
"""Split a canonical ``owner/repository`` slug, or None when unparseable."""
|
||||
match = _REPO_SLUG_RE.match(slug or "")
|
||||
if not match:
|
||||
return None
|
||||
return match.group("org"), match.group("repo")
|
||||
|
||||
|
||||
def format_repository_slug(org: str | None, repo: str | None) -> str | None:
|
||||
"""``owner/repository`` from parts, or None when either side is missing."""
|
||||
left = (org or "").strip()
|
||||
right = (repo or "").strip()
|
||||
if not left or not right:
|
||||
return None
|
||||
return f"{left}/{right}"
|
||||
|
||||
|
||||
def declared_allowed_repositories(
|
||||
profile: Mapping[str, Any] | None,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> list[str]:
|
||||
"""Canonical ``owner/repository`` authorization boundary declared by *profile*.
|
||||
|
||||
This list is an authorization boundary, never the session binding itself:
|
||||
the verified workspace selects exactly one entry (see
|
||||
:func:`assess_repository_scope`).
|
||||
|
||||
When *strict* is true (mutation path), missing profile, non-list values, or
|
||||
any malformed entry raise ``ValueError`` instead of silently collapsing to
|
||||
an empty scope (which would fail open). Read-only diagnostics may use
|
||||
strict=False.
|
||||
"""
|
||||
if not profile:
|
||||
if strict:
|
||||
raise ValueError(
|
||||
"profile unresolved; cannot declare allowed_repositories "
|
||||
"(fail closed)"
|
||||
)
|
||||
return []
|
||||
raw = profile.get("allowed_repositories")
|
||||
if raw is None:
|
||||
return []
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
if strict:
|
||||
raise ValueError(
|
||||
"allowed_repositories must be a list of owner/repository slugs "
|
||||
"(fail closed)"
|
||||
)
|
||||
return []
|
||||
slugs: list[str] = []
|
||||
errors: list[str] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, str):
|
||||
errors.append(f"non-string allowed_repositories entry {entry!r}")
|
||||
continue
|
||||
parsed = parse_repository_slug(entry)
|
||||
if not parsed:
|
||||
errors.append(
|
||||
f"malformed allowed_repositories entry {entry!r} "
|
||||
"(expected owner/repository)"
|
||||
)
|
||||
continue
|
||||
slugs.append(f"{parsed[0]}/{parsed[1]}")
|
||||
if strict and errors:
|
||||
raise ValueError("; ".join(errors) + " (fail closed)")
|
||||
return slugs
|
||||
|
||||
|
||||
def assess_repository_scope(
|
||||
*,
|
||||
workspace_slug: str | None,
|
||||
allowed: list[str] | None,
|
||||
profile_name: str | None = None,
|
||||
require_scope: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Authorize the verified workspace repository against the profile allowlist.
|
||||
|
||||
The workspace-derived slug is the only candidate: a profile that authorizes
|
||||
several repositories still binds to the single verified one, and never to
|
||||
the list as a whole.
|
||||
|
||||
*require_scope* (mutation path): missing/empty allowlist fails closed. The
|
||||
workspace remote cannot self-authorize without a configured boundary.
|
||||
"""
|
||||
scope = list(allowed or [])
|
||||
reasons: list[str] = []
|
||||
name = profile_name or "(active profile)"
|
||||
if not scope:
|
||||
if require_scope:
|
||||
reasons.append(
|
||||
f"mutation denied: profile '{name}' has no non-empty "
|
||||
"allowed_repositories authorization boundary (fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": reasons,
|
||||
"scope_enforced": True,
|
||||
"workspace_slug": workspace_slug,
|
||||
"allowed_repositories": [],
|
||||
}
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": reasons,
|
||||
"scope_enforced": False,
|
||||
"workspace_slug": workspace_slug,
|
||||
"allowed_repositories": [],
|
||||
}
|
||||
if not workspace_slug:
|
||||
reasons.append(
|
||||
f"no verified workspace repository could be established for profile "
|
||||
f"'{name}'; repository scope cannot be authorized (fail closed)"
|
||||
)
|
||||
elif workspace_slug.lower() not in {entry.lower() for entry in scope}:
|
||||
reasons.append(
|
||||
f"repository scope denial: workspace repository '{workspace_slug}' "
|
||||
f"is not authorized by profile '{name}' allowed_repositories "
|
||||
f"{sorted(scope)} (fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"scope_enforced": True,
|
||||
"workspace_slug": workspace_slug,
|
||||
"allowed_repositories": sorted(scope),
|
||||
}
|
||||
|
||||
|
||||
def assess_repository_override(
|
||||
*,
|
||||
requested_org: str | None,
|
||||
requested_repo: str | None,
|
||||
bound_org: str | None,
|
||||
bound_repo: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Caller-supplied org/repo must agree with the immutable binding.
|
||||
|
||||
A mutation request is never allowed to establish, complete, or replace the
|
||||
binding — it may only be checked against it.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
req_org = (requested_org or "").strip() or None
|
||||
req_repo = (requested_repo or "").strip() or None
|
||||
if req_repo and bound_repo and req_repo.lower() != bound_repo.lower():
|
||||
reasons.append(
|
||||
f"repository override denial: request targets '{req_repo}' but the "
|
||||
f"session is bound to '{bound_repo}' (fail closed)"
|
||||
)
|
||||
if req_org and bound_org and req_org.lower() != bound_org.lower():
|
||||
reasons.append(
|
||||
f"organization override denial: request targets '{req_org}' but the "
|
||||
f"session is bound to '{bound_org}' (fail closed)"
|
||||
)
|
||||
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||
|
||||
|
||||
def filter_profiles_for_remote(
|
||||
config: dict | None,
|
||||
remote: str | None,
|
||||
remotes: dict | None,
|
||||
) -> list[str]:
|
||||
"""Profile names whose host/context matches *remote* (enabled only)."""
|
||||
if not config or not remote:
|
||||
return []
|
||||
profiles = config.get("profiles") or {}
|
||||
contexts = config.get("contexts") or {}
|
||||
names: list[str] = []
|
||||
for name, data in profiles.items():
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if not data.get("enabled", True):
|
||||
continue
|
||||
if profile_matches_remote(data, remote, remotes, contexts=contexts):
|
||||
names.append(name)
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def profile_allowed_for_remote(
|
||||
profile: dict | None,
|
||||
remote: str | None,
|
||||
remotes: dict | None,
|
||||
*,
|
||||
contexts: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether the active profile may serve *remote*."""
|
||||
reasons: list[str] = []
|
||||
if not profile:
|
||||
reasons.append("active profile unresolved (fail closed)")
|
||||
return {"proven": False, "block": True, "reasons": reasons}
|
||||
if not remote:
|
||||
return {"proven": True, "block": False, "reasons": reasons}
|
||||
# Legacy env-only profiles have no configured base URL or v2 context. Their
|
||||
# first call may establish the process remote/host pin; after that,
|
||||
# assess_session_context rejects any drift. Configured v2 profiles still
|
||||
# require positive host/context alignment here.
|
||||
if not profile_host(profile) and not (profile.get("context") or "").strip():
|
||||
return {"proven": True, "block": False, "reasons": reasons}
|
||||
if not profile_matches_remote(profile, remote, remotes, contexts=contexts):
|
||||
p_name = profile.get("profile_name") or profile.get("name") or "(unknown)"
|
||||
p_host = profile_host(profile) or "(none)"
|
||||
r_host = remote_host(remote, remotes) or "(none)"
|
||||
reasons.append(
|
||||
f"cross-host profile denial: profile '{p_name}' (host '{p_host}') "
|
||||
f"cannot serve remote '{remote}' (host '{r_host}') (fail closed)"
|
||||
)
|
||||
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||
|
||||
|
||||
def mutation_context_audit_fields(
|
||||
ctx: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fields to include in pre-mutation audit records."""
|
||||
data = ctx if ctx is not None else get_session_context()
|
||||
if not data:
|
||||
return {
|
||||
"session_context_bound": False,
|
||||
"session_profile": None,
|
||||
"session_remote": None,
|
||||
"session_host": None,
|
||||
"session_identity": None,
|
||||
"session_repository": None,
|
||||
"session_org": None,
|
||||
}
|
||||
return {
|
||||
"session_context_bound": True,
|
||||
"session_profile": data.get("profile_name"),
|
||||
"session_remote": data.get("remote"),
|
||||
"session_host": data.get("host"),
|
||||
"session_identity": data.get("identity"),
|
||||
"session_repository": data.get("repository"),
|
||||
"session_org": data.get("org"),
|
||||
"session_role_kind": data.get("role_kind"),
|
||||
"session_context_source": data.get("source"),
|
||||
"session_canonical_repository_root": data.get("canonical_repository_root"),
|
||||
}
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(reasons),
|
||||
"bound_context": dict(ctx) if ctx else None,
|
||||
"audit": mutation_context_audit_fields(ctx),
|
||||
}
|
||||
@@ -35,3 +35,11 @@ Install for Codex:
|
||||
```
|
||||
|
||||
Preflight via MCP: `mcp_check_workflow_skill_preflight`.
|
||||
|
||||
## Tool inventory
|
||||
|
||||
Which tools actually exist is documented in
|
||||
[`docs/mcp-tool-inventory.md`](../../docs/mcp-tool-inventory.md), which a test
|
||||
holds equal to the registered set. Never plan a mutation against a tool that is
|
||||
not listed there — that is the #781 failure mode, where a documented
|
||||
`gitea_edit_issue` did not exist until execution time.
|
||||
|
||||
@@ -216,6 +216,15 @@ Helpers: `scripts/worktree-start`, `scripts/worktree-review`,
|
||||
- Never place raw tokens in LLM/MCP config.
|
||||
- Use `gitea_whoami` and `gitea_resolve_task_capability` before mutating.
|
||||
|
||||
## Tool inventory
|
||||
|
||||
[`docs/mcp-tool-inventory.md`](../../docs/mcp-tool-inventory.md) is the canonical
|
||||
list of registered tools, held equal to the live registry by a test. A tool that
|
||||
is not listed there does not exist — do not scope work around it (#781).
|
||||
|
||||
Issue content is edited with `gitea_edit_issue` (title/body only, read-after-write
|
||||
verified). `gitea_edit_pr` is pull-request-only and never accepts an issue number.
|
||||
|
||||
## Controller Handoff
|
||||
|
||||
Every task must end with a section titled exactly `Controller Handoff`. Compact
|
||||
@@ -224,6 +233,17 @@ format canonical field set per issue #182; mode-specific schemas in
|
||||
for the loaded workflow mode — not the legacy compact block alone.
|
||||
`review_proofs.assess_controller_handoff()` validates presence.
|
||||
|
||||
## Canonical self-propagating handoff
|
||||
|
||||
Every workflow mode also carries the cross-role handoff block defined in
|
||||
[`schemas/self-propagating-handoff.md`](schemas/self-propagating-handoff.md)
|
||||
(#626). Each actor consumes exactly one canonical handoff, performs exactly one
|
||||
authorized role, posts the result to the Gitea issue or PR thread, and emits the
|
||||
next complete handoff — until the controller records final closure. The block
|
||||
must be posted to Gitea, not returned in chat alone, and the next prompt is not
|
||||
an optional prose section. `self_propagating_handoff.py` implements the schema;
|
||||
`final_report_validator.py` enforces it as `shared.self_propagating_handoff`.
|
||||
|
||||
## Prompt templates
|
||||
|
||||
Ready-to-copy task prompts live in [`templates/`](templates/):
|
||||
|
||||
@@ -44,3 +44,7 @@ mutations occurred).
|
||||
```
|
||||
|
||||
Identity format: `username / profile` (not personal email unless required — #305).
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea issue thread.
|
||||
|
||||
@@ -29,3 +29,7 @@ use `none` where nothing occurred. Validated by
|
||||
* Read-only diagnostics:
|
||||
* Blockers:
|
||||
* Safe next action: (fresh run for the next PR)
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea PR thread.
|
||||
|
||||
@@ -39,6 +39,7 @@ occurred).
|
||||
- Git ref mutations:
|
||||
- MCP/Gitea mutations:
|
||||
- Reconciliation mutations:
|
||||
- Terminal label cleanup:
|
||||
- External-state mutations:
|
||||
- Read-only diagnostics:
|
||||
- Blockers:
|
||||
@@ -50,4 +51,15 @@ occurred).
|
||||
|
||||
Identity format: `username / profile` (not personal email unless required — #305).
|
||||
|
||||
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
|
||||
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
|
||||
|
||||
`Terminal label cleanup` (#780) reports the `pr_open_label_cleanup` record the
|
||||
reconciliation tool returned — `clean` / `failed` / `not applicable (no linked
|
||||
issue)`, with the labels removed and preserved. Reconciliation is a terminal
|
||||
transition, so a non-`clean` record blocks any "reconciled" claim; recover with
|
||||
`gitea_cleanup_terminal_pr_labels` (`terminal_reason='retry_recovery'`) and
|
||||
confirm with `gitea_assess_terminal_label_hygiene`.
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea issue or PR thread.
|
||||
@@ -99,6 +99,21 @@ Narrative final report and controller handoff must agree on eligibility class,
|
||||
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
|
||||
terminal review mutation, merge result, and linked issue status.
|
||||
|
||||
### Terminal label state (#780)
|
||||
|
||||
A run that takes a PR to a terminal state — merged, closed without merge,
|
||||
superseded, or reconciled as already landed — must report what happened to the
|
||||
linked issue's `status:pr-open` label, quoting the `pr_open_label_cleanup`
|
||||
record the terminal tool returned:
|
||||
|
||||
- Terminal label cleanup: `clean` / `failed` / `not applicable (no linked issue)`
|
||||
- Labels removed and preserved per issue, with the read-after-write read-back
|
||||
|
||||
Never claim the transition is complete while that record is not `clean`. A
|
||||
failed cleanup does not undo the merge; the safe next action is
|
||||
`gitea_cleanup_terminal_pr_labels` with `terminal_reason='retry_recovery'`,
|
||||
confirmed by `gitea_assess_terminal_label_hygiene`.
|
||||
|
||||
### Proof-backed claims (#395)
|
||||
|
||||
Proof-sensitive claims must cite explicit command/tool evidence in the report
|
||||
@@ -116,4 +131,9 @@ or structured MCP metadata — not narrative alone:
|
||||
|
||||
When a claim relies on prior-session blocker state or MCP metadata only, label
|
||||
the proof source explicitly (`command`, `MCP metadata`, `prior blocker`,
|
||||
`not checked`). Do not use `live proof` without that classification.
|
||||
`not checked`). Do not use `live proof` without that classification.
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea PR thread. A reviewer hands off to `merger`; a merger transitions to
|
||||
`merged-awaiting-controller` rather than declaring the work accepted.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Canonical self-propagating handoff schema (#626)
|
||||
|
||||
**Applies to:** every workflow actor — author, reviewer, merger, controller,
|
||||
operator, reconciler.
|
||||
|
||||
`#494`–`#507` defined the ledger, the canonical state comments, and the
|
||||
Canonical Thread Handoff shape. This schema owns the *chain*: each actor
|
||||
consumes exactly one canonical handoff, performs exactly one authorized role,
|
||||
records the result durably in Gitea, and emits the next complete handoff —
|
||||
until the controller records final closure.
|
||||
|
||||
Implemented and enforced by `self_propagating_handoff.py`; wired into
|
||||
`final_report_validator.py` as rule `shared.self_propagating_handoff`.
|
||||
|
||||
## The block
|
||||
|
||||
Post this block into the Gitea issue or PR thread, and include it verbatim in
|
||||
the final report. It is not an optional prose section.
|
||||
|
||||
```md
|
||||
<!-- sph:v1 -->
|
||||
## Canonical Handoff
|
||||
|
||||
```text
|
||||
REPOSITORY: <org>/<repo>
|
||||
ISSUE: <number>
|
||||
PR: <number or none>
|
||||
WORKFLOW_STATE: <one of the workflow states below>
|
||||
HEAD_SHA: <current head, or none before a branch exists>
|
||||
BASE_BRANCH: <base branch>
|
||||
BASE_OR_MERGE_SHA: <base SHA, or merge commit SHA after merge>
|
||||
ACTING_ROLE: <author|reviewer|merger|controller|operator|reconciler>
|
||||
ACTING_IDENTITY: <username (profile)>
|
||||
COMPLETED_ACTIONS: <what this actor actually did>
|
||||
VALIDATION_EVIDENCE: <commands run and their results>
|
||||
MUTATION_LEDGER: <every durable mutation performed>
|
||||
BLOCKERS: <active blockers, or none>
|
||||
NEXT_ACTOR: <role authorized by WORKFLOW_STATE, or none when complete>
|
||||
NEXT_ACTION: <exact next action, or none when complete>
|
||||
PROHIBITED_ACTIONS: <what the next actor must not do>
|
||||
NEXT_PROMPT: <complete ready-to-run prompt, or none when complete>
|
||||
WORKFLOW_FAILURE_ISSUES: <durable issue refs for tooling defects, or none>
|
||||
LAST_UPDATED: <UTC timestamp>
|
||||
```
|
||||
```
|
||||
|
||||
## Workflow states and the single authorized actor
|
||||
|
||||
| `WORKFLOW_STATE` | `NEXT_ACTOR` |
|
||||
| --------------------------- | ------------ |
|
||||
| `needs-author` | `author` |
|
||||
| `needs-review` | `reviewer` |
|
||||
| `approved-awaiting-merge` | `merger` |
|
||||
| `merged-awaiting-controller`| `controller` |
|
||||
| `blocked` | `operator` |
|
||||
| `complete` | `none` |
|
||||
|
||||
A merged PR is **not** accepted work: merge transitions to
|
||||
`merged-awaiting-controller` unless the configured workflow explicitly
|
||||
authorizes automatic acceptance.
|
||||
|
||||
## Fail-closed rules
|
||||
|
||||
* Every field is required. Only `PR`, `HEAD_SHA`, `BASE_OR_MERGE_SHA`,
|
||||
`BLOCKERS`, `WORKFLOW_FAILURE_ISSUES`, `NEXT_ACTION`, and `NEXT_PROMPT` may
|
||||
carry `none`, and `PR`/`HEAD_SHA` only in `needs-author` or `blocked`.
|
||||
* `NEXT_ACTOR` must equal the actor the declared state authorizes.
|
||||
* `blocked` requires a concrete `BLOCKERS` entry.
|
||||
* A non-terminal handoff requires a concrete `NEXT_ACTION` and a
|
||||
`NEXT_PROMPT` long enough to be ready to run.
|
||||
* `complete` must carry no `NEXT_ACTION` and no `NEXT_PROMPT`: a finished
|
||||
workflow terminates instead of manufacturing more work.
|
||||
* `NEXT_PROMPT` must name the repository and the issue, and must not depend on
|
||||
outside chat history. The issue or PR thread, workflow documentation, and
|
||||
live repository state must be sufficient to recover the task.
|
||||
* The handoff must be posted to Gitea. A chat-only report is not durable
|
||||
workflow state.
|
||||
|
||||
## Live-state recovery before acting
|
||||
|
||||
The receiving actor re-derives truth from live state instead of trusting the
|
||||
inherited handoff. `assess_handoff_live_state` detects and fails closed on:
|
||||
`changed_pr_head`, `stale_approval`, `issue_closed`, `issue_reopened`,
|
||||
`pr_merged`, `pr_closed_unmerged`, `stale_lease`, `foreign_lease`,
|
||||
`missing_worktree`, `dirty_worktree`, `namespace_mismatch`, `stale_runtime`,
|
||||
`changed_base`, and `conflicting_canonical_comments`.
|
||||
|
||||
A changed head invalidates any inherited review or merge handoff; the chain
|
||||
recovers to `needs-review`.
|
||||
|
||||
## Controller closure
|
||||
|
||||
`accept` is only honored with all four closure proofs recorded:
|
||||
`acceptance_criteria_satisfied`, `cleanup_complete`,
|
||||
`canonical_final_state_posted`, `issue_closed_through_workflow`. Otherwise the
|
||||
work item stays at `merged-awaiting-controller`.
|
||||
|
||||
`request_tests`, `request_proof`, `request_corrections`, and `reopen` return
|
||||
the work to the author; `return_to_actor` returns it to a named earlier actor.
|
||||
|
||||
## Workflow-failure escalation
|
||||
|
||||
Tooling or workflow defects found while working an item never get folded into
|
||||
the active feature issue. Each distinct failure carries `classification`,
|
||||
`linked_issue`, `temporary_impact`, `next_valid_actor`, and `recovery_prompt`.
|
||||
A failure whose signature already has a durable issue must reuse that issue
|
||||
instead of filing a duplicate.
|
||||
|
||||
## Applicability
|
||||
|
||||
Enforcement is applicability-gated exactly like the #495 canonical-state gate:
|
||||
once a report carries the `sph:v1` marker, the `Canonical Handoff` heading, or
|
||||
a `WORKFLOW_STATE:` line, the full schema is enforced and incomplete handoffs
|
||||
are rejected. Reports written before the protocol existed are unaffected.
|
||||
@@ -70,4 +70,9 @@ selected issue, and mutation ledger categories (#319, #320).
|
||||
`Read-only diagnostics` (#297).
|
||||
|
||||
Forbidden claims without proof (#330): `next eligible issue`, `issue claimed`,
|
||||
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
|
||||
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
|
||||
|
||||
The report must also carry the canonical self-propagating handoff block
|
||||
(`schemas/self-propagating-handoff.md`, #626) and that block must be posted to
|
||||
the Gitea issue or PR thread. The next prompt is not an optional prose
|
||||
section.
|
||||
@@ -450,6 +450,35 @@ If any gate fails, do not create the issue.
|
||||
|
||||
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
|
||||
|
||||
Before commenting on an existing issue, verify:
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
"""Stable-control vs dev/test runtime classification and mutation gates (#615).
|
||||
|
||||
``docs/architecture/mcp-stable-control-runtime-policy-adr.md`` states the policy:
|
||||
real Gitea mutations may only be performed by the **stable control runtime**,
|
||||
while MCP server development happens in isolated ``branches/`` worktrees and
|
||||
optional dev/test runtimes. The ADR alone is not enforcement — a daemon
|
||||
relaunched from a feature worktree still holds production credentials and will
|
||||
happily mutate production issues.
|
||||
|
||||
This module supplies the runtime half of that policy:
|
||||
|
||||
* :func:`classify_runtime_mode` decides whether the running process is a
|
||||
``stable-control``, ``dev-test``, or ``unknown`` runtime.
|
||||
* :func:`build_runtime_report` collects the reporting fields the ADR requires
|
||||
(mode, SHA, branch, checkout path, process root, workspace, binding, dirty
|
||||
files, alignment, and whether real mutations are allowed).
|
||||
* :func:`assess_runtime_mutation_gate` turns that report into a fail-closed
|
||||
mutation gate.
|
||||
* The post-transport-flap helpers keep namespace re-proving **per namespace**,
|
||||
so proving the author namespace never implies the reviewer, merger, or
|
||||
reconciler namespace is callable.
|
||||
|
||||
Every assessment is pure: callers inject the observed facts, so the logic is
|
||||
unit-testable without a git checkout or a live daemon. Only the thin
|
||||
:func:`observe_runtime` reader touches the filesystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Operator declaration of the runtime this process is. An explicit, valid
|
||||
# declaration wins over inference — an operator running a packaged release
|
||||
# layout may have no git checkout to infer from.
|
||||
ENV_RUNTIME_MODE = "GITEA_MCP_RUNTIME_MODE"
|
||||
# Escape hatch mirroring the #420 parity gate: disables enforcement only.
|
||||
ENV_DISABLE = "GITEA_MCP_DISABLE_RUNTIME_MODE_GATE"
|
||||
|
||||
RUNTIME_MODE_STABLE = "stable-control"
|
||||
RUNTIME_MODE_DEV_TEST = "dev-test"
|
||||
RUNTIME_MODE_UNKNOWN = "unknown"
|
||||
|
||||
VALID_RUNTIME_MODES = frozenset(
|
||||
{RUNTIME_MODE_STABLE, RUNTIME_MODE_DEV_TEST, RUNTIME_MODE_UNKNOWN}
|
||||
)
|
||||
|
||||
# Branches a stable control checkout is allowed to sit on. Anything else is a
|
||||
# development checkout by definition (the global worktree rule keeps the
|
||||
# control checkout on a stable branch).
|
||||
STABLE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
|
||||
# Path segment that marks an isolated development worktree.
|
||||
DEV_WORKTREE_SEGMENT = "branches"
|
||||
|
||||
BLOCKER_DEV_TEST_PRODUCTION = "dev_test_runtime_targets_production"
|
||||
BLOCKER_UNKNOWN_RUNTIME = "unknown_runtime_mode"
|
||||
BLOCKER_DIRTY_STABLE_RUNTIME = "dirty_stable_runtime_checkout"
|
||||
BLOCKER_DEV_WORKTREE_LAUNCH = "runtime_launched_from_dev_worktree"
|
||||
BLOCKER_UNSAFE_ALIGNMENT = "unsafe_process_root_workspace_alignment"
|
||||
BLOCKER_NAMESPACE_NOT_REPROVEN = "namespace_not_reproven_after_flap"
|
||||
|
||||
# Namespaces that must each be re-proven independently after a transport flap.
|
||||
WORKFLOW_NAMESPACES = ("author", "reviewer", "merger", "reconciler")
|
||||
|
||||
|
||||
def gate_disabled() -> bool:
|
||||
"""Whether the runtime-mode gate is disabled by env escape hatch."""
|
||||
return bool((os.environ.get(ENV_DISABLE) or "").strip())
|
||||
|
||||
|
||||
def declared_runtime_mode() -> str | None:
|
||||
"""Return the operator-declared runtime mode, if a valid one is set.
|
||||
|
||||
An unset or unrecognised value returns ``None`` so classification falls
|
||||
back to inference rather than trusting a typo.
|
||||
"""
|
||||
value = (os.environ.get(ENV_RUNTIME_MODE) or "").strip().lower()
|
||||
if value in VALID_RUNTIME_MODES:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _path_segments(path: str) -> list[str]:
|
||||
return [seg for seg in os.path.normpath(path).split(os.sep) if seg]
|
||||
|
||||
|
||||
def launched_from_dev_worktree(process_root: str | None) -> bool:
|
||||
"""Whether *process_root* sits inside a ``branches/`` development worktree."""
|
||||
if not process_root:
|
||||
return False
|
||||
return DEV_WORKTREE_SEGMENT in _path_segments(process_root)
|
||||
|
||||
|
||||
def classify_runtime_mode(
|
||||
*,
|
||||
process_root: str | None,
|
||||
checkout_branch: str | None,
|
||||
is_git_checkout: bool = True,
|
||||
declared_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""Classify the runtime this process is serving from.
|
||||
|
||||
``declared_mode`` (normally :func:`declared_runtime_mode`) is authoritative
|
||||
when supplied and valid. Otherwise the mode is inferred:
|
||||
|
||||
* no resolvable root, or a root that is not a git checkout → ``unknown``
|
||||
(a packaged deployment must declare its mode explicitly);
|
||||
* a root inside a ``branches/`` worktree → ``dev-test``;
|
||||
* an unreadable branch → ``unknown``;
|
||||
* a stable branch (``master``/``main``/``dev``) → ``stable-control``;
|
||||
* any other branch → ``dev-test``.
|
||||
|
||||
Returns the mode plus the discriminating facts and human-readable reasons.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
dev_worktree = launched_from_dev_worktree(process_root)
|
||||
|
||||
if declared_mode in VALID_RUNTIME_MODES:
|
||||
reasons.append(
|
||||
f"runtime mode declared by operator via {ENV_RUNTIME_MODE}="
|
||||
f"{declared_mode}"
|
||||
)
|
||||
return _mode_result(declared_mode, dev_worktree, True, reasons)
|
||||
|
||||
if not process_root:
|
||||
reasons.append(
|
||||
"runtime process root could not be resolved; runtime mode is "
|
||||
"indeterminate"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)
|
||||
|
||||
if not is_git_checkout:
|
||||
reasons.append(
|
||||
f"runtime process root '{process_root}' is not a git checkout and "
|
||||
f"no {ENV_RUNTIME_MODE} declaration was supplied"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)
|
||||
|
||||
if dev_worktree:
|
||||
reasons.append(
|
||||
f"runtime was launched from development worktree '{process_root}' "
|
||||
f"(inside '{DEV_WORKTREE_SEGMENT}/')"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_DEV_TEST, True, False, reasons)
|
||||
|
||||
branch = (checkout_branch or "").strip()
|
||||
if not branch:
|
||||
reasons.append(
|
||||
f"runtime checkout branch at '{process_root}' could not be read "
|
||||
f"(detached HEAD or unreadable); runtime mode is indeterminate"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_UNKNOWN, False, False, reasons)
|
||||
|
||||
if branch in STABLE_BRANCHES:
|
||||
reasons.append(
|
||||
f"runtime checkout '{process_root}' is on stable branch '{branch}'"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_STABLE, False, False, reasons)
|
||||
|
||||
reasons.append(
|
||||
f"runtime checkout '{process_root}' is on development branch "
|
||||
f"'{branch}', not a stable branch "
|
||||
f"({', '.join(sorted(STABLE_BRANCHES))})"
|
||||
)
|
||||
return _mode_result(RUNTIME_MODE_DEV_TEST, False, False, reasons)
|
||||
|
||||
|
||||
def _mode_result(mode, dev_worktree, declared, reasons) -> dict:
|
||||
return {
|
||||
"runtime_mode": mode,
|
||||
"dev_worktree_launched": bool(dev_worktree),
|
||||
"declared": bool(declared),
|
||||
"reasons": list(reasons),
|
||||
}
|
||||
|
||||
|
||||
def build_runtime_report(
|
||||
*,
|
||||
process_root: str | None,
|
||||
checkout_branch: str | None,
|
||||
runtime_head: str | None,
|
||||
active_task_workspace: str | None = None,
|
||||
canonical_repository_root: str | None = None,
|
||||
repository_slug: str | None = None,
|
||||
profile: str | None = None,
|
||||
authenticated_identity: str | None = None,
|
||||
dirty_files: list[str] | tuple[str, ...] | None = None,
|
||||
workspace_roots_aligned: bool | None = None,
|
||||
is_git_checkout: bool = True,
|
||||
declared_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""Build the ADR-required runtime report (#615 acceptance criterion 6).
|
||||
|
||||
``real_mutations_allowed`` is the summary bit: it is true only when the
|
||||
corresponding mutation gate finds nothing to block on for a production
|
||||
target.
|
||||
"""
|
||||
classification = classify_runtime_mode(
|
||||
process_root=process_root,
|
||||
checkout_branch=checkout_branch,
|
||||
is_git_checkout=is_git_checkout,
|
||||
declared_mode=declared_mode,
|
||||
)
|
||||
report = {
|
||||
"runtime_mode": classification["runtime_mode"],
|
||||
"runtime_mode_declared": classification["declared"],
|
||||
"runtime_mode_reasons": classification["reasons"],
|
||||
"dev_worktree_launched": classification["dev_worktree_launched"],
|
||||
"runtime_git_sha": runtime_head,
|
||||
"runtime_branch": checkout_branch,
|
||||
"runtime_checkout_path": process_root,
|
||||
"mcp_process_root": process_root,
|
||||
"active_task_workspace": active_task_workspace,
|
||||
"canonical_repository_root": canonical_repository_root,
|
||||
"repository_slug": repository_slug,
|
||||
"profile": profile,
|
||||
"authenticated_identity": authenticated_identity,
|
||||
"dirty_files": sorted(dirty_files or []),
|
||||
"workspace_roots_aligned": workspace_roots_aligned,
|
||||
"gate_enforced": not gate_disabled(),
|
||||
}
|
||||
gate = assess_runtime_mutation_gate(report)
|
||||
report["real_mutations_allowed"] = not gate["block"]
|
||||
report["mutation_block_reasons"] = gate["reasons"]
|
||||
return report
|
||||
|
||||
|
||||
def assess_runtime_mutation_gate(
|
||||
report: dict,
|
||||
*,
|
||||
target_is_production: bool = True,
|
||||
namespace: str | None = None,
|
||||
namespace_reproof: dict | None = None,
|
||||
) -> dict:
|
||||
"""Fail-closed mutation gate for the runtime a mutation would execute in.
|
||||
|
||||
Blocks when (acceptance criterion 7):
|
||||
|
||||
* the runtime is ``dev-test`` and the mutation targets the production
|
||||
repository;
|
||||
* the runtime mode is ``unknown``;
|
||||
* the stable runtime checkout is dirty;
|
||||
* the runtime was launched from a development worktree;
|
||||
* process-root / workspace alignment is unsafe;
|
||||
* (criterion 8) the namespace has not been re-proven since a transport flap.
|
||||
|
||||
The disabled escape hatch never blocks; the caller decides read-vs-mutate
|
||||
before calling.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
blockers: list[str] = []
|
||||
|
||||
if gate_disabled():
|
||||
return _gate_result(False, blockers, reasons, disabled=True)
|
||||
|
||||
mode = (report or {}).get("runtime_mode")
|
||||
|
||||
if mode == RUNTIME_MODE_UNKNOWN:
|
||||
blockers.append(BLOCKER_UNKNOWN_RUNTIME)
|
||||
reasons.append(
|
||||
"runtime mode is 'unknown'; a runtime that cannot prove it is the "
|
||||
f"stable control runtime must not mutate production (declare "
|
||||
f"{ENV_RUNTIME_MODE} or run from a stable checkout)"
|
||||
)
|
||||
|
||||
if mode == RUNTIME_MODE_DEV_TEST and target_is_production:
|
||||
blockers.append(BLOCKER_DEV_TEST_PRODUCTION)
|
||||
reasons.append(
|
||||
"runtime mode is 'dev-test' and the mutation targets the "
|
||||
"production repository; dev/test runtimes must not mutate real "
|
||||
"issues or PRs (ADR: stable control runtime vs dev runtime)"
|
||||
)
|
||||
|
||||
if report.get("dev_worktree_launched") and target_is_production:
|
||||
blockers.append(BLOCKER_DEV_WORKTREE_LAUNCH)
|
||||
reasons.append(
|
||||
f"runtime was launched from a '{DEV_WORKTREE_SEGMENT}/' development "
|
||||
f"worktree ('{report.get('mcp_process_root')}'); production "
|
||||
f"mutations require the promoted stable control runtime"
|
||||
)
|
||||
|
||||
dirty = list(report.get("dirty_files") or [])
|
||||
if mode == RUNTIME_MODE_STABLE and dirty:
|
||||
blockers.append(BLOCKER_DIRTY_STABLE_RUNTIME)
|
||||
reasons.append(
|
||||
"stable control runtime checkout is dirty "
|
||||
f"({len(dirty)} file(s): {', '.join(dirty[:5])}"
|
||||
f"{'...' if len(dirty) > 5 else ''}); the control plane must run "
|
||||
"promoted, unmodified code"
|
||||
)
|
||||
|
||||
if report.get("workspace_roots_aligned") is False:
|
||||
blockers.append(BLOCKER_UNSAFE_ALIGNMENT)
|
||||
reasons.append(
|
||||
"process-root / active-workspace alignment is unsafe; the runtime "
|
||||
"and the task workspace disagree about which checkout is being "
|
||||
"mutated"
|
||||
)
|
||||
|
||||
if namespace:
|
||||
reproof = assess_namespace_reproof(namespace_reproof, namespace)
|
||||
if reproof["reproof_required"] and not reproof["proven"]:
|
||||
blockers.append(BLOCKER_NAMESPACE_NOT_REPROVEN)
|
||||
reasons.extend(reproof["reasons"])
|
||||
|
||||
return _gate_result(bool(blockers), blockers, reasons)
|
||||
|
||||
|
||||
def _gate_result(block, blockers, reasons, *, disabled=False) -> dict:
|
||||
return {
|
||||
"block": bool(block),
|
||||
"blocker_kinds": list(blockers),
|
||||
"blocker_kind": blockers[0] if blockers else None,
|
||||
"reasons": list(reasons),
|
||||
"gate_disabled": bool(disabled),
|
||||
}
|
||||
|
||||
|
||||
def runtime_block_reasons(
|
||||
report: dict,
|
||||
*,
|
||||
target_is_production: bool = True,
|
||||
namespace: str | None = None,
|
||||
namespace_reproof: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""Block reasons for a mutation gate (empty when the mutation may proceed)."""
|
||||
gate = assess_runtime_mutation_gate(
|
||||
report,
|
||||
target_is_production=target_is_production,
|
||||
namespace=namespace,
|
||||
namespace_reproof=namespace_reproof,
|
||||
)
|
||||
return gate["reasons"]
|
||||
|
||||
|
||||
def runtime_report_payload(report: dict, gate: dict | None = None) -> dict:
|
||||
"""Structured recovery payload for permission-block responses."""
|
||||
gate = gate or assess_runtime_mutation_gate(report)
|
||||
return {
|
||||
"kind": "runtime_mode_block",
|
||||
"runtime_mode": report.get("runtime_mode"),
|
||||
"runtime_git_sha": report.get("runtime_git_sha"),
|
||||
"runtime_branch": report.get("runtime_branch"),
|
||||
"runtime_checkout_path": report.get("runtime_checkout_path"),
|
||||
"blocker_kind": gate.get("blocker_kind"),
|
||||
"blocker_kinds": list(gate.get("blocker_kinds") or []),
|
||||
"reasons": list(gate.get("reasons") or []),
|
||||
"recovery": [
|
||||
"Real workflow mutations run only on the promoted stable control "
|
||||
"runtime (see docs/architecture/"
|
||||
"mcp-stable-control-runtime-policy-adr.md).",
|
||||
"Operator action: promote the intended revision into the stable "
|
||||
"runtime and reload it — see "
|
||||
"docs/stable-runtime-promotion-runbook.md.",
|
||||
"Normal author/reviewer/merger/reconciler sessions must not kill, "
|
||||
"restart, or relaunch the MCP server themselves.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def format_runtime_mode(report: dict) -> str:
|
||||
"""One-line human summary for logs / runtime context."""
|
||||
mode = report.get("runtime_mode") or RUNTIME_MODE_UNKNOWN
|
||||
sha = report.get("runtime_git_sha")
|
||||
branch = report.get("runtime_branch") or "unknown-branch"
|
||||
short = sha[:12] if sha else "unknown-sha"
|
||||
suffix = (
|
||||
"" if report.get("real_mutations_allowed", True) else " (mutations blocked)"
|
||||
)
|
||||
return f"{mode} at {short} on {branch}{suffix}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-transport-flap namespace re-proving (#615 acceptance criterion 8)
|
||||
#
|
||||
# A transport flap (#584) drops every gitea-* namespace at once. Proving the
|
||||
# author namespace afterwards says nothing about the reviewer, merger, or
|
||||
# reconciler namespace, so proof is tracked per namespace and a flap
|
||||
# invalidates all of them.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REQUIRED_NAMESPACE_PROOF_STEPS = (
|
||||
"whoami",
|
||||
"runtime_context",
|
||||
"capability_resolved",
|
||||
)
|
||||
|
||||
|
||||
def new_reproof_state() -> dict:
|
||||
"""Return an empty post-flap re-proving state."""
|
||||
return {"flap_at": None, "namespaces": {}}
|
||||
|
||||
|
||||
def record_transport_flap(state: dict | None, *, at: str) -> dict:
|
||||
"""Record a transport flap: every namespace must be re-proven after *at*.
|
||||
|
||||
Existing per-namespace proofs are kept for audit but no longer satisfy the
|
||||
gate, because they were recorded before the flap.
|
||||
"""
|
||||
result = dict(state or new_reproof_state())
|
||||
result["flap_at"] = at
|
||||
result["namespaces"] = dict(result.get("namespaces") or {})
|
||||
return result
|
||||
|
||||
|
||||
def record_namespace_proof(
|
||||
state: dict | None,
|
||||
namespace: str,
|
||||
*,
|
||||
at: str,
|
||||
whoami: bool = False,
|
||||
runtime_context: bool = False,
|
||||
capability_resolved: bool = False,
|
||||
stale_runtime_reported: bool = False,
|
||||
) -> dict:
|
||||
"""Record proof steps completed for exactly one namespace.
|
||||
|
||||
A namespace whose proof reported a reconnect/restart/stale-runtime gate is
|
||||
never counted as proven, regardless of which steps ran.
|
||||
"""
|
||||
result = dict(state or new_reproof_state())
|
||||
namespaces = dict(result.get("namespaces") or {})
|
||||
namespaces[(namespace or "").strip()] = {
|
||||
"at": at,
|
||||
"whoami": bool(whoami),
|
||||
"runtime_context": bool(runtime_context),
|
||||
"capability_resolved": bool(capability_resolved),
|
||||
"stale_runtime_reported": bool(stale_runtime_reported),
|
||||
}
|
||||
result["namespaces"] = namespaces
|
||||
return result
|
||||
|
||||
|
||||
def assess_namespace_reproof(state: dict | None, namespace: str) -> dict:
|
||||
"""Whether *namespace* is re-proven after the most recent transport flap.
|
||||
|
||||
``reproof_required`` is false when no flap has been recorded — this gate
|
||||
only speaks to post-flap proof and never invents a requirement.
|
||||
"""
|
||||
ns = (namespace or "").strip()
|
||||
store = state or {}
|
||||
flap_at = store.get("flap_at")
|
||||
reasons: list[str] = []
|
||||
|
||||
if not flap_at:
|
||||
return {
|
||||
"namespace": ns,
|
||||
"reproof_required": False,
|
||||
"proven": True,
|
||||
"flap_at": None,
|
||||
"proof_at": None,
|
||||
"missing_steps": [],
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
entry = (store.get("namespaces") or {}).get(ns)
|
||||
if not entry:
|
||||
reasons.append(
|
||||
f"MCP namespace '{ns}' has not been re-proven since the transport "
|
||||
f"flap at {flap_at}; run whoami, runtime context, and capability "
|
||||
f"resolve for '{ns}' itself (proof of another namespace does not "
|
||||
f"transfer)"
|
||||
)
|
||||
return {
|
||||
"namespace": ns,
|
||||
"reproof_required": True,
|
||||
"proven": False,
|
||||
"flap_at": flap_at,
|
||||
"proof_at": None,
|
||||
"missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
proof_at = entry.get("at")
|
||||
if proof_at is not None and str(proof_at) < str(flap_at):
|
||||
reasons.append(
|
||||
f"MCP namespace '{ns}' proof at {proof_at} predates the transport "
|
||||
f"flap at {flap_at}; re-prove the namespace before mutating"
|
||||
)
|
||||
return {
|
||||
"namespace": ns,
|
||||
"reproof_required": True,
|
||||
"proven": False,
|
||||
"flap_at": flap_at,
|
||||
"proof_at": proof_at,
|
||||
"missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
missing = [step for step in REQUIRED_NAMESPACE_PROOF_STEPS if not entry.get(step)]
|
||||
if missing:
|
||||
reasons.append(
|
||||
f"MCP namespace '{ns}' post-flap proof is incomplete; missing: "
|
||||
f"{', '.join(missing)}"
|
||||
)
|
||||
if entry.get("stale_runtime_reported"):
|
||||
missing = missing or ["stale_runtime_clear"]
|
||||
reasons.append(
|
||||
f"MCP namespace '{ns}' reported a reconnect/restart/stale-runtime "
|
||||
f"gate during re-proving; mutation stays blocked until the "
|
||||
f"namespace reconnects cleanly"
|
||||
)
|
||||
|
||||
return {
|
||||
"namespace": ns,
|
||||
"reproof_required": True,
|
||||
"proven": not missing,
|
||||
"flap_at": flap_at,
|
||||
"proof_at": proof_at,
|
||||
"missing_steps": list(missing),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def unproven_namespaces(
|
||||
state: dict | None, namespaces=WORKFLOW_NAMESPACES
|
||||
) -> list[str]:
|
||||
"""Return the namespaces still requiring post-flap re-proving."""
|
||||
out = []
|
||||
for ns in namespaces:
|
||||
assessment = assess_namespace_reproof(state, ns)
|
||||
if assessment["reproof_required"] and not assessment["proven"]:
|
||||
out.append(ns)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Promotion records (#615 acceptance criterion 4 / 10)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROMOTION_REQUIRED_FIELDS = (
|
||||
"previous_runtime_sha",
|
||||
"promoted_runtime_sha",
|
||||
"source_branch",
|
||||
"source_pr",
|
||||
"restart_method",
|
||||
"health_check_proof",
|
||||
"identity_proof",
|
||||
"profile_proof",
|
||||
"workspace_proof",
|
||||
"mutation_capability_proof",
|
||||
"rollback_instructions",
|
||||
)
|
||||
|
||||
|
||||
def assess_promotion_record(record: dict | None) -> dict:
|
||||
"""Validate an operator promotion record against the ADR checklist.
|
||||
|
||||
A promotion that does not record both the previous and the promoted SHA is
|
||||
not a promotion — it is an undocumented restart.
|
||||
"""
|
||||
data = record or {}
|
||||
missing = [
|
||||
field
|
||||
for field in PROMOTION_REQUIRED_FIELDS
|
||||
if not str(data.get(field) or "").strip()
|
||||
]
|
||||
reasons = []
|
||||
if missing:
|
||||
reasons.append(
|
||||
"promotion record is incomplete; missing: " + ", ".join(missing)
|
||||
)
|
||||
previous = str(data.get("previous_runtime_sha") or "").strip()
|
||||
promoted = str(data.get("promoted_runtime_sha") or "").strip()
|
||||
if previous and promoted and previous == promoted:
|
||||
reasons.append(
|
||||
"promotion record lists the same previous and promoted SHA "
|
||||
f"({previous[:12]}); nothing was promoted"
|
||||
)
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"missing_fields": missing,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filesystem observation (the only impure helper)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _git_capture(root: str, *args: str) -> str | None:
|
||||
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 observe_dirty_files(process_root: str | None) -> list[str]:
|
||||
"""Read the live dirty-file list at *process_root*.
|
||||
|
||||
Split out from :func:`observe_runtime` because dirtiness is the one runtime
|
||||
fact that legitimately changes during a process lifetime. The mutation gate
|
||||
must re-read it per call rather than trust a startup snapshot, or a checkout
|
||||
that goes dirty after the snapshot is never blocked again (#615).
|
||||
"""
|
||||
if not process_root:
|
||||
return []
|
||||
porcelain = _git_capture(process_root, "status", "--porcelain") or ""
|
||||
return [line[3:].strip() for line in porcelain.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def observe_runtime(process_root: str | None) -> dict:
|
||||
"""Read the runtime facts classification needs from *process_root*.
|
||||
|
||||
Returns ``checkout_branch``, ``runtime_head``, ``is_git_checkout``, and
|
||||
``dirty_files``. Every read failure degrades to ``None``/empty rather than
|
||||
raising, so a runtime that cannot be inspected classifies as ``unknown``
|
||||
instead of crashing the caller.
|
||||
"""
|
||||
empty = {
|
||||
"checkout_branch": None,
|
||||
"runtime_head": None,
|
||||
"is_git_checkout": False,
|
||||
"dirty_files": [],
|
||||
}
|
||||
if not process_root:
|
||||
return empty
|
||||
if not _git_capture(process_root, "rev-parse", "--show-toplevel"):
|
||||
return empty
|
||||
branch = _git_capture(process_root, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if branch == "HEAD": # detached HEAD has no branch name
|
||||
branch = None
|
||||
dirty = observe_dirty_files(process_root)
|
||||
return {
|
||||
"checkout_branch": branch,
|
||||
"runtime_head": _git_capture(process_root, "rev-parse", "HEAD"),
|
||||
"is_git_checkout": True,
|
||||
"dirty_files": dirty,
|
||||
}
|
||||
+119
-1
@@ -36,6 +36,20 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
# #781: editing an issue title/body is issue authoring, the same authority
|
||||
# every other non-create/non-close issue mutation gates on. Deliberately not
|
||||
# a new operation name: introducing one would silently strip the capability
|
||||
# from every already-configured author profile.
|
||||
"edit_issue": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
# #780: retire status:pr-open after a terminal PR transition. Same label
|
||||
# authority as set_issue_labels — it is a strictly narrower operation.
|
||||
"cleanup_terminal_pr_labels": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
"create_label": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
@@ -137,6 +151,17 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
# #742: owner-session terminal release/abandon of a merger-held lease when
|
||||
# the merge does not occur. Apply path posts an append-only terminal lease
|
||||
# marker (gitea.pr.comment); merger-only, never a reviewer path.
|
||||
"release_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
"gitea_release_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
||||
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
||||
"cleanup_obsolete_reviewer_comment_lease": {
|
||||
@@ -147,6 +172,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.comment",
|
||||
"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": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
@@ -221,9 +263,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.merge",
|
||||
"role": "merger",
|
||||
},
|
||||
# #729: delete_branch is reconciler-owned. gitea.branch.delete is granted
|
||||
# only to the reconciler profile, so the resolver must classify this task as
|
||||
# reconciler (previously "author", which no delete-capable profile held).
|
||||
# Raw gitea_delete_branch still redirects reconciler to the guarded
|
||||
# gitea_cleanup_merged_pr_branch path (#514/#687); author/reviewer/merger
|
||||
# stay denied by both the permission gate and this role gate.
|
||||
"delete_branch": {
|
||||
"permission": "gitea.branch.delete",
|
||||
"role": "author",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"cleanup_merged_pr_branch": {
|
||||
"permission": "gitea.branch.delete",
|
||||
@@ -398,13 +446,83 @@ 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
|
||||
|
||||
|
||||
# Tasks for which permission alone is insufficient: the active/configured
|
||||
# profile's declared role must also match the task role. This is the complete
|
||||
# resolver set from master at the #723 reconstruction point, shared with
|
||||
# runtime reporting so those two authorities cannot drift again.
|
||||
ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
|
||||
{
|
||||
"acquire_reviewer_pr_lease",
|
||||
"gitea_acquire_reviewer_pr_lease",
|
||||
"review_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"merge_pr",
|
||||
"acquire_merger_pr_lease",
|
||||
"gitea_acquire_merger_pr_lease",
|
||||
"adopt_merger_pr_lease",
|
||||
"gitea_adopt_merger_pr_lease",
|
||||
"release_merger_pr_lease",
|
||||
"gitea_release_merger_pr_lease",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
"update_pr_branch_by_merge",
|
||||
"gitea_update_pr_branch_by_merge",
|
||||
"delete_branch",
|
||||
"cleanup_merged_pr_branch",
|
||||
"reconciliation_cleanup",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
}
|
||||
)
|
||||
|
||||
# Issue-mutating MCP tools and their resolver task keys.
|
||||
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
||||
"gitea_create_issue": "create_issue",
|
||||
"gitea_close_issue": "close_issue",
|
||||
"gitea_edit_issue": "edit_issue",
|
||||
"gitea_create_issue_comment": "comment_issue",
|
||||
"gitea_mark_issue": "mark_issue",
|
||||
"gitea_set_issue_labels": "set_issue_labels",
|
||||
"gitea_cleanup_terminal_pr_labels": "cleanup_terminal_pr_labels",
|
||||
"gitea_create_label": "create_label",
|
||||
"gitea_commit_files": "commit_files",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Authoritative terminal-transition cleanup for ``status:pr-open`` (#780).
|
||||
|
||||
``status:pr-open`` is applied by ``gitea_create_pr`` while a linked pull
|
||||
request is open. Nothing removed it again: the workflow's terminal paths
|
||||
(merge, close-without-merge, supersession, already-landed reconciliation,
|
||||
controller closure) each ended without touching the label, so a repository
|
||||
audit found 40 closed issues still carrying it.
|
||||
|
||||
This module is the single source of truth for that cleanup. Every sanctioned
|
||||
terminal path plans its label mutation here rather than implementing its own
|
||||
rule, so the paths cannot drift apart:
|
||||
|
||||
- :func:`plan_pr_open_cleanup` decides the exact resulting label set. It only
|
||||
ever removes ``status:pr-open``; every other label is preserved verbatim,
|
||||
including the case where the result is an empty label set.
|
||||
- :func:`verify_pr_open_cleanup` is the read-after-write check. It proves the
|
||||
label is gone *and* that no unrelated label was dropped or added.
|
||||
- :func:`detect_residual_pr_open` is the terminal validation: given issues, it
|
||||
reports any that still carry the label, so a controller closure or audit
|
||||
fails loudly instead of leaving the leak behind.
|
||||
|
||||
The rule is idempotent by construction: an issue without the label plans no
|
||||
mutation, so retries and recovery re-runs are harmless.
|
||||
|
||||
This module performs no I/O — callers own the Gitea API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
import issue_workflow_labels
|
||||
|
||||
#: The single label this module is responsible for retiring.
|
||||
PR_OPEN_LABEL = "status:pr-open"
|
||||
|
||||
# Canonical terminal reasons — the sanctioned ways an issue can end up
|
||||
# associated with a pull request that is no longer open.
|
||||
MERGED = "merged"
|
||||
CLOSED_WITHOUT_MERGE = "closed_without_merge"
|
||||
SUPERSEDED = "superseded"
|
||||
ALREADY_LANDED = "already_landed"
|
||||
CONTROLLER_CLOSURE = "controller_closure"
|
||||
ABANDONED = "abandoned"
|
||||
RETRY_RECOVERY = "retry_recovery"
|
||||
|
||||
TERMINAL_REASONS: tuple[str, ...] = (
|
||||
MERGED,
|
||||
CLOSED_WITHOUT_MERGE,
|
||||
SUPERSEDED,
|
||||
ALREADY_LANDED,
|
||||
CONTROLLER_CLOSURE,
|
||||
ABANDONED,
|
||||
RETRY_RECOVERY,
|
||||
)
|
||||
|
||||
_REASON_ALIASES: dict[str, str] = {
|
||||
"merge": MERGED,
|
||||
"merged": MERGED,
|
||||
"pr_merged": MERGED,
|
||||
"closed": CLOSED_WITHOUT_MERGE,
|
||||
"close": CLOSED_WITHOUT_MERGE,
|
||||
"closed_without_merge": CLOSED_WITHOUT_MERGE,
|
||||
"pr_closed": CLOSED_WITHOUT_MERGE,
|
||||
"supersede": SUPERSEDED,
|
||||
"superseded": SUPERSEDED,
|
||||
"supersession": SUPERSEDED,
|
||||
"already_landed": ALREADY_LANDED,
|
||||
"reconcile_already_landed": ALREADY_LANDED,
|
||||
"controller_closure": CONTROLLER_CLOSURE,
|
||||
"close_issue": CONTROLLER_CLOSURE,
|
||||
"abandon": ABANDONED,
|
||||
"abandoned": ABANDONED,
|
||||
"retry": RETRY_RECOVERY,
|
||||
"recovery": RETRY_RECOVERY,
|
||||
"retry_recovery": RETRY_RECOVERY,
|
||||
}
|
||||
|
||||
#: Human-readable phrasing used in audit comments and diagnostics.
|
||||
REASON_DESCRIPTIONS: dict[str, str] = {
|
||||
MERGED: "the linked PR was merged",
|
||||
CLOSED_WITHOUT_MERGE: "the linked PR was closed without merging",
|
||||
SUPERSEDED: "the linked PR was superseded by another merged PR",
|
||||
ALREADY_LANDED: "the linked PR's change was already on the target branch",
|
||||
CONTROLLER_CLOSURE: "the issue reached controller closure",
|
||||
ABANDONED: "the linked PR was abandoned",
|
||||
RETRY_RECOVERY: "a partial terminal transition is being recovered",
|
||||
}
|
||||
|
||||
|
||||
def canonical_terminal_reason(reason: str | None) -> str:
|
||||
"""Normalize a terminal reason, failing closed on anything unrecognized."""
|
||||
name = (reason or "").strip()
|
||||
if name in TERMINAL_REASONS:
|
||||
return name
|
||||
normalized = name.lower().replace("-", "_").replace(" ", "_")
|
||||
try:
|
||||
return _REASON_ALIASES[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"unknown terminal PR reason '{reason}' (expected one of: "
|
||||
+ ", ".join(TERMINAL_REASONS)
|
||||
+ ")"
|
||||
) from exc
|
||||
|
||||
|
||||
def plan_pr_open_cleanup(
|
||||
current_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
*,
|
||||
terminal_reason: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Plan the label set an issue must carry after a terminal PR transition.
|
||||
|
||||
The plan removes ``status:pr-open`` and nothing else. When the label is
|
||||
absent the plan is an explicit no-op (``cleanup_required`` False), which is
|
||||
what makes repeated cleanup calls harmless. When it was the only label the
|
||||
resulting set is legitimately empty.
|
||||
"""
|
||||
reason = canonical_terminal_reason(terminal_reason)
|
||||
before = issue_workflow_labels.label_names(current_labels)
|
||||
after = [name for name in before if name != PR_OPEN_LABEL]
|
||||
present = len(after) != len(before)
|
||||
return {
|
||||
"terminal_reason": reason,
|
||||
"terminal_reason_description": REASON_DESCRIPTIONS[reason],
|
||||
"label": PR_OPEN_LABEL,
|
||||
"label_present": present,
|
||||
"cleanup_required": present,
|
||||
"idempotent_noop": not present,
|
||||
"labels_before": before,
|
||||
"labels_after": after,
|
||||
"removed": [PR_OPEN_LABEL] if present else [],
|
||||
"preserved": list(after),
|
||||
"empty_label_set": not after,
|
||||
}
|
||||
|
||||
|
||||
def verify_pr_open_cleanup(
|
||||
observed_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
*,
|
||||
plan: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Read-after-write check for a planned cleanup.
|
||||
|
||||
Verifies the label is gone and that the observed set matches the plan
|
||||
exactly, so an unrelated label silently dropped (or re-added) by the API is
|
||||
reported rather than accepted.
|
||||
"""
|
||||
observed = issue_workflow_labels.label_names(observed_labels)
|
||||
expected = list(plan.get("labels_after") or [])
|
||||
observed_set = set(observed)
|
||||
expected_set = set(expected)
|
||||
residual = PR_OPEN_LABEL in observed_set
|
||||
unexpected_removals = sorted(expected_set - observed_set)
|
||||
unexpected_additions = sorted(observed_set - expected_set - {PR_OPEN_LABEL})
|
||||
|
||||
reasons: list[str] = []
|
||||
if residual:
|
||||
reasons.append(
|
||||
f"'{PR_OPEN_LABEL}' is still present after terminal cleanup"
|
||||
)
|
||||
if unexpected_removals:
|
||||
reasons.append(
|
||||
"unrelated labels were dropped by the cleanup: "
|
||||
+ ", ".join(unexpected_removals)
|
||||
)
|
||||
if unexpected_additions:
|
||||
reasons.append(
|
||||
"unexpected labels appeared during the cleanup: "
|
||||
+ ", ".join(unexpected_additions)
|
||||
)
|
||||
|
||||
verified = not reasons
|
||||
return {
|
||||
"verified": verified,
|
||||
"residual": residual,
|
||||
"observed_labels": observed,
|
||||
"expected_labels": expected,
|
||||
"unexpected_removals": unexpected_removals,
|
||||
"unexpected_additions": unexpected_additions,
|
||||
"empty_label_set": not observed,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if verified
|
||||
else (
|
||||
"Re-run the terminal cleanup for this issue with "
|
||||
f"terminal_reason='{RETRY_RECOVERY}' and confirm the read-back "
|
||||
f"no longer reports '{PR_OPEN_LABEL}'."
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def summarize_cleanup_results(
|
||||
results: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
terminal_reason: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate per-issue cleanup outcomes into one reportable record."""
|
||||
reason = canonical_terminal_reason(terminal_reason)
|
||||
entries = [dict(entry) for entry in results]
|
||||
removed = [e.get("issue_number") for e in entries if e.get("status") == "removed"]
|
||||
absent = [
|
||||
e.get("issue_number") for e in entries if e.get("status") == "not present"
|
||||
]
|
||||
failed = [
|
||||
e.get("issue_number")
|
||||
for e in entries
|
||||
if e.get("status") not in ("removed", "not present") or not e.get("verified")
|
||||
]
|
||||
reasons: list[str] = []
|
||||
for entry in entries:
|
||||
for text in entry.get("reasons") or []:
|
||||
reasons.append(f"issue #{entry.get('issue_number')}: {text}")
|
||||
clean = not failed
|
||||
return {
|
||||
"label": PR_OPEN_LABEL,
|
||||
"terminal_reason": reason,
|
||||
"clean": clean,
|
||||
"checked": [e.get("issue_number") for e in entries],
|
||||
"removed": removed,
|
||||
"already_absent": absent,
|
||||
"failed": failed,
|
||||
"results": entries,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if clean
|
||||
else (
|
||||
"Terminal label cleanup did not complete for "
|
||||
+ ", ".join(f"#{num}" for num in failed)
|
||||
+ ". Re-run gitea_cleanup_terminal_pr_labels with "
|
||||
f"terminal_reason='{RETRY_RECOVERY}' for those issues."
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def detect_residual_pr_open(
|
||||
issues: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
open_pr_issue_numbers: Iterable[int] = (),
|
||||
) -> dict[str, Any]:
|
||||
"""Terminal validation: report issues still carrying ``status:pr-open``.
|
||||
|
||||
An issue with a genuinely open pull request is allowed to keep the label,
|
||||
so *open_pr_issue_numbers* is excluded from the residual set rather than
|
||||
being reported as a leak.
|
||||
"""
|
||||
legitimate: set[int] = set()
|
||||
for num in open_pr_issue_numbers or ():
|
||||
try:
|
||||
legitimate.add(int(num))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
checked = 0
|
||||
residual: list[dict[str, Any]] = []
|
||||
exempt: list[int] = []
|
||||
|
||||
for issue in issues or []:
|
||||
checked += 1
|
||||
names = issue_workflow_labels.label_names(issue)
|
||||
if PR_OPEN_LABEL not in names:
|
||||
continue
|
||||
try:
|
||||
number = int(issue.get("number"))
|
||||
except (TypeError, ValueError):
|
||||
number = None
|
||||
if number is not None and number in legitimate:
|
||||
exempt.append(number)
|
||||
continue
|
||||
residual.append(
|
||||
{
|
||||
"number": number,
|
||||
"state": issue.get("state"),
|
||||
"labels": names,
|
||||
}
|
||||
)
|
||||
|
||||
clean = not residual
|
||||
reasons = [
|
||||
(
|
||||
f"issue #{entry['number']} ({entry.get('state') or 'unknown state'}) "
|
||||
f"still carries '{PR_OPEN_LABEL}' with no open PR"
|
||||
)
|
||||
for entry in residual
|
||||
]
|
||||
return {
|
||||
"label": PR_OPEN_LABEL,
|
||||
"clean": clean,
|
||||
"checked_count": checked,
|
||||
"residual_count": len(residual),
|
||||
"residual_issues": residual,
|
||||
"exempt_open_pr_issues": sorted(exempt),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
""
|
||||
if clean
|
||||
else (
|
||||
"Run gitea_cleanup_terminal_pr_labels with "
|
||||
f"terminal_reason='{RETRY_RECOVERY}' for issues "
|
||||
+ ", ".join(f"#{entry['number']}" for entry in residual)
|
||||
+ " before declaring the terminal transition complete."
|
||||
)
|
||||
),
|
||||
}
|
||||
+63
-17
@@ -26,6 +26,14 @@ def _reset_mutation_authority(monkeypatch):
|
||||
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||
so durable load/save never touches host state even after env clears.
|
||||
"""
|
||||
import session_context_binding as session_ctx
|
||||
|
||||
# Each pytest item is an independent logical MCP session. Reset both before
|
||||
# and after the item; the post-yield reset is in a finally block so an
|
||||
# assertion, exception, or unittest teardown failure cannot pollute the
|
||||
# next item. Production code has no automatic per-call reset path.
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
for env_key in [
|
||||
"GITEA_SESSION_PROFILE_LOCK",
|
||||
"GITEA_ACTIVE_WORKTREE",
|
||||
@@ -80,11 +88,31 @@ def _reset_mutation_authority(monkeypatch):
|
||||
try:
|
||||
import mcp_server
|
||||
except Exception:
|
||||
_state_tmp.cleanup()
|
||||
yield
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
_state_tmp.cleanup()
|
||||
return
|
||||
import gitea_config
|
||||
|
||||
monkeypatch.setattr(gitea_config, "_active_profile_override", None)
|
||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||
# #714: clear both module namespaces. mcp_server.py execs gitea_mcp_server.py
|
||||
# into its own globals, so `import gitea_mcp_server` is a separate module
|
||||
# object with its own identity caches; leaving it dirty leaks login across
|
||||
# tests that import the implementation module directly.
|
||||
try:
|
||||
import gitea_mcp_server as _gitea_impl
|
||||
|
||||
monkeypatch.setattr(_gitea_impl, "_IDENTITY_CACHE", {})
|
||||
if hasattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE"):
|
||||
monkeypatch.setattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE", {})
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(mcp_server, "_ACTOR_IDENTITY_CACHE"):
|
||||
monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {})
|
||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
|
||||
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
||||
@@ -113,19 +141,37 @@ def _reset_mutation_authority(monkeypatch):
|
||||
capability_stop_terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
import capability_stop_terminal
|
||||
capability_stop_terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import review_workflow_load
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
except Exception:
|
||||
pass
|
||||
gitea_config._active_profile_override = None
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
_state_tmp.cleanup()
|
||||
|
||||
|
||||
# #714: deterministic workspace remotes only (no host git dependency).
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _deterministic_workspace_remotes():
|
||||
try:
|
||||
from mutation_profile_fixture import install_deterministic_remote_urls
|
||||
install_deterministic_remote_urls()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
import capability_stop_terminal
|
||||
capability_stop_terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import review_workflow_load
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
except Exception:
|
||||
pass
|
||||
_state_tmp.cleanup()
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Centralized config-backed mutation fixture for #714.
|
||||
|
||||
Mutation tests must opt into this helper explicitly. It never grants
|
||||
mutation authority via environment variables alone.
|
||||
|
||||
Design rules:
|
||||
- temporary v2 configuration with non-empty ``allowed_repositories``
|
||||
- profile-scoped operations (not every mutation op for every test)
|
||||
- deterministic workspace remotes (no host Git dependency)
|
||||
- one canonical repository per profile by default
|
||||
- isolated state + cleanup in ``finally``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from typing import Iterable, Sequence
|
||||
from unittest.mock import patch
|
||||
|
||||
PRGS_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||
PRGS_URL = f"https://gitea.prgs.cc/{PRGS_SLUG}.git"
|
||||
MDCPS_SLUG = "913443/eAgenda"
|
||||
MDCPS_URL = f"https://gitea.dadeschools.net/{MDCPS_SLUG}.git"
|
||||
EXAMPLE_SLUG = "Example-Org/Example-Repo"
|
||||
EXAMPLE_URL = f"https://gitea.example.com/{EXAMPLE_SLUG}.git"
|
||||
TIMESHEET_SLUG = "Scaled-Tech-Consulting/Timesheet"
|
||||
|
||||
|
||||
def _author_ops() -> list[str]:
|
||||
return [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.close",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
|
||||
|
||||
def _author_forbidden() -> list[str]:
|
||||
return [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.review",
|
||||
]
|
||||
|
||||
|
||||
def _reviewer_ops() -> list[str]:
|
||||
return [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
]
|
||||
|
||||
|
||||
def _merger_ops() -> list[str]:
|
||||
return [
|
||||
"gitea.read",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.comment",
|
||||
]
|
||||
|
||||
|
||||
def _profile(
|
||||
*,
|
||||
name: str,
|
||||
context: str,
|
||||
role: str,
|
||||
base_url: str,
|
||||
auth_env: str,
|
||||
allowed_operations: Sequence[str],
|
||||
forbidden_operations: Sequence[str],
|
||||
allowed_repositories: Sequence[str],
|
||||
username: str | None = None,
|
||||
) -> dict:
|
||||
body = {
|
||||
"enabled": True,
|
||||
"context": context,
|
||||
"role": role,
|
||||
"base_url": base_url,
|
||||
"auth": {"type": "env", "name": auth_env},
|
||||
"allowed_operations": list(allowed_operations),
|
||||
"forbidden_operations": list(forbidden_operations),
|
||||
"allowed_repositories": list(allowed_repositories),
|
||||
"execution_profile": name,
|
||||
}
|
||||
if username:
|
||||
body["username"] = username
|
||||
return body
|
||||
|
||||
|
||||
def build_dual_remote_config(
|
||||
*,
|
||||
allowed_operations: Iterable[str] | None = None,
|
||||
allowed_repositories_prgs: Sequence[str] | None = None,
|
||||
allowed_repositories_mdcps: Sequence[str] | None = None,
|
||||
extra_profiles: dict | None = None,
|
||||
include_example_repo: bool = False,
|
||||
) -> dict:
|
||||
"""Build a minimal dual-host v2 config.
|
||||
|
||||
Each profile authorizes only its host-canonical repository by default.
|
||||
``include_example_repo`` adds Example-Org/Example-Repo when a test patches
|
||||
REMOTES to that synthetic unit-test identity.
|
||||
"""
|
||||
ops = list(allowed_operations or _author_ops())
|
||||
forb = _author_forbidden()
|
||||
prgs_repos = list(allowed_repositories_prgs or [PRGS_SLUG])
|
||||
mdcps_repos = list(allowed_repositories_mdcps or [MDCPS_SLUG])
|
||||
if include_example_repo:
|
||||
for repos in (prgs_repos, mdcps_repos):
|
||||
if EXAMPLE_SLUG not in repos:
|
||||
repos.append(EXAMPLE_SLUG)
|
||||
|
||||
profiles = {
|
||||
"test-author-prgs": _profile(
|
||||
name="test-author-prgs",
|
||||
context="prgs",
|
||||
role="author",
|
||||
base_url="https://gitea.prgs.cc",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=ops,
|
||||
forbidden_operations=forb,
|
||||
allowed_repositories=prgs_repos,
|
||||
),
|
||||
"test-author-dadeschools": _profile(
|
||||
name="test-author-dadeschools",
|
||||
context="mdcps",
|
||||
role="author",
|
||||
base_url="https://gitea.dadeschools.net",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=ops,
|
||||
forbidden_operations=forb,
|
||||
allowed_repositories=mdcps_repos,
|
||||
),
|
||||
"test-reviewer-prgs": _profile(
|
||||
name="test-reviewer-prgs",
|
||||
context="prgs",
|
||||
role="reviewer",
|
||||
base_url="https://gitea.prgs.cc",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=_reviewer_ops(),
|
||||
forbidden_operations=[
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.merge",
|
||||
"gitea.issue.create",
|
||||
],
|
||||
allowed_repositories=prgs_repos,
|
||||
),
|
||||
"test-merger-prgs": _profile(
|
||||
name="test-merger-prgs",
|
||||
context="prgs",
|
||||
role="merger",
|
||||
base_url="https://gitea.prgs.cc",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=_merger_ops(),
|
||||
forbidden_operations=[
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.issue.create",
|
||||
],
|
||||
allowed_repositories=prgs_repos,
|
||||
),
|
||||
}
|
||||
if extra_profiles:
|
||||
profiles.update(deepcopy(extra_profiles))
|
||||
|
||||
# Legacy aliases used by older tests — same host scope as the base profile.
|
||||
for alias, base in (
|
||||
("gitea-author", "test-author-prgs"),
|
||||
("author-test", "test-author-dadeschools"),
|
||||
("author", "test-author-dadeschools"),
|
||||
("full-author", "test-author-dadeschools"),
|
||||
("test-author", "test-author-dadeschools"),
|
||||
("prgs-author", "test-author-prgs"),
|
||||
("gitea-reviewer", "test-reviewer-prgs"),
|
||||
("prgs-reviewer", "test-reviewer-prgs"),
|
||||
("gitea-merger", "test-merger-prgs"),
|
||||
("prgs-merger", "test-merger-prgs"),
|
||||
):
|
||||
if alias not in profiles and base in profiles:
|
||||
clone = deepcopy(profiles[base])
|
||||
clone["execution_profile"] = alias
|
||||
profiles[alias] = clone
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def build_v2_config(**kwargs):
|
||||
"""Back-compat alias."""
|
||||
return build_dual_remote_config(
|
||||
allowed_operations=kwargs.get("allowed_operations"),
|
||||
extra_profiles=kwargs.get("extra_profiles"),
|
||||
include_example_repo=bool(kwargs.get("include_example_repo")),
|
||||
)
|
||||
|
||||
|
||||
def inject_allowed_repositories(
|
||||
config: dict,
|
||||
repos: Sequence[str],
|
||||
*,
|
||||
profiles: Sequence[str] | None = None,
|
||||
) -> dict:
|
||||
"""Return a deep copy of *config* with allowlists set on selected profiles."""
|
||||
out = deepcopy(config)
|
||||
targets = set(profiles) if profiles is not None else set(out.get("profiles") or {})
|
||||
for name, prof in (out.get("profiles") or {}).items():
|
||||
if name in targets:
|
||||
prof["allowed_repositories"] = list(repos)
|
||||
return out
|
||||
|
||||
|
||||
def ensure_all_profiles_have_scope(
|
||||
config: dict,
|
||||
default_repos: Sequence[str],
|
||||
) -> dict:
|
||||
"""Add non-empty allowed_repositories to every profile that lacks one."""
|
||||
out = deepcopy(config)
|
||||
for _name, prof in (out.get("profiles") or {}).items():
|
||||
raw = prof.get("allowed_repositories")
|
||||
if not isinstance(raw, (list, tuple)) or not raw:
|
||||
prof["allowed_repositories"] = list(default_repos)
|
||||
return out
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mutation_profile_env(
|
||||
*,
|
||||
profile_name: str = "test-author-dadeschools",
|
||||
allowed_operations: Iterable[str] | None = None,
|
||||
allowed_repositories: Sequence[str] | None = None,
|
||||
workspace_urls: dict | None = None,
|
||||
clear_env: bool = False,
|
||||
extra_env: dict | None = None,
|
||||
extra_profiles: dict | None = None,
|
||||
include_example_repo: bool = False,
|
||||
config: dict | None = None,
|
||||
):
|
||||
"""Context manager: config-backed mutation authority + deterministic remotes."""
|
||||
import gitea_config
|
||||
import gitea_mcp_server as srv
|
||||
import session_context_binding as session_ctx
|
||||
|
||||
if config is None:
|
||||
prgs_repos = None
|
||||
mdcps_repos = None
|
||||
if allowed_repositories is not None:
|
||||
# Caller-specified single allowlist applied to both host profiles.
|
||||
prgs_repos = list(allowed_repositories)
|
||||
mdcps_repos = list(allowed_repositories)
|
||||
cfg = build_dual_remote_config(
|
||||
allowed_operations=allowed_operations,
|
||||
allowed_repositories_prgs=prgs_repos,
|
||||
allowed_repositories_mdcps=mdcps_repos,
|
||||
extra_profiles=extra_profiles,
|
||||
include_example_repo=include_example_repo,
|
||||
)
|
||||
else:
|
||||
cfg = deepcopy(config)
|
||||
if allowed_repositories is not None:
|
||||
cfg = ensure_all_profiles_have_scope(cfg, list(allowed_repositories))
|
||||
|
||||
urls = (
|
||||
{"prgs": PRGS_URL, "dadeschools": MDCPS_URL}
|
||||
if workspace_urls is None
|
||||
else dict(workspace_urls)
|
||||
)
|
||||
|
||||
tmp = tempfile.TemporaryDirectory(prefix="gitea-mut-cfg-")
|
||||
try:
|
||||
cfg_path = os.path.join(tmp.name, "profiles.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(cfg, fh)
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": cfg_path,
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
"GITEA_TOKEN_TEST": "test-token",
|
||||
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||
),
|
||||
}
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
||||
def _remote_url(name: str):
|
||||
if name in urls:
|
||||
return urls[name]
|
||||
# Prefer live REMOTES (tests often patch Example-Org).
|
||||
try:
|
||||
entry = srv.REMOTES.get(name) or {}
|
||||
host = entry.get("host")
|
||||
org = entry.get("org")
|
||||
repo = entry.get("repo")
|
||||
if host and org and repo:
|
||||
if (
|
||||
name == "prgs"
|
||||
and org == "Scaled-Tech-Consulting"
|
||||
and repo == "Timesheet"
|
||||
):
|
||||
return PRGS_URL
|
||||
if name == "dadeschools" and repo == "Timesheet":
|
||||
return MDCPS_URL
|
||||
return f"https://{host}/{org}/{repo}.git"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
gitea_config._active_profile_override = None
|
||||
try:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
payload = {
|
||||
"env": env,
|
||||
"config_path": cfg_path,
|
||||
"profile_name": profile_name,
|
||||
"urls": urls,
|
||||
"config": cfg,
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=clear_env):
|
||||
os.environ.setdefault(
|
||||
"PYTEST_CURRENT_TEST",
|
||||
env.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||
)
|
||||
with patch.object(srv, "_local_git_remote_url", side_effect=_remote_url):
|
||||
mcp_srv = None
|
||||
try:
|
||||
import mcp_server as mcp_srv # type: ignore
|
||||
except Exception:
|
||||
mcp_srv = None
|
||||
if mcp_srv is not None:
|
||||
with patch.object(
|
||||
mcp_srv, "_local_git_remote_url", side_effect=_remote_url
|
||||
):
|
||||
yield payload
|
||||
else:
|
||||
yield payload
|
||||
finally:
|
||||
try:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
except Exception:
|
||||
pass
|
||||
gitea_config._active_profile_override = None
|
||||
tmp.cleanup()
|
||||
|
||||
|
||||
# Process-lifetime shared config for mass-migrating env-only mutation tests.
|
||||
_SHARED_CFG_PATH = None
|
||||
_SHARED_CFG_DIR = None
|
||||
_SHARED_CFG_WITH_EXAMPLE_PATH = None
|
||||
|
||||
|
||||
def shared_mutation_config_path(*, include_example_repo: bool = False) -> str:
|
||||
"""Write (once) a dual-remote mutation config and return its path."""
|
||||
global _SHARED_CFG_PATH, _SHARED_CFG_DIR, _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||
import atexit
|
||||
import shutil
|
||||
|
||||
if include_example_repo:
|
||||
if _SHARED_CFG_WITH_EXAMPLE_PATH and os.path.isfile(
|
||||
_SHARED_CFG_WITH_EXAMPLE_PATH
|
||||
):
|
||||
return _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||
if _SHARED_CFG_DIR is None:
|
||||
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||
path = os.path.join(_SHARED_CFG_DIR, "profiles-with-example.json")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(build_dual_remote_config(include_example_repo=True), fh)
|
||||
_SHARED_CFG_WITH_EXAMPLE_PATH = path
|
||||
return path
|
||||
|
||||
if _SHARED_CFG_PATH and os.path.isfile(_SHARED_CFG_PATH):
|
||||
return _SHARED_CFG_PATH
|
||||
if _SHARED_CFG_DIR is None:
|
||||
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||
_SHARED_CFG_PATH = os.path.join(_SHARED_CFG_DIR, "profiles.json")
|
||||
with open(_SHARED_CFG_PATH, "w", encoding="utf-8") as fh:
|
||||
json.dump(build_dual_remote_config(), fh)
|
||||
return _SHARED_CFG_PATH
|
||||
|
||||
|
||||
def shared_mutation_env(
|
||||
profile_name: str = "test-author-dadeschools",
|
||||
*,
|
||||
include_example_repo: bool = False,
|
||||
**extra,
|
||||
) -> dict:
|
||||
"""Env dict for mutation tests: config-backed + optional extras.
|
||||
|
||||
Always carries ``PYTEST_CURRENT_TEST`` so ``patch.dict(..., clear=True)``
|
||||
does not strip the pytest marker and trip the #695 native-transport wall.
|
||||
"""
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": shared_mutation_config_path(
|
||||
include_example_repo=include_example_repo
|
||||
),
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
"GITEA_TOKEN_TEST": "test-token",
|
||||
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||
),
|
||||
}
|
||||
env.update(extra)
|
||||
# Callers must not be able to drop the pytest marker by accident.
|
||||
env.setdefault(
|
||||
"PYTEST_CURRENT_TEST",
|
||||
os.environ.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def install_deterministic_remote_urls() -> None:
|
||||
"""Patch server modules so workspace remotes are deterministic.
|
||||
|
||||
Prefer live ``REMOTES`` entries (so tests that patch Example-Org keep
|
||||
workspace alignment). Map the historical prgs→Timesheet default to
|
||||
Gitea-Tools so session binding matches the control repository under test.
|
||||
"""
|
||||
import gitea_mcp_server as srv
|
||||
|
||||
def _url(name: str):
|
||||
try:
|
||||
entry = srv.REMOTES.get(name) or {}
|
||||
host = entry.get("host")
|
||||
org = entry.get("org")
|
||||
repo = entry.get("repo")
|
||||
if host and org and repo:
|
||||
if (
|
||||
name == "prgs"
|
||||
and org == "Scaled-Tech-Consulting"
|
||||
and repo == "Timesheet"
|
||||
):
|
||||
return PRGS_URL
|
||||
if name == "dadeschools" and repo == "Timesheet":
|
||||
# Prefer mdcps eAgenda canonical for dual-config tests.
|
||||
return MDCPS_URL
|
||||
return f"https://{host}/{org}/{repo}.git"
|
||||
except Exception:
|
||||
pass
|
||||
if name == "prgs":
|
||||
return PRGS_URL
|
||||
if name == "dadeschools":
|
||||
return MDCPS_URL
|
||||
return None
|
||||
|
||||
srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||
try:
|
||||
import mcp_server as mcp_srv
|
||||
|
||||
mcp_srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,3 +1,7 @@
|
||||
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
|
||||
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
|
||||
import json
|
||||
import os
|
||||
@@ -65,11 +69,9 @@ class TestPreflightWarnings(unittest.TestCase):
|
||||
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
|
||||
# gitea.issue.comment (see task_capability_map), so the gate must be
|
||||
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||
),
|
||||
}
|
||||
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
)
|
||||
|
||||
|
||||
class TestIssueLockArtifactWarning(unittest.TestCase):
|
||||
|
||||
@@ -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,380 @@
|
||||
"""Allocator ownership exclusion tests (#765).
|
||||
|
||||
One session's active lease must never blockade the author queue for a
|
||||
different controller. Covers: foreign lease skipped, next unclaimed candidate
|
||||
selected, own task resumable, task-local blocker quarantined, all-claimed ->
|
||||
wait, same profile + different controller_instance_id -> different ownership,
|
||||
and claimed candidates reported in skipped results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from allocator_service import (
|
||||
OUTCOME_OWNERSHIP_DEFECT,
|
||||
OUTCOME_PREVIEW,
|
||||
OUTCOME_WAIT,
|
||||
OWNERSHIP_FOREIGN,
|
||||
OWNERSHIP_OWN,
|
||||
OWNERSHIP_UNKNOWN,
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
classify_claim_ownership,
|
||||
resolve_controller_instance_id,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB
|
||||
|
||||
REMOTE = "prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
|
||||
MINE = "ctl-mine-0001"
|
||||
THEIRS = "ctl-theirs-0002"
|
||||
|
||||
|
||||
def _issue(number: int, **kwargs) -> WorkCandidate:
|
||||
base = dict(
|
||||
kind="issue",
|
||||
number=number,
|
||||
state="open",
|
||||
labels=("status:ready", "type:bug"),
|
||||
title=f"issue {number}",
|
||||
priority=20,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return WorkCandidate(**base)
|
||||
|
||||
|
||||
def _claim(number: int, *, session_id: str, instance: str | None, kind: str = "issue"):
|
||||
return {
|
||||
"lease_id": f"lease-{number}",
|
||||
"session_id": session_id,
|
||||
"controller_instance_id": instance,
|
||||
"role": "author",
|
||||
"profile": "prgs-author",
|
||||
"expires_at": "2026-07-20T07:06:09Z",
|
||||
"work_kind": kind,
|
||||
"work_number": number,
|
||||
}
|
||||
|
||||
|
||||
class AllocatorOwnershipTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||
|
||||
def _allocate(
|
||||
self,
|
||||
candidates,
|
||||
*,
|
||||
claims,
|
||||
session_id="sess-mine",
|
||||
instance=MINE,
|
||||
apply=False,
|
||||
role="author",
|
||||
):
|
||||
return allocate_next_work(
|
||||
self.db,
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
candidates=candidates,
|
||||
apply=apply,
|
||||
profile_name="prgs-author",
|
||||
controller_instance_id=instance,
|
||||
claims=claims,
|
||||
)
|
||||
|
||||
|
||||
class TestOwnershipClassification(AllocatorOwnershipTestCase):
|
||||
def test_no_claim_returns_none(self):
|
||||
self.assertIsNone(
|
||||
classify_claim_ownership(
|
||||
None, session_id="s", controller_instance_id=MINE
|
||||
)
|
||||
)
|
||||
|
||||
def test_same_controller_instance_is_own(self):
|
||||
claim = _claim(1, session_id="other-session", instance=MINE)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=MINE
|
||||
),
|
||||
OWNERSHIP_OWN,
|
||||
)
|
||||
|
||||
def test_same_profile_different_instance_is_foreign(self):
|
||||
"""Shared profile must not imply shared ownership."""
|
||||
claim = _claim(1, session_id="other-session", instance=THEIRS)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=MINE
|
||||
),
|
||||
OWNERSHIP_FOREIGN,
|
||||
)
|
||||
|
||||
def test_exact_session_match_is_own(self):
|
||||
claim = _claim(1, session_id="sess-mine", instance=None)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=None
|
||||
),
|
||||
OWNERSHIP_OWN,
|
||||
)
|
||||
|
||||
def test_legacy_claim_with_neither_side_identified_is_foreign(self):
|
||||
"""No identities anywhere: a different session id is simply not ours."""
|
||||
claim = _claim(1, session_id="someone-else", instance=None)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=None
|
||||
),
|
||||
OWNERSHIP_FOREIGN,
|
||||
)
|
||||
|
||||
def test_claim_identified_but_local_undeclared_is_unknown(self):
|
||||
"""Only one side identified: not comparable, so never adopt."""
|
||||
claim = _claim(1, session_id="someone-else", instance=THEIRS)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=None
|
||||
),
|
||||
OWNERSHIP_UNKNOWN,
|
||||
)
|
||||
|
||||
def test_local_identified_but_claim_undeclared_is_unknown(self):
|
||||
"""A legacy lease may be our own under an old session id; do not guess."""
|
||||
claim = _claim(1, session_id="someone-else", instance=None)
|
||||
self.assertEqual(
|
||||
classify_claim_ownership(
|
||||
claim, session_id="sess-mine", controller_instance_id=MINE
|
||||
),
|
||||
OWNERSHIP_UNKNOWN,
|
||||
)
|
||||
|
||||
def test_resolve_controller_instance_id_reads_env(self):
|
||||
self.assertEqual(
|
||||
resolve_controller_instance_id({"GITEA_CONTROLLER_INSTANCE_ID": MINE}),
|
||||
MINE,
|
||||
)
|
||||
self.assertIsNone(resolve_controller_instance_id({}))
|
||||
self.assertIsNone(
|
||||
resolve_controller_instance_id({"GITEA_CONTROLLER_INSTANCE_ID": " "})
|
||||
)
|
||||
|
||||
|
||||
class TestForeignLeaseDoesNotBlockade(AllocatorOwnershipTestCase):
|
||||
def test_foreign_claim_skipped_and_next_issue_selected(self):
|
||||
"""Skip the claimed issue, select the next unclaimed one."""
|
||||
candidates = [_issue(607), _issue(615), _issue(617)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
|
||||
}
|
||||
result = self._allocate(candidates, claims=claims)
|
||||
|
||||
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
|
||||
self.assertEqual(result["selected"]["number"], 615)
|
||||
skipped_607 = [s for s in result["skipped"] if s["number"] == 607]
|
||||
self.assertEqual(len(skipped_607), 1)
|
||||
self.assertEqual(
|
||||
skipped_607[0]["reason_code"], SKIP_CLAIMED_BY_OTHER_SESSION
|
||||
)
|
||||
self.assertIn(SKIP_CLAIMED_BY_OTHER_SESSION, skipped_607[0]["reason"])
|
||||
|
||||
def test_claimed_candidate_appears_in_skipped_inventory(self):
|
||||
"""Skipped reporting must reflect claimed candidates."""
|
||||
candidates = [_issue(607), _issue(615)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
|
||||
}
|
||||
result = self._allocate(candidates, claims=claims)
|
||||
self.assertEqual(len(result["skipped"]), 1)
|
||||
self.assertEqual(len(result["claims_excluded"]), 1)
|
||||
excluded = result["claims_excluded"][0]
|
||||
self.assertEqual(excluded["number"], 607)
|
||||
self.assertEqual(excluded["ownership"], OWNERSHIP_FOREIGN)
|
||||
self.assertEqual(excluded["owner_controller_instance_id"], THEIRS)
|
||||
|
||||
def test_task_local_blocker_does_not_freeze_unrelated_work(self):
|
||||
"""A quarantined task must not stop the rest of the queue."""
|
||||
candidates = [_issue(607), _issue(615), _issue(617)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
|
||||
}
|
||||
first = self._allocate(candidates, claims=claims)
|
||||
self.assertEqual(first["selected"]["number"], 615)
|
||||
|
||||
# 615 then gets claimed by yet another controller; queue still advances.
|
||||
claims[("issue", 615)] = _claim(
|
||||
615, session_id="sess-third", instance="ctl-third-0003"
|
||||
)
|
||||
second = self._allocate(candidates, claims=claims)
|
||||
self.assertEqual(second["selected"]["number"], 617)
|
||||
|
||||
def test_multiple_controllers_get_different_issues(self):
|
||||
"""Concurrent author sessions work on different issues."""
|
||||
candidates = [_issue(607), _issue(615)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-theirs", instance=THEIRS)
|
||||
}
|
||||
mine = self._allocate(candidates, claims=claims, instance=MINE)
|
||||
theirs = self._allocate(
|
||||
candidates, claims=claims, session_id="sess-theirs", instance=THEIRS
|
||||
)
|
||||
self.assertEqual(mine["selected"]["number"], 615)
|
||||
# The other controller may still be handed its own in-progress task.
|
||||
self.assertEqual(theirs["selected"]["number"], 607)
|
||||
|
||||
def test_unclaimed_queue_is_unaffected(self):
|
||||
candidates = [_issue(607), _issue(615)]
|
||||
result = self._allocate(candidates, claims={})
|
||||
self.assertEqual(result["selected"]["number"], 607)
|
||||
self.assertEqual(result["skipped"], [])
|
||||
self.assertEqual(result["claims_excluded"], [])
|
||||
|
||||
|
||||
class TestOwnTaskResume(AllocatorOwnershipTestCase):
|
||||
def test_controller_may_resume_its_own_active_task(self):
|
||||
"""Own claim stays selectable across a new session id."""
|
||||
candidates = [_issue(607), _issue(615)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-mine-old", instance=MINE)
|
||||
}
|
||||
result = self._allocate(
|
||||
candidates, claims=claims, session_id="sess-mine-new", instance=MINE
|
||||
)
|
||||
self.assertEqual(result["selected"]["number"], 607)
|
||||
self.assertEqual(result["claims_excluded"], [])
|
||||
|
||||
def test_own_claim_by_exact_session_is_selectable(self):
|
||||
candidates = [_issue(607)]
|
||||
claims = {("issue", 607): _claim(607, session_id="sess-mine", instance=None)}
|
||||
result = self._allocate(
|
||||
candidates, claims=claims, session_id="sess-mine", instance=None
|
||||
)
|
||||
self.assertEqual(result["selected"]["number"], 607)
|
||||
|
||||
|
||||
class TestAllCandidatesClaimed(AllocatorOwnershipTestCase):
|
||||
def test_all_claimed_returns_wait_not_a_claimed_selection(self):
|
||||
"""Never hand back a claimed issue; report waiting instead."""
|
||||
candidates = [_issue(607), _issue(615)]
|
||||
claims = {
|
||||
("issue", 607): _claim(607, session_id="sess-a", instance=THEIRS),
|
||||
("issue", 615): _claim(615, session_id="sess-b", instance="ctl-c-0003"),
|
||||
}
|
||||
result = self._allocate(candidates, claims=claims)
|
||||
self.assertIsNone(result["selected"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_WAIT)
|
||||
self.assertEqual(len(result["claims_excluded"]), 2)
|
||||
|
||||
def test_unidentifiable_owner_reports_ownership_defect(self):
|
||||
"""Refuse to adopt when ownership cannot be established."""
|
||||
candidates = [_issue(607)]
|
||||
claims = {("issue", 607): _claim(607, session_id="sess-legacy", instance=None)}
|
||||
result = self._allocate(candidates, claims=claims)
|
||||
self.assertIsNone(result["selected"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_OWNERSHIP_DEFECT)
|
||||
self.assertEqual(len(result["ownership_defects"]), 1)
|
||||
self.assertEqual(
|
||||
result["ownership_defects"][0]["ownership"], OWNERSHIP_UNKNOWN
|
||||
)
|
||||
|
||||
|
||||
class TestClaimsFromControlPlaneDb(AllocatorOwnershipTestCase):
|
||||
"""End-to-end against the real substrate, not injected claim dicts."""
|
||||
|
||||
def _seed_lease(self, number: int, *, session_id: str, instance: str | None):
|
||||
self.db.upsert_session(
|
||||
session_id=session_id,
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=4242,
|
||||
controller_instance_id=instance,
|
||||
)
|
||||
return self.db.assign_and_lease(
|
||||
session_id=session_id,
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
kind="issue",
|
||||
number=number,
|
||||
)
|
||||
|
||||
def test_controller_instance_id_persists_on_session(self):
|
||||
row = self.db.upsert_session(
|
||||
session_id="sess-x",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=1,
|
||||
controller_instance_id=MINE,
|
||||
)
|
||||
self.assertEqual(row["controller_instance_id"], MINE)
|
||||
|
||||
def test_heartbeat_without_instance_does_not_erase_ownership(self):
|
||||
self.db.upsert_session(
|
||||
session_id="sess-x",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=1,
|
||||
controller_instance_id=MINE,
|
||||
)
|
||||
row = self.db.upsert_session(
|
||||
session_id="sess-x", role="author", profile="prgs-author", pid=1
|
||||
)
|
||||
self.assertEqual(row["controller_instance_id"], MINE)
|
||||
|
||||
def test_list_active_claims_surfaces_owner_instance(self):
|
||||
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
|
||||
claims = self.db.list_active_claims(remote=REMOTE, org=ORG, repo=REPO)
|
||||
self.assertIn(("issue", 607), claims)
|
||||
self.assertEqual(claims[("issue", 607)]["controller_instance_id"], THEIRS)
|
||||
|
||||
def test_live_foreign_lease_is_excluded_without_injected_claims(self):
|
||||
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
|
||||
result = allocate_next_work(
|
||||
self.db,
|
||||
session_id="sess-mine",
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
candidates=[_issue(607), _issue(615)],
|
||||
apply=False,
|
||||
profile_name="prgs-author",
|
||||
controller_instance_id=MINE,
|
||||
)
|
||||
self.assertEqual(result["selected"]["number"], 615)
|
||||
self.assertEqual(
|
||||
result["skipped"][0]["reason_code"], SKIP_CLAIMED_BY_OTHER_SESSION
|
||||
)
|
||||
|
||||
def test_apply_reserves_the_unclaimed_issue(self):
|
||||
self._seed_lease(607, session_id="sess-theirs", instance=THEIRS)
|
||||
result = allocate_next_work(
|
||||
self.db,
|
||||
session_id="sess-mine",
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
candidates=[_issue(607), _issue(615)],
|
||||
apply=True,
|
||||
profile_name="prgs-author",
|
||||
controller_instance_id=MINE,
|
||||
)
|
||||
self.assertEqual(result["outcome"], "assigned_work")
|
||||
self.assertEqual(result["selected"]["number"], 615)
|
||||
self.assertEqual(result["assignment"]["work_number"], 615)
|
||||
|
||||
|
||||
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,349 @@
|
||||
"""Allocator pre-rank exclusions and candidates_json transport (#776).
|
||||
|
||||
Covers:
|
||||
* #617 excluded before ranking (never leased when exclude_issue_numbers=[617]);
|
||||
* excluded top candidate selects the next safe candidate;
|
||||
* all candidates excluded → WAIT, no lease;
|
||||
* decoded-list and JSON-string candidates_json;
|
||||
* malformed / type-invalid fail-closed cases;
|
||||
* dry-run/apply fingerprint match and drift rejection;
|
||||
* foreign lease and same-owner lease on excluded issue;
|
||||
* skipped-accounting reason parity (excluded_by_controller);
|
||||
* public MCP entry-point coverage for exclude_issue_numbers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import gitea_mcp_server as srv
|
||||
from allocator_service import (
|
||||
OUTCOME_ASSIGNED,
|
||||
OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
|
||||
OUTCOME_CANDIDATE_SET_DRIFT,
|
||||
OUTCOME_NO_SAFE,
|
||||
OUTCOME_PREVIEW,
|
||||
OUTCOME_WAIT,
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION,
|
||||
SKIP_EXCLUDED_BY_CONTROLLER,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
candidate_from_dict,
|
||||
candidate_set_fingerprint,
|
||||
normalize_candidates_payload,
|
||||
normalize_exclude_issue_numbers,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB
|
||||
|
||||
REMOTE = "prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
MINE = "ctl-mine-776"
|
||||
THEIRS = "ctl-theirs-776"
|
||||
|
||||
|
||||
def _issue(number: int, **kwargs) -> WorkCandidate:
|
||||
base = dict(
|
||||
kind="issue",
|
||||
number=number,
|
||||
state="open",
|
||||
labels=("status:ready", "type:bug"),
|
||||
title=f"issue {number}",
|
||||
priority=20,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return WorkCandidate(**base)
|
||||
|
||||
|
||||
def _claim(number: int, *, session_id: str, instance: str | None, kind: str = "issue"):
|
||||
return {
|
||||
"lease_id": f"lease-{number}",
|
||||
"session_id": session_id,
|
||||
"controller_instance_id": instance,
|
||||
"role": "author",
|
||||
"profile": "prgs-author",
|
||||
"expires_at": "2026-07-21T12:00:00Z",
|
||||
"work_kind": kind,
|
||||
"work_number": number,
|
||||
}
|
||||
|
||||
|
||||
def _cand_dict(number: int, **kwargs) -> dict:
|
||||
d = {
|
||||
"kind": "issue",
|
||||
"number": number,
|
||||
"state": "open",
|
||||
"labels": ["status:ready", "type:bug"],
|
||||
"title": f"issue {number}",
|
||||
"priority": 20,
|
||||
}
|
||||
d.update(kwargs)
|
||||
return d
|
||||
|
||||
|
||||
class AllocatorExcludeServiceTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||
|
||||
def _alloc(self, candidates, **kwargs):
|
||||
defaults = dict(
|
||||
session_id="sess-776",
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
profile_name="prgs-author",
|
||||
controller_instance_id=MINE,
|
||||
claims={},
|
||||
apply=False,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return allocate_next_work(self.db, candidates=candidates, **defaults)
|
||||
|
||||
def test_exclude_617_before_ranking_never_selects(self) -> None:
|
||||
"""AC2/AC8: highest-ranked #617 is removed before ranking."""
|
||||
cands = [_issue(617), _issue(700), _issue(701)]
|
||||
res = self._alloc(cands, exclude_issue_numbers=[617])
|
||||
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
|
||||
self.assertEqual(res["selected"]["number"], 700)
|
||||
skipped = {s["number"]: s for s in res["skipped"]}
|
||||
self.assertIn(617, skipped)
|
||||
self.assertEqual(
|
||||
skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
|
||||
)
|
||||
self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, skipped[617]["reason"])
|
||||
|
||||
def test_excluded_top_selects_next_safe(self) -> None:
|
||||
"""AC2: excluding the oldest ready issue promotes the next number."""
|
||||
cands = [_issue(600), _issue(601), _issue(602)]
|
||||
res = self._alloc(cands, exclude_issue_numbers=[600])
|
||||
self.assertEqual(res["selected"]["number"], 601)
|
||||
|
||||
def test_all_candidates_excluded_wait_no_lease(self) -> None:
|
||||
"""AC7: every candidate excluded → WAIT, no assignment."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
res = self._alloc(cands, exclude_issue_numbers=[617, 700], apply=True)
|
||||
self.assertEqual(res["outcome"], OUTCOME_WAIT)
|
||||
self.assertIsNone(res["selected"])
|
||||
self.assertIsNone(res["assignment"])
|
||||
self.assertEqual(len(res["controller_excluded"]), 2)
|
||||
|
||||
def test_omit_exclude_retains_existing_behavior(self) -> None:
|
||||
"""AC1/AC9: omit exclusions → #617 still wins when oldest ready."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
res = self._alloc(cands)
|
||||
self.assertEqual(res["selected"]["number"], 617)
|
||||
self.assertEqual(res.get("exclude_issue_numbers"), [])
|
||||
|
||||
def test_foreign_lease_still_skipped(self) -> None:
|
||||
"""AC6/AC9: foreign claims keep SKIP_CLAIMED_BY_OTHER_SESSION."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
claims = {
|
||||
("issue", 700): _claim(700, session_id="other", instance=THEIRS),
|
||||
}
|
||||
res = self._alloc(
|
||||
cands, exclude_issue_numbers=[617], claims=claims
|
||||
)
|
||||
# 617 excluded, 700 foreign → wait, no selection
|
||||
self.assertEqual(res["outcome"], OUTCOME_WAIT)
|
||||
self.assertIsNone(res["selected"])
|
||||
codes = {s["reason_code"] for s in res["skipped"]}
|
||||
self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, codes)
|
||||
self.assertIn(SKIP_CLAIMED_BY_OTHER_SESSION, codes)
|
||||
|
||||
def test_same_owner_lease_on_excluded_blocks_resume_release(self) -> None:
|
||||
"""AC5: excluded + live same-owner lease → structured blocker."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
claims = {
|
||||
("issue", 617): _claim(617, session_id="sess-776", instance=MINE),
|
||||
}
|
||||
res = self._alloc(
|
||||
cands, exclude_issue_numbers=[617], claims=claims, apply=True
|
||||
)
|
||||
self.assertEqual(res["outcome"], OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE)
|
||||
self.assertIsNone(res["assignment"])
|
||||
self.assertEqual(res["blocked_lease"]["number"], 617)
|
||||
self.assertIn("resume", res["blocked_lease"]["safe_next_action"])
|
||||
|
||||
def test_dry_run_apply_fingerprint_match(self) -> None:
|
||||
"""AC4: dry-run and apply share the same fingerprint."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
dry = self._alloc(cands, exclude_issue_numbers=[617], apply=False)
|
||||
apply_res = self._alloc(
|
||||
cands,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=True,
|
||||
expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
|
||||
)
|
||||
self.assertEqual(
|
||||
dry["candidate_set_fingerprint"],
|
||||
apply_res["candidate_set_fingerprint"],
|
||||
)
|
||||
self.assertEqual(apply_res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(apply_res["selected"]["number"], 700)
|
||||
|
||||
def test_apply_rejects_fingerprint_drift(self) -> None:
|
||||
"""AC4: material candidate-set drift fails closed on apply."""
|
||||
cands = [_issue(617), _issue(700)]
|
||||
res = self._alloc(
|
||||
cands,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=True,
|
||||
expected_candidate_set_fingerprint="0" * 64,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res["outcome"], OUTCOME_CANDIDATE_SET_DRIFT)
|
||||
self.assertIsNone(res["assignment"])
|
||||
|
||||
def test_fingerprint_stable_helper(self) -> None:
|
||||
cands = [_issue(700), _issue(617)]
|
||||
a = candidate_set_fingerprint(cands, exclude_issue_numbers=[617])
|
||||
b = candidate_set_fingerprint(
|
||||
list(reversed(cands)), exclude_issue_numbers=[617]
|
||||
)
|
||||
self.assertEqual(a, b)
|
||||
|
||||
def test_normalize_exclude_rejects_bool(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_exclude_issue_numbers([True])
|
||||
|
||||
def test_normalize_exclude_rejects_scalar(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_exclude_issue_numbers(617)
|
||||
|
||||
|
||||
class CandidatesJsonNormalizeTest(unittest.TestCase):
|
||||
def test_decoded_list(self) -> None:
|
||||
"""AC3: already-decoded list from MCP transport."""
|
||||
cands = normalize_candidates_payload([_cand_dict(617), _cand_dict(700)])
|
||||
self.assertEqual([c.number for c in cands], [617, 700])
|
||||
|
||||
def test_json_string(self) -> None:
|
||||
"""AC3: backward-compatible JSON string."""
|
||||
raw = json.dumps([_cand_dict(617)])
|
||||
cands = normalize_candidates_payload(raw)
|
||||
self.assertEqual(cands[0].number, 617)
|
||||
|
||||
def test_malformed_json_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
normalize_candidates_payload("{not json")
|
||||
self.assertIn("malformed", str(ctx.exception).lower())
|
||||
|
||||
def test_scalar_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_candidates_payload(42)
|
||||
|
||||
def test_bool_number_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_candidates_payload([_cand_dict(True)]) # type: ignore[arg-type]
|
||||
|
||||
def test_invalid_record_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_candidates_payload(["not-a-dict"])
|
||||
|
||||
def test_object_not_list_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_candidates_payload(json.dumps({"number": 1}))
|
||||
|
||||
def test_candidate_from_dict_rejects_bool_number(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
candidate_from_dict({"kind": "issue", "number": True})
|
||||
|
||||
|
||||
class AllocateNextWorkMcpExcludeTest(unittest.TestCase):
|
||||
"""Public MCP entry-point coverage (#776 AC8)."""
|
||||
|
||||
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 _call(self, **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.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.sentry_observability.monitor_checkin", return_value=None
|
||||
):
|
||||
return srv.gitea_allocate_next_work(
|
||||
remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
|
||||
)
|
||||
|
||||
def test_mcp_exclude_617_decoded_list_never_selects(self) -> None:
|
||||
"""AC8: public tool with decoded list + exclude_issue_numbers=[617]."""
|
||||
candidates = [_cand_dict(617), _cand_dict(700)]
|
||||
res = self._call(
|
||||
candidates_json=candidates,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=False,
|
||||
)
|
||||
self.assertTrue(res.get("success"), res)
|
||||
self.assertEqual(res["selected"]["number"], 700)
|
||||
skipped = {s["number"]: s for s in res["skipped"]}
|
||||
self.assertEqual(
|
||||
skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
|
||||
)
|
||||
self.assertNotEqual(res["selected"]["number"], 617)
|
||||
|
||||
def test_mcp_exclude_617_json_string_apply(self) -> None:
|
||||
"""AC8: JSON-string transport + apply never leases #617."""
|
||||
raw = json.dumps([_cand_dict(617), _cand_dict(700)])
|
||||
res = self._call(
|
||||
candidates_json=raw,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=True,
|
||||
)
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(res["assignment"]["work_number"], 700)
|
||||
self.assertNotEqual(res["selected"]["number"], 617)
|
||||
|
||||
def test_mcp_malformed_candidates_json_fail_closed(self) -> None:
|
||||
res = self._call(candidates_json="{bad", apply=False)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertIsNone(res["assignment"])
|
||||
self.assertTrue(any("fail closed" in r for r in res["reasons"]))
|
||||
|
||||
def test_mcp_bool_number_fail_closed(self) -> None:
|
||||
res = self._call(
|
||||
candidates_json=[{"kind": "issue", "number": True, "priority": 20}],
|
||||
apply=False,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertIsNone(res["assignment"])
|
||||
|
||||
def test_mcp_fingerprint_dry_run_apply_parity(self) -> None:
|
||||
candidates = [_cand_dict(617), _cand_dict(700)]
|
||||
dry = self._call(
|
||||
candidates_json=candidates,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=False,
|
||||
)
|
||||
apply_res = self._call(
|
||||
candidates_json=candidates,
|
||||
exclude_issue_numbers=[617],
|
||||
apply=True,
|
||||
expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
|
||||
)
|
||||
self.assertEqual(
|
||||
dry["candidate_set_fingerprint"],
|
||||
apply_res["candidate_set_fingerprint"],
|
||||
)
|
||||
self.assertEqual(apply_res["selected"]["number"], 700)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,3 +1,7 @@
|
||||
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
|
||||
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
||||
|
||||
import os
|
||||
|
||||
+57
-17
@@ -1,3 +1,7 @@
|
||||
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
|
||||
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
|
||||
|
||||
Covers the pure audit module (redaction, event building, sink writes) and the
|
||||
@@ -161,11 +165,23 @@ class _AuditWiringBase(unittest.TestCase):
|
||||
self._dir.cleanup()
|
||||
|
||||
def _env(self, **extra):
|
||||
env = {"GITEA_AUDIT_LOG": self.audit_path,
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"read,merge,gitea.issue.create,gitea.issue.close")}
|
||||
# Default: prgs-aligned author with create/close (remote=prgs tests).
|
||||
# Callers override GITEA_MCP_PROFILE for merger/reviewer paths.
|
||||
profile = extra.pop("GITEA_MCP_PROFILE", None) or extra.pop(
|
||||
"GITEA_PROFILE_NAME", None
|
||||
) or "test-author-prgs"
|
||||
env = shared_mutation_env(
|
||||
profile,
|
||||
GITEA_AUDIT_LOG=self.audit_path,
|
||||
GITEA_TOKEN_TEST="test-token",
|
||||
)
|
||||
# Strip legacy env-only authority knobs if callers still pass them;
|
||||
# config-backed profiles own operations and repositories (#714).
|
||||
extra.pop("GITEA_ALLOWED_OPERATIONS", None)
|
||||
extra.pop("GITEA_FORBIDDEN_OPERATIONS", None)
|
||||
extra.pop("GITEA_PROFILE_NAME", None)
|
||||
env.update(extra)
|
||||
env["GITEA_MCP_PROFILE"] = profile
|
||||
return env
|
||||
|
||||
def _records(self):
|
||||
@@ -196,7 +212,7 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
||||
rec = recs[0]
|
||||
self.assertEqual(rec["action"], "create_issue")
|
||||
self.assertEqual(rec["result"], "succeeded")
|
||||
self.assertEqual(rec["profile_name"], "gitea-author")
|
||||
self.assertIn(rec["profile_name"], ("gitea-author", "test-author-prgs", "prgs-author"))
|
||||
self.assertEqual(rec["authenticated_username"], "author-bot")
|
||||
self.assertEqual(rec["issue_number"], 11)
|
||||
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
|
||||
@@ -222,7 +238,17 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_close_issue_audited(self, _auth, mock_api):
|
||||
mock_api.side_effect = [{"state": "closed"}, {"login": "mgr-bot"}]
|
||||
# Keyed rather than positional: closing an issue also reads its labels
|
||||
# before and after the state change for the #780 terminal cleanup and
|
||||
# its read-after-write check, so call order is not a fixed sequence.
|
||||
def api(method, url, auth, payload=None):
|
||||
if method == "PATCH":
|
||||
return {"state": "closed"}
|
||||
if "/issues/" in url:
|
||||
return {"number": 42, "labels": []}
|
||||
return {"login": "mgr-bot"}
|
||||
|
||||
mock_api.side_effect = api
|
||||
with patch.dict(os.environ, self._env(), clear=True):
|
||||
gitea_close_issue(issue_number=42, remote="prgs")
|
||||
recs = self._records()
|
||||
@@ -239,10 +265,11 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
||||
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
|
||||
# No GITEA_AUDIT_LOG -> audit is a no-op: one create POST, no file.
|
||||
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
|
||||
with patch.dict(os.environ, {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||
}, clear=True):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
shared_mutation_env("test-author-prgs"),
|
||||
clear=True,
|
||||
):
|
||||
gitea_create_issue(title="x", remote="prgs")
|
||||
issue_posts = [
|
||||
c for c in mock_api.call_args_list if c.args[0] == "POST"
|
||||
@@ -342,8 +369,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
self._pr("author-bot"), approval, # gate 7 feedback
|
||||
{}, {"merged_commit_sha": "c1"},
|
||||
]
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
||||
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
||||
@@ -362,8 +388,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
def test_merge_blocked_audited(self, _auth, mock_api):
|
||||
# Self-author merge is blocked; must still be recorded as blocked.
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
||||
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
||||
@@ -385,11 +410,23 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
||||
]
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
||||
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
||||
env = self._env(GITEA_MCP_PROFILE="test-reviewer-prgs")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
import session_context_binding as _sc
|
||||
from tests.test_mcp_server import (
|
||||
_init_reviewer_session,
|
||||
_install_owned_reviewer_lease,
|
||||
)
|
||||
from mcp_server import gitea_mark_final_review_decision
|
||||
|
||||
# Rebind after profile env is applied so durable session state
|
||||
# matches the active reviewer profile (#714 / #695).
|
||||
_sc._reset_session_context_for_testing()
|
||||
_init_reviewer_session("prgs")
|
||||
lease = _install_owned_reviewer_lease(8)
|
||||
lease.start()
|
||||
self.addCleanup(lease.stop)
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
gitea_mark_final_review_decision(
|
||||
8, "approve", expected_head_sha="abc123", remote="prgs",
|
||||
)
|
||||
@@ -398,7 +435,10 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
self.assertTrue(r["performed"])
|
||||
self.assertTrue(
|
||||
r["performed"],
|
||||
msg=f"submit_pr_review blocked: {r}",
|
||||
)
|
||||
recs = self._records()
|
||||
self.assertEqual(len(recs), 1)
|
||||
self.assertEqual(recs[0]["action"], "submit_pr_review")
|
||||
|
||||
@@ -26,8 +26,9 @@ from review_proofs import assess_audit_reconciliation_report as proofs_assess
|
||||
from task_capability_map import required_permission, required_role
|
||||
|
||||
DELETE_PROFILE = {
|
||||
"profile_name": "prgs-author-delete",
|
||||
"role": "author",
|
||||
# #729: delete_branch is reconciler-owned.
|
||||
"profile_name": "prgs-reconciler-delete",
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
@@ -238,7 +239,7 @@ class TestMcpGates(unittest.TestCase):
|
||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||
def test_delete_branch_blocked_in_audit_phase(self, _profile):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
|
||||
@@ -278,7 +279,7 @@ class TestMcpGates(unittest.TestCase):
|
||||
after_state="gone",
|
||||
)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||
self.mock_api.return_value = {}
|
||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
|
||||
@@ -79,18 +79,33 @@ class TestPreflightIntegration(unittest.TestCase):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "author"
|
||||
mcp_server._preflight_resolved_task = None
|
||||
control_root = "/repo/Gitea-Tools"
|
||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||
with mock.patch("gitea_auth.get_profile", return_value={"profile_name": "gitea-author"}):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
with mock.patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree",
|
||||
return_value=None,
|
||||
):
|
||||
with mock.patch(
|
||||
"gitea_auth.get_profile",
|
||||
return_value={"profile_name": "gitea-author"},
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
blob = str(ctx.exception)
|
||||
self.assertTrue(
|
||||
"Branches-only mutation guard" in blob
|
||||
or "control checkout" in blob
|
||||
or "author worktree" in blob.lower()
|
||||
or "#618" in blob,
|
||||
msg=blob,
|
||||
)
|
||||
|
||||
def test_verify_preflight_allows_branches_worktree(self):
|
||||
import mcp_server
|
||||
@@ -98,14 +113,46 @@ class TestPreflightIntegration(unittest.TestCase):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "author"
|
||||
mcp_server._preflight_resolved_task = None
|
||||
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
||||
healthy_ctx = {
|
||||
"workspace_path": worktree,
|
||||
"workspace_binding_source": "worktree_path argument",
|
||||
"workspace_role_kind": "author",
|
||||
"ignored_bindings": [],
|
||||
"process_project_root": "/repo/Gitea-Tools",
|
||||
"canonical_repo_root": "/repo/Gitea-Tools",
|
||||
"roots_aligned": True,
|
||||
"bound_worktree_missing": False,
|
||||
"author_worktree_block": False,
|
||||
"author_worktree_reasons": [],
|
||||
"author_worktree_resolution": {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"bound_worktree_missing": False,
|
||||
"workspace_path": worktree,
|
||||
"workspace_binding_source": "worktree_path argument",
|
||||
"reasons": [],
|
||||
},
|
||||
"path_exists": True,
|
||||
"in_git_worktree_list": True,
|
||||
"inspected_git_root": worktree,
|
||||
}
|
||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
with mock.patch.object(
|
||||
mcp_server, "_session_author_lock_worktree", return_value=None
|
||||
):
|
||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||
with mock.patch.object(
|
||||
mcp_server,
|
||||
"_resolve_namespace_mutation_context",
|
||||
return_value=healthy_ctx,
|
||||
):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -257,22 +257,27 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||
]
|
||||
self.assertFalse(delete_calls)
|
||||
|
||||
def test_reconciler_with_branch_delete_cannot_raw_delete(self):
|
||||
"""#687: reconciler + gitea.branch.delete still cannot call raw delete."""
|
||||
def test_reconciler_with_branch_delete_can_raw_delete(self):
|
||||
"""#729: delete_branch is re-homed from author to reconciler. The
|
||||
reconciler is the delete-capable role and performs raw deletion of an
|
||||
eligible (non-preservation, non-protected) branch — superseding the
|
||||
#687 redirect that previously blocked it."""
|
||||
patch(
|
||||
"mcp_server.get_profile",
|
||||
return_value=dict(RECONCILER_WITH_DELETE),
|
||||
).start()
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||
self.mock_api.return_value = {}
|
||||
res = gitea_delete_branch(
|
||||
branch="fix/issue-683-workflow-guard-hardening",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(res.get("success", True))
|
||||
self.assertFalse(res.get("performed", True))
|
||||
reasons = " ".join(res.get("reasons") or [])
|
||||
self.assertIn("raw gitea_delete_branch", reasons)
|
||||
self.assertIn("cleanup_merged_pr_branch", reasons)
|
||||
self.mock_api.assert_not_called()
|
||||
self.assertTrue(res.get("success"))
|
||||
delete_calls = [
|
||||
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
|
||||
]
|
||||
self.assertTrue(delete_calls)
|
||||
|
||||
def test_reconciler_raw_delete_denies_preservation_branch(self):
|
||||
patch(
|
||||
@@ -286,17 +291,18 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||
self.assertFalse(res.get("performed", True))
|
||||
self.mock_api.assert_not_called()
|
||||
|
||||
def test_author_with_branch_delete_role_ok_but_preserve_blocked(self):
|
||||
"""Author role may use raw delete path when permitted; preserve fails closed."""
|
||||
def test_reconciler_raw_delete_role_ok_but_preserve_blocked(self):
|
||||
"""#729: reconciler role may use raw delete path when permitted;
|
||||
preservation branch still fails closed."""
|
||||
patch(
|
||||
"mcp_server.get_profile",
|
||||
return_value={
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"profile_name": "prgs-reconciler",
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
|
||||
@@ -29,6 +29,7 @@ CONFIG = {
|
||||
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
||||
"forbidden_operations": ["gitea.pr.create"],
|
||||
"execution_profile": "commit-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
"pr-only-author": {
|
||||
"enabled": True,
|
||||
@@ -39,6 +40,7 @@ CONFIG = {
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
||||
"forbidden_operations": ["gitea.repo.commit"],
|
||||
"execution_profile": "pr-only-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
"reviewer-profile": {
|
||||
"enabled": True,
|
||||
@@ -51,6 +53,7 @@ CONFIG = {
|
||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
|
||||
],
|
||||
"execution_profile": "reviewer-profile",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
|
||||
@@ -35,6 +35,7 @@ CONFIG = {
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
"execution_profile": "full-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
"reviewer-no-commit": {
|
||||
"enabled": True,
|
||||
@@ -49,6 +50,7 @@ CONFIG = {
|
||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
|
||||
],
|
||||
"execution_profile": "reviewer-no-commit",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
|
||||
@@ -35,6 +35,7 @@ CONFIG = {
|
||||
"gitea.pr.review",
|
||||
],
|
||||
"execution_profile": "full-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -227,6 +228,7 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_commit_files_traversal_blocked(self, _auth, mock_api):
|
||||
mock_api.return_value = {"login": "author-user"}
|
||||
# Remove active lock file to ensure it fails on traversal/invalid locks
|
||||
os.remove(self.lock_file_path)
|
||||
|
||||
@@ -248,6 +250,7 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
|
||||
mock_api.return_value = {"login": "author-user"}
|
||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
mcp_server.gitea_commit_files(
|
||||
@@ -266,6 +269,7 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
|
||||
mock_api.return_value = {"login": "author-user"}
|
||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
mcp_server.gitea_commit_files(
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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
|
||||
"""Tests for canonical JSON runtime-profile configuration (gitea_config) and
|
||||
its integration into gitea_auth.get_profile / get_auth_header.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
"""Tests for create_issue.py.
|
||||
|
||||
Every test mocks auth functions so no real network calls or keychain
|
||||
@@ -12,7 +13,7 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import contextlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch, MagicMock, patch
|
||||
|
||||
# The module under test lives in the repo root, not a package.
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
@@ -92,6 +93,26 @@ class TestRemoteResolution(unittest.TestCase):
|
||||
# ---------------------------------------------------------------------------
|
||||
@unittest.skipIf(_SKIP, _REASON)
|
||||
class TestAPIPayload(unittest.TestCase):
|
||||
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
|
||||
not the historical REMOTES Timesheet default. Intentional security-policy
|
||||
migration of legacy omitted-target assertions.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self._ws_env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
)
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._ws_env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._ws_env.stop()
|
||||
|
||||
"""Ensure the JSON payload sent to Gitea is correct."""
|
||||
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
@@ -142,7 +163,7 @@ class TestAPIPayload(unittest.TestCase):
|
||||
url = mock_api.call_args[0][1]
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues",
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/issues",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -26,15 +26,23 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
||||
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
|
||||
|
||||
# Disable early return in verify_preflight_purity for testing
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
# #618: isolate from ambient session issue locks
|
||||
self._lock_patch = patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree", return_value=None
|
||||
)
|
||||
self._lock_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
srv._preflight_resolved_task = None
|
||||
self._lock_patch.stop()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@@ -51,29 +59,62 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
||||
"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,
|
||||
):
|
||||
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
|
||||
# path is the stable control checkout (not under branches/), mutation must fail.
|
||||
# #749: clean canonical control checkout is the sanctioned create_issue path.
|
||||
mock_api.return_value = {
|
||||
"number": 77,
|
||||
"html_url": "https://gitea.example.com/issues/77",
|
||||
}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
try:
|
||||
res = srv.gitea_create_issue(title="Test issue", body="body text")
|
||||
except RuntimeError as exc:
|
||||
self.assertIn("stable control checkout", str(exc))
|
||||
else:
|
||||
# #683: production guards return typed blockers at entrypoints
|
||||
self.assertFalse(res.get("success"))
|
||||
self.assertFalse(res.get("performed"))
|
||||
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()
|
||||
)
|
||||
self.assertTrue(res.get("exact_next_action"))
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
with patch.object(srv, "_run_anti_stomp_preflight", return_value=None):
|
||||
with patch.object(srv, "_enforce_root_checkout_guard"):
|
||||
res = srv.gitea_create_issue(
|
||||
title="Test issue", body="body text for gate"
|
||||
)
|
||||
self.assertEqual(res.get("number"), 77)
|
||||
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=[])
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@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._profile_permission_block", return_value=None)
|
||||
|
||||
+23
-2
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
"""Tests for create_pr.py.
|
||||
|
||||
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
|
||||
@@ -8,7 +9,7 @@ import json
|
||||
import sys
|
||||
import unittest
|
||||
import contextlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch, MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
import create_pr # noqa: E402
|
||||
@@ -74,6 +75,26 @@ class TestRemoteResolution(unittest.TestCase):
|
||||
# API payload
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAPIPayload(unittest.TestCase):
|
||||
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
|
||||
not the historical REMOTES Timesheet default. Intentional security-policy
|
||||
migration of legacy omitted-target assertions.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self._ws_env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
)
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._ws_env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._ws_env.stop()
|
||||
|
||||
|
||||
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_payload_fields(self, _cred):
|
||||
@@ -113,7 +134,7 @@ class TestAPIPayload(unittest.TestCase):
|
||||
url = MockReq.call_args[0][0]
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/pulls",
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/pulls",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -1,9 +1,15 @@
|
||||
"""Tests for gitea_delete_branch capability gate (Issue #408).
|
||||
"""Tests for gitea_delete_branch capability + role gate (Issue #408, #729).
|
||||
|
||||
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
|
||||
without it the delete fails closed (no preflight, no auth lookup, no API call,
|
||||
structured permission report). With it, deletion proceeds through existing
|
||||
preflight and audit unchanged.
|
||||
structured permission report).
|
||||
|
||||
#729: delete_branch is reconciler-owned. ``gitea.branch.delete`` is granted only
|
||||
to the reconciler profile, so the resolver classifies delete_branch as a
|
||||
reconciler task. Raw ``gitea_delete_branch`` still redirects the reconciler to
|
||||
the guarded ``gitea_cleanup_merged_pr_branch`` path (#514/#687); author,
|
||||
reviewer, and merger remain denied by the permission gate and/or the required
|
||||
role gate.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -16,6 +22,8 @@ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.par
|
||||
|
||||
import mcp_server
|
||||
from mcp_server import gitea_delete_branch
|
||||
import task_capability_map
|
||||
import role_session_router
|
||||
|
||||
FAKE_AUTH = "token fake"
|
||||
|
||||
@@ -38,6 +46,22 @@ AUTHOR_WITH_DELETE = {
|
||||
"audit_label": "prgs-author-deleter",
|
||||
}
|
||||
|
||||
# #729: delete_branch is reconciler-owned. The reconciler holds
|
||||
# gitea.branch.delete but raw gitea_delete_branch redirects it to the guarded
|
||||
# gitea_cleanup_merged_pr_branch path (#514/#687).
|
||||
RECONCILER_WITH_DELETE = {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create",
|
||||
],
|
||||
"audit_label": "prgs-reconciler",
|
||||
}
|
||||
|
||||
CONFIG = {
|
||||
"version": 2,
|
||||
"contexts": {
|
||||
@@ -81,6 +105,20 @@ CONFIG = {
|
||||
],
|
||||
"execution_profile": "reviewer-profile",
|
||||
},
|
||||
# #729: reconciler is the only delete-capable role.
|
||||
"reconciler-profile": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "reconciler",
|
||||
"username": "reconciler-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_RECONCILER"},
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.pr.merge"],
|
||||
"execution_profile": "reconciler-profile",
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
}
|
||||
@@ -121,6 +159,9 @@ class TestDeleteBranchToolGate(unittest.TestCase):
|
||||
def _set_profile(self, profile):
|
||||
patch("mcp_server.get_profile", return_value=profile).start()
|
||||
|
||||
def _delete_calls(self):
|
||||
return [c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"]
|
||||
|
||||
def test_blocked_without_delete_capability(self):
|
||||
self._set_profile(AUTHOR_NO_DELETE)
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
@@ -135,33 +176,53 @@ class TestDeleteBranchToolGate(unittest.TestCase):
|
||||
self.mock_api.assert_not_called()
|
||||
self.mock_auth.assert_not_called()
|
||||
|
||||
def test_allowed_delete_proceeds(self):
|
||||
def test_author_with_delete_perm_denied_by_role_gate(self):
|
||||
"""#729: even an author holding gitea.branch.delete is denied — the
|
||||
required role is now reconciler. Fail closed, no API DELETE."""
|
||||
self._set_profile(AUTHOR_WITH_DELETE)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||
self.assertEqual(res["active_role_kind"], "author")
|
||||
self.assertTrue(res["reasons"])
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
def test_reviewer_with_delete_perm_denied_by_role_gate(self):
|
||||
"""A reviewer that somehow holds the permission is still denied."""
|
||||
reviewer_with_delete = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.pr.review", "gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "prgs-reviewer",
|
||||
}
|
||||
self._set_profile(reviewer_with_delete)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="reviewer")
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||
self.assertEqual(res["active_role_kind"], "reviewer")
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
def test_reconciler_performs_raw_delete(self):
|
||||
"""#729: the reconciler is the delete-capable role and performs the raw
|
||||
deletion (no audit phase active here); the API DELETE is issued."""
|
||||
self._set_profile(RECONCILER_WITH_DELETE)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="reconciler")
|
||||
self.mock_api.return_value = {}
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertIn("deleted", res["message"])
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertTrue(delete_calls)
|
||||
|
||||
def test_allowed_delete_audited_with_capability_proof(self):
|
||||
self._set_profile(AUTHOR_WITH_DELETE)
|
||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||
mock_write = patch("gitea_audit.write_event").start()
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
self.mock_api.return_value = {}
|
||||
gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
mock_write.assert_called()
|
||||
event = mock_write.call_args[0][0]
|
||||
self.assertEqual(event["action"], "delete_branch")
|
||||
self.assertEqual(
|
||||
event["request_metadata"]["required_permission"],
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
self.assertTrue(self._delete_calls())
|
||||
|
||||
|
||||
class TestDeleteBranchResolverParity(unittest.TestCase):
|
||||
@@ -194,24 +255,89 @@ class TestDeleteBranchResolverParity(unittest.TestCase):
|
||||
"GITEA_MCP_PROFILE": profile,
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||
"GITEA_TOKEN_RECONCILER": "reconciler-pass",
|
||||
}
|
||||
|
||||
def _delete_calls(self):
|
||||
return [c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"]
|
||||
|
||||
def test_reconciler_resolver_allows_delete_branch(self):
|
||||
"""#729: resolve(delete_branch) on the reconciler profile is allowed and
|
||||
classified as a reconciler task."""
|
||||
with patch.dict(os.environ, self._env("reconciler-profile"), clear=True):
|
||||
resolve = mcp_server.gitea_resolve_task_capability(
|
||||
task="delete_branch", remote="prgs")
|
||||
# Deterministic role-map outcomes (avoid runtime-staleness-dependent
|
||||
# allowed_in_current_session, which folds in reconnect state).
|
||||
self.assertEqual(resolve["required_role_kind"], "reconciler")
|
||||
self.assertEqual(
|
||||
resolve["required_operation_permission"], "gitea.branch.delete")
|
||||
self.assertTrue(resolve["active_profile_permission_allowed"])
|
||||
self.assertTrue(resolve["configured"])
|
||||
self.assertIn("reconciler-profile", resolve["matching_configured_profile"])
|
||||
self.assertEqual(resolve["active_role_kind"], "reconciler")
|
||||
|
||||
def test_author_resolver_denies_delete_branch(self):
|
||||
"""#729: an author session cannot resolve delete_branch — required role
|
||||
is reconciler and the author lacks the permission."""
|
||||
with patch.dict(os.environ, self._env("author-no-delete"), clear=True):
|
||||
resolve = mcp_server.gitea_resolve_task_capability(
|
||||
task="delete_branch", remote="prgs")
|
||||
self.assertEqual(resolve["required_role_kind"], "reconciler")
|
||||
self.assertFalse(resolve["allowed_in_current_session"])
|
||||
|
||||
def test_reviewer_resolver_denial_blocks_raw_tool(self):
|
||||
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
|
||||
resolve = mcp_server.gitea_resolve_task_capability(
|
||||
task="delete_branch", remote="prgs")
|
||||
self.assertFalse(resolve["allowed_in_current_session"])
|
||||
patch("mcp_server.get_profile", return_value={
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.review"],
|
||||
"forbidden_operations": ["gitea.branch.delete"],
|
||||
}).start()
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(
|
||||
res["permission_report"]["missing_permission"],
|
||||
resolve["required_operation_permission"],
|
||||
)
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertFalse(delete_calls)
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
|
||||
class TestDeleteBranchRoleMapParity(unittest.TestCase):
|
||||
"""#729: both single-source-of-truth maps must classify delete_branch as
|
||||
reconciler and stay in agreement."""
|
||||
|
||||
def test_task_capability_map_role_reconciler(self):
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role("delete_branch"), "reconciler")
|
||||
self.assertEqual(
|
||||
task_capability_map.required_permission("delete_branch"),
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
|
||||
def test_router_required_role_reconciler(self):
|
||||
self.assertEqual(
|
||||
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
|
||||
"reconciler",
|
||||
)
|
||||
self.assertEqual(
|
||||
role_session_router.required_role_for_task("delete_branch"),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_router_set_membership_moved(self):
|
||||
self.assertIn("delete_branch", role_session_router.RECONCILER_TASKS)
|
||||
self.assertNotIn("delete_branch", role_session_router.AUTHOR_TASKS)
|
||||
|
||||
def test_maps_agree(self):
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role("delete_branch"),
|
||||
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Regression tests for durable author worktree resolution (#618)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import author_mutation_worktree as amw # noqa: E402
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
|
||||
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||
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 TestDurableAuthorWorktreeResolution(unittest.TestCase):
|
||||
def test_missing_author_env_fails_closed_no_control_fallback(self):
|
||||
missing = "/nonexistent/branches/mcp-author-clean-ns"
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
author_worktree_env=missing,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(result["bound_worktree_missing"])
|
||||
self.assertFalse(result["silent_control_fallback"])
|
||||
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, result["reasons"][0])
|
||||
self.assertNotEqual(
|
||||
os.path.realpath(result["workspace_path"]),
|
||||
os.path.realpath(CONTROL_CHECKOUT_ROOT),
|
||||
)
|
||||
|
||||
def test_missing_active_env_fails_closed(self):
|
||||
missing = "/nonexistent/branches/deleted-active"
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
active_worktree_env=missing,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(result["bound_worktree_missing"])
|
||||
self.assertIn(amw.ACTIVE_WORKTREE_ENV, result["workspace_binding_source"])
|
||||
|
||||
def test_derives_from_active_author_issue_lock(self):
|
||||
lock_wt = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "issue-618-lock")
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
session_lock_worktree=lock_wt,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
validate=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
result["workspace_path"], os.path.realpath(os.path.abspath(lock_wt))
|
||||
)
|
||||
self.assertIn("issue lock", result["workspace_binding_source"])
|
||||
|
||||
def test_explicit_worktree_path_wins_over_lock(self):
|
||||
explicit = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "issue-618-explicit")
|
||||
lock_wt = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "issue-618-lock")
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
worktree_path=explicit,
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
session_lock_worktree=lock_wt,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
validate=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
result["workspace_path"], os.path.realpath(os.path.abspath(explicit))
|
||||
)
|
||||
self.assertEqual(result["workspace_binding_source"], "worktree_path argument")
|
||||
|
||||
def test_no_binding_does_not_silently_use_control_checkout(self):
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertFalse(result["silent_control_fallback"])
|
||||
blob = " ".join(result["reasons"])
|
||||
self.assertIn("control checkout", blob)
|
||||
self.assertIn("forbidden", blob)
|
||||
|
||||
def test_process_root_under_branches_is_allowed(self):
|
||||
branches_root = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "session-wt")
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
process_project_root=branches_root,
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
validate=False,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(
|
||||
result["workspace_path"], os.path.realpath(branches_root)
|
||||
)
|
||||
self.assertIn("branches/", result["workspace_binding_source"])
|
||||
|
||||
def test_lock_ownership_mismatch_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = tmp
|
||||
branches = os.path.join(root, "branches")
|
||||
os.makedirs(os.path.join(branches, "a"))
|
||||
os.makedirs(os.path.join(branches, "b"))
|
||||
# Seed a fake .git so membership/list may soft-fail without hard error
|
||||
os.makedirs(os.path.join(root, ".git"))
|
||||
result = amw.resolve_durable_author_worktree(
|
||||
worktree_path=os.path.join(branches, "a"),
|
||||
process_project_root=root,
|
||||
session_lock_worktree=os.path.join(branches, "b"),
|
||||
canonical_repo_root=root,
|
||||
validate=True,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(
|
||||
any("lock" in r.lower() and "match" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_traversal_safety_blocks_escape(self):
|
||||
assessment = amw.assess_path_traversal_safety(
|
||||
path="/tmp/other-repo/branches/evil",
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
)
|
||||
self.assertTrue(assessment["block"])
|
||||
self.assertTrue(any("escapes" in r for r in assessment["reasons"]))
|
||||
|
||||
def test_bound_worktree_existence_reports_null_git_root(self):
|
||||
assessment = amw.assess_bound_worktree_existence(
|
||||
configured_path="/nonexistent/branches/gone",
|
||||
binding_source=f"{amw.AUTHOR_WORKTREE_ENV} environment variable",
|
||||
canonical_repo_root=CONTROL_CHECKOUT_ROOT,
|
||||
profile_name="prgs-author",
|
||||
)
|
||||
self.assertTrue(assessment["block"])
|
||||
self.assertIsNone(assessment["inspected_git_root"])
|
||||
self.assertFalse(assessment["path_exists"])
|
||||
msg = amw.format_bound_worktree_missing_error(assessment)
|
||||
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, msg)
|
||||
self.assertIn("prgs-author", msg)
|
||||
self.assertIn("recreate or repoint", msg.lower())
|
||||
|
||||
|
||||
class TestNamespaceAuthorNoDemotion(unittest.TestCase):
|
||||
def test_author_missing_env_not_demoted_to_process_root(self):
|
||||
missing = "/nonexistent/branches/mcp-author-clean-ns"
|
||||
demotions: list[str] = []
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="author",
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
env={amw.AUTHOR_WORKTREE_ENV: missing},
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertIn("AUTHOR", source)
|
||||
self.assertNotEqual(os.path.realpath(path), os.path.realpath(CONTROL_CHECKOUT_ROOT))
|
||||
self.assertTrue(any("not demoted" in d for d in demotions))
|
||||
|
||||
def test_reviewer_still_demotes_missing_env(self):
|
||||
"""#702 demotion retained for non-author roles."""
|
||||
demotions: list[str] = []
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
env={"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-gone"},
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(source, "MCP server process root (default)")
|
||||
self.assertEqual(path, os.path.realpath(CONTROL_CHECKOUT_ROOT))
|
||||
self.assertTrue(demotions)
|
||||
|
||||
def test_mutation_context_surfaces_missing_binding_health(self):
|
||||
ctx = nwb.resolve_namespace_mutation_context(
|
||||
role_kind="author",
|
||||
worktree_path=None,
|
||||
process_project_root=CONTROL_CHECKOUT_ROOT,
|
||||
env={amw.AUTHOR_WORKTREE_ENV: "/nonexistent/branches/mcp-author-clean-ns"},
|
||||
profile_name="prgs-author",
|
||||
)
|
||||
self.assertTrue(ctx.get("bound_worktree_missing"))
|
||||
self.assertTrue(ctx.get("author_worktree_block"))
|
||||
self.assertIsNone(ctx.get("inspected_git_root"))
|
||||
self.assertFalse(ctx.get("path_exists"))
|
||||
|
||||
|
||||
class TestCreateIssueAndCommentAgreeOnMissingWorktree(unittest.TestCase):
|
||||
"""AC3/AC4: create_issue and create_issue_comment enforce the same rule."""
|
||||
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "author"
|
||||
srv._preflight_resolved_task = None
|
||||
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
|
||||
self._lock_patch = patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree", return_value=None
|
||||
)
|
||||
self._lock_patch.start()
|
||||
self.addCleanup(self._restore)
|
||||
|
||||
def _restore(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
srv._preflight_resolved_task = None
|
||||
self._lock_patch.stop()
|
||||
os.environ.pop(amw.AUTHOR_WORKTREE_ENV, None)
|
||||
os.environ.pop(amw.ACTIVE_WORKTREE_ENV, None)
|
||||
|
||||
def _assert_blocked_missing(self, result_or_exc):
|
||||
if isinstance(result_or_exc, BaseException):
|
||||
blob = str(result_or_exc)
|
||||
else:
|
||||
blob = " ".join(
|
||||
str(x)
|
||||
for x in (
|
||||
result_or_exc.get("reasons") or [],
|
||||
result_or_exc.get("message"),
|
||||
result_or_exc.get("blocker_kind"),
|
||||
)
|
||||
if x
|
||||
)
|
||||
if not blob:
|
||||
blob = str(result_or_exc)
|
||||
self.assertTrue(
|
||||
amw.BOUND_WORKTREE_MISSING_MESSAGE in blob
|
||||
or "does not exist" in blob
|
||||
or "bound worktree" in blob.lower(),
|
||||
msg=blob,
|
||||
)
|
||||
|
||||
@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_create_issue_blocked_when_author_env_missing(
|
||||
self, _get_all, mock_api, _role, _ns, _prof, _auth
|
||||
):
|
||||
missing = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-618-author-env"
|
||||
)
|
||||
os.environ[amw.AUTHOR_WORKTREE_ENV] = missing
|
||||
srv._preflight_resolved_task = "create_issue"
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
try:
|
||||
res = srv.gitea_create_issue(title="Test issue", body="body text here")
|
||||
except RuntimeError as exc:
|
||||
self._assert_blocked_missing(exc)
|
||||
else:
|
||||
self.assertFalse(res.get("success", True) and res.get("number"))
|
||||
self._assert_blocked_missing(res)
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
def test_create_issue_comment_blocked_when_author_env_missing(self, mock_api, _auth):
|
||||
missing = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-618-author-env"
|
||||
)
|
||||
os.environ[amw.AUTHOR_WORKTREE_ENV] = missing
|
||||
srv._preflight_resolved_task = "comment_issue"
|
||||
author_env = {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||
amw.AUTHOR_WORKTREE_ENV: missing,
|
||||
}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch.dict(os.environ, author_env, clear=False):
|
||||
try:
|
||||
res = srv.gitea_create_issue_comment(
|
||||
issue_number=618,
|
||||
body="evidence comment",
|
||||
remote="prgs",
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
self._assert_blocked_missing(exc)
|
||||
else:
|
||||
self.assertFalse(res.get("success", True))
|
||||
self._assert_blocked_missing(res)
|
||||
mock_api.assert_not_called()
|
||||
|
||||
|
||||
class TestRuntimeContextUnhealthyMissingWorktree(unittest.TestCase):
|
||||
def setUp(self):
|
||||
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._lock_patch = patch(
|
||||
"gitea_mcp_server._session_author_lock_worktree", return_value=None
|
||||
)
|
||||
self._lock_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._lock_patch.stop()
|
||||
os.environ.pop(amw.AUTHOR_WORKTREE_ENV, None)
|
||||
|
||||
def test_assess_preflight_reports_null_git_root_and_missing(self):
|
||||
missing = "/nonexistent/branches/mcp-author-clean-ns"
|
||||
os.environ[amw.AUTHOR_WORKTREE_ENV] = missing
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server.get_profile", return_value={
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": ["gitea.pr.create"],
|
||||
"forbidden_operations": [],
|
||||
}):
|
||||
status = srv.assess_preflight_status()
|
||||
self.assertFalse(status["preflight_ready"])
|
||||
blob = " ".join(status["preflight_block_reasons"])
|
||||
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, blob)
|
||||
details = status["preflight_workspace"]
|
||||
self.assertIsNotNone(details)
|
||||
self.assertTrue(details.get("bound_worktree_missing"))
|
||||
self.assertIsNone(details.get("inspected_git_root"))
|
||||
self.assertFalse(details.get("path_exists"))
|
||||
self.assertFalse(details.get("workspace_healthy"))
|
||||
|
||||
|
||||
class TestThreadLedgerExample(unittest.TestCase):
|
||||
def test_bound_worktree_missing_ledger_example_exists(self):
|
||||
import thread_state_ledger_examples as examples
|
||||
|
||||
names = [name for name, _h, _l in examples.EXAMPLES]
|
||||
self.assertIn("bound_worktree_missing_blocker", names)
|
||||
for name, _handoff, ledger in examples.EXAMPLES:
|
||||
if name == "bound_worktree_missing_blocker":
|
||||
self.assertIn(amw.BOUND_WORKTREE_MISSING_MESSAGE, ledger)
|
||||
self.assertIn("inspected_git_root", ledger)
|
||||
self.assertIn("operator", ledger.lower())
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,430 @@
|
||||
"""#685: gitea_resolve_task_capability must be side-effect free.
|
||||
|
||||
Stale-runtime detection remains fail-closed, but the resolver must never:
|
||||
* touch mcp_config.json (or any MCP client config)
|
||||
* spawn recovery threads
|
||||
* call os._exit / terminate the serving process
|
||||
* claim that an auto-restart was triggered
|
||||
|
||||
Recovery is owned by the IDE/client reconnect path only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import sys
|
||||
|
||||
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
|
||||
|
||||
ROLE_PROFILES = (
|
||||
("create_issue", "prgs-author", "author"),
|
||||
("review_pr", "prgs-reviewer", "reviewer"),
|
||||
("merge_pr", "prgs-merger", "merger"),
|
||||
("reconciliation_cleanup", "prgs-reconciler", "reconciler"),
|
||||
)
|
||||
|
||||
|
||||
def _stale_self_ps_mocks(profile: str = "prgs-author"):
|
||||
"""Build subprocess mocks: self PID is stale vs code mtime."""
|
||||
mock_getpid = MagicMock(return_value=12345)
|
||||
mock_exists = MagicMock(return_value=True)
|
||||
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
||||
mock_getmtime = MagicMock(return_value=code_time.timestamp())
|
||||
|
||||
ps_output = (
|
||||
" PID LSTART COMMAND\n"
|
||||
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
||||
)
|
||||
mock_run_ps = MagicMock()
|
||||
mock_run_ps.stdout = ps_output
|
||||
|
||||
mock_run_env = MagicMock()
|
||||
mock_run_env.stdout = f"GITEA_MCP_PROFILE={profile}"
|
||||
|
||||
mock_run_git = MagicMock()
|
||||
mock_run_git.stdout = "SAME"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if args[0] == "ps" and "eww" in args:
|
||||
return mock_run_env
|
||||
if args[0] == "ps":
|
||||
return mock_run_ps
|
||||
if args[0] == "git":
|
||||
return mock_run_git
|
||||
raise ValueError(f"Unexpected subprocess args: {args}")
|
||||
|
||||
mock_run = MagicMock(side_effect=side_effect)
|
||||
return mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||
|
||||
|
||||
class TestIssue685DiagnosticsNoSideEffects(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch("os.utime")
|
||||
@patch("threading.Thread")
|
||||
@patch("os._exit")
|
||||
def test_stale_self_does_not_touch_config_or_exit(
|
||||
self,
|
||||
mock_exit,
|
||||
mock_thread,
|
||||
mock_utime,
|
||||
mock_getpid,
|
||||
mock_exists,
|
||||
mock_getmtime,
|
||||
mock_run,
|
||||
):
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||
|
||||
before_threads = threading.active_count()
|
||||
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||
"create_issue", ["prgs-author"]
|
||||
)
|
||||
after_threads = threading.active_count()
|
||||
|
||||
self.assertTrue(
|
||||
any("stale-runtime" in r and "active Gitea MCP server process is stale" in r
|
||||
for r in reasons),
|
||||
reasons,
|
||||
)
|
||||
# Must not claim auto-restart / config touch
|
||||
blob = " ".join(reasons)
|
||||
self.assertNotIn("Auto-restart has been triggered", blob)
|
||||
self.assertNotIn("touched mcp_config", blob)
|
||||
self.assertNotIn("will cleanly exit", blob)
|
||||
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
self.assertEqual(before_threads, after_threads)
|
||||
|
||||
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch("os.utime")
|
||||
def test_repeated_stale_calls_do_not_trigger_restart_loop(
|
||||
self, mock_utime, mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||
):
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||
|
||||
for _ in range(5):
|
||||
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||
"create_issue", ["prgs-author"]
|
||||
)
|
||||
self.assertTrue(any("stale-runtime" in r for r in reasons))
|
||||
|
||||
mock_utime.assert_not_called()
|
||||
|
||||
def test_trigger_mcp_auto_restart_removed(self):
|
||||
"""#685 AC: auto-restart helper is removed (unreachable from read-only)."""
|
||||
self.assertFalse(hasattr(mcp_server, "_trigger_mcp_auto_restart"))
|
||||
self.assertFalse(hasattr(mcp_server, "_restart_triggered"))
|
||||
|
||||
|
||||
class TestIssue685ResolverTypedBlocker(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||
mcp_server.capability_stop_terminal.clear()
|
||||
|
||||
def _resolve_with_stale_runtime(self, task: str, profile_name: str, role: str):
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.branch.delete",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.close",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
profile = {
|
||||
"profile_name": profile_name,
|
||||
"role": role,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
config = {
|
||||
"profiles": {
|
||||
profile_name: {
|
||||
"role": role,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||
profile_name
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
},
|
||||
clear=False,
|
||||
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=config
|
||||
), patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="test-user"
|
||||
), patch.object(
|
||||
mcp_server, "_ensure_matching_profile", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_preflight_check", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
), patch(
|
||||
"subprocess.run", mock_run
|
||||
), patch(
|
||||
"os.path.getmtime", mock_getmtime
|
||||
), patch(
|
||||
"os.path.exists", mock_exists
|
||||
), patch(
|
||||
"os.getpid", mock_getpid
|
||||
), patch(
|
||||
"os.utime"
|
||||
) as mock_utime, patch(
|
||||
"threading.Thread"
|
||||
) as mock_thread, patch(
|
||||
"os._exit"
|
||||
) as mock_exit:
|
||||
result = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||
return result, mock_utime, mock_thread, mock_exit
|
||||
|
||||
def test_stale_returns_typed_blocker_fields(self):
|
||||
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||
"create_issue", "prgs-author", "author"
|
||||
)
|
||||
self.assertTrue(result.get("restart_required"), result)
|
||||
self.assertTrue(result.get("stop_required"), result)
|
||||
self.assertEqual(result.get("blocker_kind"), "runtime_reconnect_required")
|
||||
self.assertIs(result.get("mutation_performed"), False)
|
||||
action = result.get("exact_safe_next_action") or ""
|
||||
self.assertIn("reconnect", action.lower())
|
||||
self.assertNotIn("None; ready for operations", action)
|
||||
reason = result.get("reason") or ""
|
||||
self.assertIn("stale-runtime", reason)
|
||||
self.assertNotIn("Auto-restart has been triggered", reason)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
def test_all_four_role_profiles_get_same_side_effect_free_contract(self):
|
||||
for task, profile, role in ROLE_PROFILES:
|
||||
with self.subTest(task=task, profile=profile):
|
||||
# Skip tasks that may be unknown on this branch
|
||||
try:
|
||||
import task_capability_map as tcm
|
||||
|
||||
tcm.required_permission(task)
|
||||
except Exception:
|
||||
self.skipTest(f"task {task} not in capability map")
|
||||
|
||||
result, mock_utime, mock_thread, mock_exit = (
|
||||
self._resolve_with_stale_runtime(task, profile, role)
|
||||
)
|
||||
self.assertTrue(
|
||||
result.get("restart_required") or result.get("stop_required"),
|
||||
result,
|
||||
)
|
||||
self.assertEqual(
|
||||
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||
)
|
||||
self.assertIs(result.get("mutation_performed"), False, result)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
def test_config_mtime_and_contents_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = os.path.join(tmp, "mcp_config.json")
|
||||
original = '{"servers": {"gitea-author": {}}}'
|
||||
with open(cfg, "w", encoding="utf-8") as fh:
|
||||
fh.write(original)
|
||||
mtime_before = os.path.getmtime(cfg)
|
||||
|
||||
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||
"create_issue", "prgs-author", "author"
|
||||
)
|
||||
# Force-path also must not use real utime when diagnostics runs
|
||||
with open(cfg, encoding="utf-8") as fh:
|
||||
after = fh.read()
|
||||
self.assertEqual(after, original)
|
||||
self.assertEqual(os.path.getmtime(cfg), mtime_before)
|
||||
mock_utime.assert_not_called()
|
||||
self.assertTrue(result.get("restart_required"), result)
|
||||
|
||||
|
||||
class TestIssue685MutationGatesStillFailClosed(unittest.TestCase):
|
||||
def test_parity_stale_still_reports_restart_required(self):
|
||||
"""Mutation-facing parity gate remains fail-closed when heads differ."""
|
||||
import master_parity_gate as mpg
|
||||
|
||||
out = mpg.assess_master_parity(
|
||||
{"startup_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
)
|
||||
self.assertFalse(out.get("in_parity"))
|
||||
self.assertTrue(out.get("restart_required") or out.get("stale"))
|
||||
|
||||
|
||||
class TestIssue685DocstringReadOnlyContract(unittest.TestCase):
|
||||
def test_resolve_docstring_declares_side_effect_free(self):
|
||||
doc = mcp_server.gitea_resolve_task_capability.__doc__ or ""
|
||||
lower = doc.lower()
|
||||
self.assertTrue(
|
||||
"side-effect" in lower or "read-only" in lower or "does not mutate" in lower,
|
||||
doc,
|
||||
)
|
||||
self.assertNotIn("auto-restart", lower)
|
||||
self.assertNotIn("os._exit", lower)
|
||||
|
||||
|
||||
class TestIssue685MergeCoexistenceWithMasterAnnotations(unittest.TestCase):
|
||||
"""After merging master: #685 reconnect blocker + #702 report-only binding."""
|
||||
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||
mcp_server.capability_stop_terminal.clear()
|
||||
|
||||
def test_resolve_uses_report_only_stale_binding_assessment(self):
|
||||
"""Capability resolve must call stale-binding assess with auto_recover=False."""
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.merge",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
profile = {
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
config = {
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"role": "author",
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||
"prgs-author"
|
||||
)
|
||||
fake_binding = {
|
||||
"classification": "missing_path",
|
||||
"active_worktree": "/tmp/gone",
|
||||
"reasons": ["path missing"],
|
||||
}
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||
"GITEA_MCP_PROFILE": "prgs-author",
|
||||
},
|
||||
clear=False,
|
||||
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=config
|
||||
), patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="test-user"
|
||||
), patch.object(
|
||||
mcp_server, "_ensure_matching_profile", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_preflight_check", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"_assess_stale_active_binding",
|
||||
return_value=fake_binding,
|
||||
) as mock_assess, patch(
|
||||
"subprocess.run", mock_run
|
||||
), patch(
|
||||
"os.path.getmtime", mock_getmtime
|
||||
), patch(
|
||||
"os.path.exists", mock_exists
|
||||
), patch(
|
||||
"os.getpid", mock_getpid
|
||||
), patch(
|
||||
"os.utime"
|
||||
) as mock_utime, patch(
|
||||
"threading.Thread"
|
||||
) as mock_thread, patch(
|
||||
"os._exit"
|
||||
) as mock_exit:
|
||||
result = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_issue", remote="prgs"
|
||||
)
|
||||
|
||||
mock_assess.assert_called()
|
||||
# Every call must be report-only (no env clear / session-state write).
|
||||
for call in mock_assess.call_args_list:
|
||||
self.assertFalse(call.kwargs.get("auto_recover", True))
|
||||
self.assertEqual(
|
||||
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||
)
|
||||
self.assertEqual(result.get("stale_binding_recovery"), fake_binding, result)
|
||||
self.assertIs(result.get("mutation_performed"), False, result)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Issue #706: immutable startup/profile canonical_repository_root capability.
|
||||
|
||||
Cross-repository MCP namespaces (e.g. ``eagenda-author``) must bind their
|
||||
canonical repository root to the *configured target repository*, not to the
|
||||
Gitea-Tools install checkout the server script lives in. These tests prove:
|
||||
|
||||
* the configured binding is resolved from profile/env with env precedence,
|
||||
* the target repository identity and git common-directory membership are
|
||||
validated (AC3),
|
||||
* the branches-only guard (#274) is enforced *inside the target repo* (AC4),
|
||||
* missing / conflicting / forged bindings fail closed (AC5),
|
||||
* prgs and mdcps namespaces stay simultaneously isolated (AC6),
|
||||
* the session binding pins the canonical root immutably,
|
||||
* no weakening of the install-root single-repo default (AC8).
|
||||
|
||||
Uses two distinct real git repositories/worktrees (AC7).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import canonical_repository_root as crr # noqa: E402
|
||||
import gitea_config # noqa: E402
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
import session_context_binding as session_ctx # noqa: E402
|
||||
|
||||
|
||||
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:
|
||||
"""Create a real git repo with one commit and a remote; return realpath root."""
|
||||
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))
|
||||
|
||||
|
||||
def _add_worktree(repo_root: str, worktree_path: Path, branch: str) -> str:
|
||||
_git(repo_root, "worktree", "add", "-q", "-b", branch, str(worktree_path))
|
||||
return os.path.realpath(str(worktree_path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# configured_canonical_root: resolution + precedence
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConfiguredCanonicalRoot(unittest.TestCase):
|
||||
def test_unconfigured_returns_none(self):
|
||||
value, source = crr.configured_canonical_root({}, {})
|
||||
self.assertIsNone(value)
|
||||
self.assertIsNone(source)
|
||||
|
||||
def test_profile_field_used(self):
|
||||
value, source = crr.configured_canonical_root(
|
||||
{"canonical_repository_root": "/repo/eAgenda"}, {}
|
||||
)
|
||||
self.assertEqual(value, "/repo/eAgenda")
|
||||
self.assertIn("profile", source)
|
||||
|
||||
def test_env_overrides_profile(self):
|
||||
value, source = crr.configured_canonical_root(
|
||||
{"canonical_repository_root": "/repo/eAgenda"},
|
||||
{crr.CANONICAL_ROOT_ENV: "/repo/other"},
|
||||
)
|
||||
self.assertEqual(value, "/repo/other")
|
||||
self.assertIn(crr.CANONICAL_ROOT_ENV, source)
|
||||
|
||||
def test_blank_values_ignored(self):
|
||||
value, source = crr.configured_canonical_root(
|
||||
{"canonical_repository_root": " "},
|
||||
{crr.CANONICAL_ROOT_ENV: ""},
|
||||
)
|
||||
self.assertIsNone(value)
|
||||
self.assertIsNone(source)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assess_canonical_repository_root: default (single-repo) path
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestUnconfiguredFallback(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = self._tmp.name
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_unconfigured_falls_back_to_process_root(self):
|
||||
root = _init_repo(
|
||||
Path(self.tmp) / "install",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||
)
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=None,
|
||||
source=None,
|
||||
expected_slug=None,
|
||||
process_project_root=root,
|
||||
)
|
||||
self.assertTrue(got["proven"])
|
||||
self.assertFalse(got["block"])
|
||||
self.assertFalse(got["configured"])
|
||||
self.assertEqual(got["canonical_repo_root"], root)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assess_canonical_repository_root: configured cross-repo path
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConfiguredCrossRepo(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self._tmp.name)
|
||||
self.install = _init_repo(
|
||||
self.tmp / "Gitea-Tools",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||
)
|
||||
self.target = _init_repo(
|
||||
self.tmp / "mcp-control-plane",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_valid_configured_root_binds_to_target(self):
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=self.target,
|
||||
source="profile canonical_repository_root",
|
||||
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||
process_project_root=self.install,
|
||||
)
|
||||
self.assertTrue(got["proven"], got.get("reasons"))
|
||||
self.assertFalse(got["block"])
|
||||
self.assertTrue(got["configured"])
|
||||
self.assertEqual(got["canonical_repo_root"], self.target)
|
||||
self.assertNotEqual(got["canonical_repo_root"], self.install)
|
||||
|
||||
def test_missing_path_fails_closed(self):
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=str(self.tmp / "does-not-exist"),
|
||||
source="profile canonical_repository_root",
|
||||
expected_slug=None,
|
||||
process_project_root=self.install,
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
self.assertFalse(got["proven"])
|
||||
self.assertTrue(any("exist" in r for r in got["reasons"]))
|
||||
|
||||
def test_non_git_path_fails_closed(self):
|
||||
plain = self.tmp / "plain"
|
||||
plain.mkdir()
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=str(plain),
|
||||
source="profile canonical_repository_root",
|
||||
expected_slug=None,
|
||||
process_project_root=self.install,
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
self.assertTrue(any("git" in r for r in got["reasons"]))
|
||||
|
||||
def test_identity_mismatch_fails_closed(self):
|
||||
# Configured root is the install repo, but session expects the target
|
||||
# repo identity: a forged/conflicting binding.
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=self.install,
|
||||
source="profile canonical_repository_root",
|
||||
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||
process_project_root=self.install,
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
self.assertTrue(any("identity" in r for r in got["reasons"]))
|
||||
|
||||
def test_unprovable_identity_blocks_when_required(self):
|
||||
noremote = _init_repo(self.tmp / "noremote-src", "x", remote_name="origin")
|
||||
_git(noremote, "remote", "remove", "origin")
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=noremote,
|
||||
source="profile canonical_repository_root",
|
||||
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||
process_project_root=self.install,
|
||||
require_binding=True,
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
|
||||
def test_repository_identity_slug_reads_remote(self):
|
||||
self.assertEqual(
|
||||
crr.repository_identity_slug(self.target),
|
||||
"Scaled-Tech-Consulting/mcp-control-plane",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace membership + branches-only enforced inside the TARGET repo (AC4)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestNamespaceContextUsesConfiguredRoot(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self._tmp.name)
|
||||
self.install = _init_repo(
|
||||
self.tmp / "Gitea-Tools",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||
)
|
||||
self.target = _init_repo(
|
||||
self.tmp / "mcp-control-plane",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
|
||||
)
|
||||
(Path(self.target) / "branches").mkdir()
|
||||
self.target_wt = _add_worktree(
|
||||
self.target,
|
||||
Path(self.target) / "branches" / "author-issue-1",
|
||||
"feat/issue-1",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_context_canonical_root_is_configured_target(self):
|
||||
ctx = nwb.resolve_namespace_mutation_context(
|
||||
role_kind="author",
|
||||
worktree_path=self.target_wt,
|
||||
process_project_root=self.install,
|
||||
env={},
|
||||
configured_canonical_root=self.target,
|
||||
)
|
||||
self.assertEqual(ctx["canonical_repo_root"], self.target)
|
||||
self.assertFalse(ctx["roots_aligned"])
|
||||
|
||||
def test_target_worktree_is_member_of_target_root(self):
|
||||
got = nwb.amw.assess_workspace_repo_membership(
|
||||
workspace_path=self.target_wt,
|
||||
canonical_repo_root=self.target,
|
||||
)
|
||||
self.assertTrue(got["proven"], got.get("reasons"))
|
||||
|
||||
def test_target_worktree_not_member_of_install_root(self):
|
||||
got = nwb.amw.assess_workspace_repo_membership(
|
||||
workspace_path=self.target_wt,
|
||||
canonical_repo_root=self.install,
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
|
||||
def test_branches_guard_passes_inside_target(self):
|
||||
assessment = nwb.assess_namespace_mutation_workspace(
|
||||
role_kind="author",
|
||||
worktree_path=self.target_wt,
|
||||
worktree=None,
|
||||
process_project_root=self.install,
|
||||
env={},
|
||||
current_branch="feat/issue-1",
|
||||
configured_canonical_root=self.target,
|
||||
)
|
||||
self.assertFalse(assessment["block"], assessment.get("reasons"))
|
||||
self.assertEqual(assessment["canonical_repo_root"], self.target)
|
||||
|
||||
def test_target_control_checkout_blocks_branches_guard(self):
|
||||
assessment = nwb.assess_namespace_mutation_workspace(
|
||||
role_kind="author",
|
||||
worktree_path=self.target, # stable target checkout, not branches/
|
||||
worktree=None,
|
||||
process_project_root=self.install,
|
||||
env={},
|
||||
current_branch="master",
|
||||
configured_canonical_root=self.target,
|
||||
)
|
||||
self.assertTrue(assessment["block"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Immutable session pin (AC1/AC5)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSessionCanonicalRootPin(unittest.TestCase):
|
||||
def setUp(self):
|
||||
os.environ["PYTEST_CURRENT_TEST"] = "t"
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
def tearDown(self):
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
def test_seed_stores_canonical_root(self):
|
||||
ctx = session_ctx.seed_session_context_if_unbound(
|
||||
profile_name="mcp-control-plane-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="svc",
|
||||
repository="mcp-control-plane",
|
||||
org="Scaled-Tech-Consulting",
|
||||
canonical_repository_root="/repo/mcp-control-plane",
|
||||
)
|
||||
self.assertEqual(ctx["canonical_repository_root"], "/repo/mcp-control-plane")
|
||||
|
||||
def test_canonical_root_drift_fails_closed(self):
|
||||
session_ctx.seed_session_context_if_unbound(
|
||||
profile_name="mcp-control-plane-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="svc",
|
||||
repository="mcp-control-plane",
|
||||
org="Scaled-Tech-Consulting",
|
||||
canonical_repository_root="/repo/mcp-control-plane",
|
||||
)
|
||||
got = session_ctx.assess_session_context(
|
||||
profile_name="mcp-control-plane-author",
|
||||
remote="prgs",
|
||||
canonical_repository_root="/repo/forged-elsewhere",
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
self.assertTrue(any("canonical" in r for r in got["reasons"]))
|
||||
|
||||
def test_matching_canonical_root_passes(self):
|
||||
session_ctx.seed_session_context_if_unbound(
|
||||
profile_name="mcp-control-plane-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="svc",
|
||||
repository="mcp-control-plane",
|
||||
org="Scaled-Tech-Consulting",
|
||||
canonical_repository_root="/repo/mcp-control-plane",
|
||||
)
|
||||
got = session_ctx.assess_session_context(
|
||||
profile_name="mcp-control-plane-author",
|
||||
remote="prgs",
|
||||
canonical_repository_root="/repo/mcp-control-plane",
|
||||
)
|
||||
self.assertFalse(got["block"], got["reasons"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simultaneous prgs / mdcps isolation (AC6)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSimultaneousIsolation(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self._tmp.name)
|
||||
self.prgs = _init_repo(
|
||||
self.tmp / "prgs-repo",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
|
||||
)
|
||||
self.mdcps = _init_repo(
|
||||
self.tmp / "mdcps-repo",
|
||||
"https://gitea.dadeschools.net/dadeschools/eAgenda.git",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_each_namespace_binds_its_own_target(self):
|
||||
a = crr.assess_canonical_repository_root(
|
||||
configured_value=self.prgs,
|
||||
source="env",
|
||||
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||
process_project_root=self.tmp.as_posix(),
|
||||
)
|
||||
b = crr.assess_canonical_repository_root(
|
||||
configured_value=self.mdcps,
|
||||
source="env",
|
||||
expected_slug="dadeschools/eAgenda",
|
||||
process_project_root=self.tmp.as_posix(),
|
||||
)
|
||||
self.assertEqual(a["canonical_repo_root"], self.prgs)
|
||||
self.assertEqual(b["canonical_repo_root"], self.mdcps)
|
||||
self.assertNotEqual(a["canonical_repo_root"], b["canonical_repo_root"])
|
||||
|
||||
def test_cross_wired_identity_blocks(self):
|
||||
# prgs path claimed under the mdcps identity → forged binding.
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=self.prgs,
|
||||
source="env",
|
||||
expected_slug="dadeschools/eAgenda",
|
||||
process_project_root=self.tmp.as_posix(),
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config validation of the profile field (AC5: malformed bindings fail closed)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConfigValidation(unittest.TestCase):
|
||||
def test_absent_is_allowed(self):
|
||||
# single-repo default: no field configured
|
||||
gitea_config._validate_canonical_repository_root("p", None)
|
||||
|
||||
def test_valid_absolute_path_ok(self):
|
||||
gitea_config._validate_canonical_repository_root(
|
||||
"p", "/Users/x/Development/mcp-control-plane"
|
||||
)
|
||||
|
||||
def test_relative_path_rejected(self):
|
||||
with self.assertRaises(gitea_config.ConfigError):
|
||||
gitea_config._validate_canonical_repository_root(
|
||||
"p", "relative/path"
|
||||
)
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
with self.assertRaises(gitea_config.ConfigError):
|
||||
gitea_config._validate_canonical_repository_root("p", " ")
|
||||
|
||||
def test_non_string_rejected(self):
|
||||
with self.assertRaises(gitea_config.ConfigError):
|
||||
gitea_config._validate_canonical_repository_root("p", ["/x"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Issue #706 F1 integration regression (review 457).
|
||||
|
||||
The unit tests in ``test_issue_706_canonical_repository_root.py`` exercise
|
||||
``crr.assess_canonical_repository_root`` / ``session_ctx.seed_session_context_if_unbound``
|
||||
with a *preselected* slug, so they never drive the real end-to-end path that
|
||||
review 457 found broken:
|
||||
|
||||
_seed_session_context -> _trusted_session_repository -> _workspace_repository_slug
|
||||
-> _local_git_remote_url (cwd=PROJECT_ROOT, always Gitea-Tools)
|
||||
|
||||
Before the fix, the session repository identity was pinned from the *install*
|
||||
checkout remote even when a cross-repository ``canonical_repository_root`` was
|
||||
configured to an external repository (e.g. mcp-control-plane). The mutation
|
||||
preflight then derived ``expected_slug`` from that install-derived pin and
|
||||
``_enforce_canonical_repository_root`` failed closed on a self-inflicted
|
||||
identity mismatch, so the stated cross-repo namespaces stayed blocked.
|
||||
|
||||
These tests use two *real* temporary git repositories and drive the live
|
||||
``mcp_server._seed_session_context`` and ``mcp_server._enforce_canonical_repository_root``
|
||||
functions, asserting the session pins the *configured target* identity and that
|
||||
enforcement accepts the target worktree — while every fail-closed property is
|
||||
preserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server # noqa: E402 # loads gitea_mcp_server.py into this namespace
|
||||
import canonical_repository_root as crr # noqa: E402
|
||||
import session_context_binding as session_ctx # noqa: E402
|
||||
|
||||
INSTALL_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
TARGET_SLUG = "Scaled-Tech-Consulting/mcp-control-plane"
|
||||
TARGET_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git"
|
||||
OTHER_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/other-repo.git"
|
||||
|
||||
|
||||
def _git(cwd: str, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _init_repo(path: Path, remote_url: str) -> 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", "prgs", 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))
|
||||
|
||||
|
||||
def _add_worktree(repo_root: str, wt: Path, branch: str) -> str:
|
||||
_git(repo_root, "worktree", "add", "-q", "-b", branch, str(wt))
|
||||
return os.path.realpath(str(wt))
|
||||
|
||||
|
||||
def _profile(role: str, *, canonical: str | None = None,
|
||||
allowed=(TARGET_SLUG,)) -> dict:
|
||||
p = {
|
||||
"profile_name": f"mcp-control-plane-{role}",
|
||||
"role": role,
|
||||
"username": "svc",
|
||||
"allowed_operations": ["gitea.read"],
|
||||
"forbidden_operations": [],
|
||||
"allowed_repositories": list(allowed),
|
||||
}
|
||||
if canonical is not None:
|
||||
p["canonical_repository_root"] = canonical
|
||||
return p
|
||||
|
||||
|
||||
class _Base(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
tmp = Path(self._tmp.name)
|
||||
self.install = _init_repo(tmp / "Gitea-Tools", INSTALL_URL)
|
||||
self.target = _init_repo(tmp / "mcp-control-plane", TARGET_URL)
|
||||
(Path(self.target) / "branches").mkdir()
|
||||
self.target_wt = _add_worktree(
|
||||
self.target, Path(self.target) / "branches" / "author-issue-1",
|
||||
"feat/issue-1",
|
||||
)
|
||||
# PROJECT_ROOT and the install git remote are the Gitea-Tools install
|
||||
# checkout — exactly the source that (mis)seeded the session before.
|
||||
self._p_root = mock.patch.object(mcp_server, "PROJECT_ROOT", self.install)
|
||||
self._p_url = mock.patch.object(
|
||||
mcp_server, "_local_git_remote_url", return_value=INSTALL_URL
|
||||
)
|
||||
self._p_root.start()
|
||||
self._p_url.start()
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
def tearDown(self):
|
||||
mock.patch.stopall()
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
def _seed(self, profile, env=None):
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
|
||||
mock.patch.dict(os.environ, env or {}, clear=False):
|
||||
return mcp_server._seed_session_context(
|
||||
profile=profile, remote="prgs", host="gitea.prgs.cc",
|
||||
identity="svc",
|
||||
)
|
||||
|
||||
def _enforce(self, profile, env=None):
|
||||
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
|
||||
mock.patch.dict(os.environ, env or {}, clear=False):
|
||||
mcp_server._enforce_canonical_repository_root(
|
||||
self.target_wt, remote="prgs"
|
||||
)
|
||||
|
||||
|
||||
class TestF1SeedsConfiguredTargetIdentity(_Base):
|
||||
def test_env_config_seeds_target_not_install(self):
|
||||
ctx = self._seed(
|
||||
_profile("reviewer"),
|
||||
env={crr.CANONICAL_ROOT_ENV: self.target},
|
||||
)
|
||||
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
|
||||
self.assertEqual(ctx["repository"], "mcp-control-plane")
|
||||
# Regression guard: must NOT be the install repo.
|
||||
self.assertNotEqual(ctx["repository"], "Gitea-Tools")
|
||||
|
||||
def test_profile_field_seeds_target_not_install(self):
|
||||
ctx = self._seed(_profile("merger", canonical=self.target))
|
||||
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
|
||||
self.assertEqual(ctx["repository"], "mcp-control-plane")
|
||||
self.assertNotEqual(ctx["repository"], "Gitea-Tools")
|
||||
|
||||
def test_enforce_accepts_target_after_seed_reviewer(self):
|
||||
prof = _profile("reviewer", canonical=self.target)
|
||||
self._seed(prof)
|
||||
# Would raise RuntimeError on the self-inflicted identity mismatch
|
||||
# before the fix.
|
||||
self._enforce(prof)
|
||||
|
||||
def test_enforce_accepts_target_after_seed_merger(self):
|
||||
prof = _profile("merger", canonical=self.target)
|
||||
self._seed(prof)
|
||||
self._enforce(prof)
|
||||
|
||||
def test_env_overrides_profile_for_seed(self):
|
||||
# profile points at install; env points at the real target → env wins.
|
||||
prof = _profile("reviewer", canonical=self.install,
|
||||
allowed=(TARGET_SLUG,))
|
||||
ctx = self._seed(prof, env={crr.CANONICAL_ROOT_ENV: self.target})
|
||||
self.assertEqual(ctx["repository"], "mcp-control-plane")
|
||||
|
||||
|
||||
class TestUnconfiguredDefaultUnchanged(_Base):
|
||||
def test_unconfigured_keeps_install_identity(self):
|
||||
prof = _profile("author", allowed=("Scaled-Tech-Consulting/Gitea-Tools",))
|
||||
ctx = self._seed(prof) # no env, no profile canonical field
|
||||
self.assertEqual(ctx["repository"], "Gitea-Tools")
|
||||
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
|
||||
|
||||
|
||||
class TestFailClosed(_Base):
|
||||
def _trusted(self, profile, env=None, *, for_mutation=True):
|
||||
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
|
||||
mock.patch.dict(os.environ, env or {}, clear=False):
|
||||
return mcp_server._trusted_session_repository(
|
||||
profile, "prgs", for_mutation=for_mutation
|
||||
)
|
||||
|
||||
def test_nonexistent_configured_root_fails_closed(self):
|
||||
res = self._trusted(
|
||||
_profile("reviewer"),
|
||||
env={crr.CANONICAL_ROOT_ENV: self.target + "-missing"},
|
||||
)
|
||||
self.assertIsNone(res["repository"])
|
||||
self.assertTrue(res["reasons"])
|
||||
|
||||
def test_non_git_configured_root_fails_closed(self):
|
||||
plain = Path(self._tmp.name) / "plain"
|
||||
plain.mkdir()
|
||||
res = self._trusted(
|
||||
_profile("reviewer"), env={crr.CANONICAL_ROOT_ENV: str(plain)}
|
||||
)
|
||||
self.assertIsNone(res["repository"])
|
||||
self.assertTrue(any("git" in r for r in res["reasons"]))
|
||||
|
||||
def test_unallowlisted_target_identity_fails_closed(self):
|
||||
# Configured target is valid, but the profile does not authorize it.
|
||||
res = self._trusted(
|
||||
_profile("reviewer", canonical=self.target,
|
||||
allowed=("Scaled-Tech-Consulting/Gitea-Tools",)),
|
||||
)
|
||||
self.assertIsNone(res["repository"])
|
||||
self.assertTrue(any("scope" in r.lower() for r in res["reasons"]))
|
||||
|
||||
def test_forged_identity_conflict_fails_closed_at_enforce(self):
|
||||
# Seed the target, then present a *different* configured root at
|
||||
# enforcement time: the session pin no longer matches → fail closed.
|
||||
other = _init_repo(Path(self._tmp.name) / "other", OTHER_URL)
|
||||
prof_seed = _profile("reviewer", canonical=self.target)
|
||||
self._seed(prof_seed)
|
||||
prof_drift = _profile(
|
||||
"reviewer", canonical=other,
|
||||
allowed=(TARGET_SLUG, "Scaled-Tech-Consulting/other-repo"),
|
||||
)
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._enforce(prof_drift)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,509 @@
|
||||
"""#714: production first-bind pins the workspace-verified repository scope.
|
||||
|
||||
These tests drive the real entry points (``gitea_whoami``,
|
||||
``gitea_get_runtime_context``, ``gitea_resolve_task_capability``,
|
||||
``gitea_activate_profile``) in production order. They deliberately do not
|
||||
construct a ``_SessionContext`` directly: the defect they cover is that the
|
||||
production first-bind path left ``repository``/``org`` unbound, so
|
||||
``assess_session_context`` skipped its repository/org drift checks and a
|
||||
same-host mutation against another repository was not blocked.
|
||||
|
||||
The trusted repository identity comes from the workspace-aligned git remote
|
||||
(``Scaled-Tech-Consulting/Gitea-Tools`` for this checkout), never from
|
||||
``REMOTES`` (whose ``prgs`` default repo is ``Timesheet``) and never from a
|
||||
caller-supplied ``org``/``repo`` argument.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
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
|
||||
|
||||
WORKSPACE_ORG = "Scaled-Tech-Consulting"
|
||||
WORKSPACE_REPO = "Gitea-Tools"
|
||||
WORKSPACE_SLUG = f"{WORKSPACE_ORG}/{WORKSPACE_REPO}"
|
||||
WORKSPACE_URL = f"https://gitea.prgs.cc/{WORKSPACE_SLUG}.git"
|
||||
|
||||
_BASE_AUTHOR_OPS = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
|
||||
|
||||
def _config(allowed_repositories=None):
|
||||
profile = {
|
||||
"enabled": True,
|
||||
"context": "prgs",
|
||||
"role": "author",
|
||||
"username": "jcwalker3",
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
|
||||
"allowed_operations": list(_BASE_AUTHOR_OPS),
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"execution_profile": "prgs-author",
|
||||
}
|
||||
if allowed_repositories is not None:
|
||||
profile["allowed_repositories"] = list(allowed_repositories)
|
||||
return {
|
||||
"version": 2,
|
||||
# v2-contexts normalizes the config and keeps only rules.* — a
|
||||
# top-level allow_runtime_switching would be dropped.
|
||||
"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": {"prgs-author": profile},
|
||||
}
|
||||
|
||||
|
||||
class _ProductionOrderBase(unittest.TestCase):
|
||||
"""Real entry points, pinned workspace remote, mocked network only."""
|
||||
|
||||
allowed_repositories = [WORKSPACE_SLUG]
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(_config(self.allowed_repositories)))
|
||||
self._env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": "prgs-author",
|
||||
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||
}
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
srv._MUTATION_AUTHORITY = None
|
||||
# Pin the workspace git remote so the trusted source is deterministic
|
||||
# and never depends on the developer's checkout layout.
|
||||
self._remote_url = patch.object(
|
||||
srv, "_local_git_remote_url", side_effect=self._local_remote_url
|
||||
)
|
||||
self._remote_url.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._remote_url.stop()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
srv._MUTATION_AUTHORITY = None
|
||||
self._dir.cleanup()
|
||||
|
||||
def _local_remote_url(self, remote_name):
|
||||
return WORKSPACE_URL if remote_name == "prgs" else None
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class TestProductionFirstBindPinsRepository(_ProductionOrderBase):
|
||||
def test_whoami_first_bind_is_complete(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertIsNotNone(ctx)
|
||||
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||
self.assertEqual(ctx["remote"], "prgs")
|
||||
self.assertEqual(ctx["host"], "gitea.prgs.cc")
|
||||
|
||||
def test_runtime_context_first_bind_is_complete(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_get_runtime_context(remote="prgs")
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||
|
||||
def test_capability_preflight_first_bind_is_complete(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||
|
||||
def test_activate_profile_binds_complete_context(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||
|
||||
|
||||
class TestProductionOrderRepositoryDriftBlocks(_ProductionOrderBase):
|
||||
def test_whoami_then_same_host_other_repository_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("Other-Tools" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||
)
|
||||
|
||||
def test_whoami_then_timesheet_blocks(self):
|
||||
"""REMOTES['prgs'].repo is Timesheet — a default target, not a scope."""
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs",
|
||||
org=WORKSPACE_ORG,
|
||||
repo="Timesheet",
|
||||
org_explicit=True,
|
||||
repo_explicit=True,
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("Timesheet" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||
)
|
||||
|
||||
def test_whoami_then_same_host_other_org_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org="Other-Org", repo=WORKSPACE_REPO
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("Other-Org" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||
)
|
||||
|
||||
def test_runtime_context_then_other_repository_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_get_runtime_context(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
def test_capability_preflight_then_other_repository_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
def test_activate_profile_then_other_repository_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org="Other-Org", repo="Other-Tools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
def test_cross_host_still_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(remote="dadeschools")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("cross-host" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||
)
|
||||
|
||||
def test_authorized_repository_mutation_remains_allowed(self):
|
||||
"""Normal PR #715 author comment/push path must stay functional."""
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
self.assertIsNone(
|
||||
srv._session_context_mutation_block(
|
||||
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
|
||||
)
|
||||
)
|
||||
# A bare call with no override resolves to the same bound scope.
|
||||
self.assertIsNone(srv._session_context_mutation_block(remote="prgs"))
|
||||
|
||||
|
||||
class TestMutationRequestCannotEstablishBinding(_ProductionOrderBase):
|
||||
def test_caller_values_cannot_establish_binding_on_first_mutation(self):
|
||||
"""A mutation-first session must not be pinned by request values."""
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
srv._session_context_mutation_block(
|
||||
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
|
||||
)
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertIsNotNone(ctx)
|
||||
# Bound to the verified workspace, never to the request values.
|
||||
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||
|
||||
def test_mutation_first_with_attacker_values_is_blocked(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
def test_later_call_cannot_overwrite_bound_repository(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
before = session_ctx.get_session_context()
|
||||
for _ in range(3):
|
||||
srv._session_context_mutation_block(
|
||||
remote="prgs", org="Other-Org", repo="Other-Tools"
|
||||
)
|
||||
self.assertEqual(session_ctx.get_session_context(), before)
|
||||
|
||||
|
||||
class TestUnverifiedWorkspaceFailsClosed(_ProductionOrderBase):
|
||||
def _local_remote_url(self, remote_name):
|
||||
return None # no verifiable workspace repository
|
||||
|
||||
def test_mutation_blocks_when_workspace_repository_unverified(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertIsNone(ctx["repository"])
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"no verified workspace repository" in r or "unverified" in r
|
||||
for r in blocked["reasons"]
|
||||
),
|
||||
blocked["reasons"],
|
||||
)
|
||||
|
||||
def test_activate_profile_rejects_unverifiable_workspace(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||
self.assertFalse(res.get("success", True))
|
||||
self.assertEqual(res.get("blocker_kind"), "repository_scope")
|
||||
|
||||
|
||||
class TestUnauthorizedWorkspaceFailsClosed(_ProductionOrderBase):
|
||||
allowed_repositories = ["Scaled-Tech-Consulting/Some-Other-Project"]
|
||||
|
||||
def test_workspace_absent_from_allowlist_blocks_mutation(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("not authorized" in r for r in blocked["reasons"]),
|
||||
blocked["reasons"],
|
||||
)
|
||||
|
||||
def test_workspace_absent_from_allowlist_blocks_activation(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||
self.assertFalse(res.get("success", True))
|
||||
self.assertEqual(res.get("blocker_kind"), "repository_scope")
|
||||
|
||||
|
||||
class TestTimesheetNotAuthorizedInThisPhase(_ProductionOrderBase):
|
||||
"""Cross-project use is paused: only Gitea-Tools is authorized."""
|
||||
|
||||
def test_timesheet_workspace_is_rejected(self):
|
||||
def timesheet_remote(remote_name):
|
||||
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Timesheet.git"
|
||||
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
with patch.object(
|
||||
srv, "_local_git_remote_url", side_effect=timesheet_remote
|
||||
):
|
||||
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("not authorized" in r for r in blocked["reasons"]),
|
||||
blocked["reasons"],
|
||||
)
|
||||
|
||||
|
||||
class TestConcurrentFirstBindSelectsOneRepository(_ProductionOrderBase):
|
||||
def test_concurrent_initialization_cannot_change_selected_repository(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
thread_count = 8
|
||||
barrier = threading.Barrier(thread_count)
|
||||
results = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def racer(index: int) -> None:
|
||||
barrier.wait()
|
||||
srv._session_context_mutation_block(
|
||||
remote="prgs", org=f"Racer-Org-{index}", repo=f"Racer-Repo-{index}"
|
||||
)
|
||||
with lock:
|
||||
results.append(session_ctx.get_session_context())
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=racer, args=(i,)) for i in range(thread_count)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=10)
|
||||
|
||||
self.assertFalse(any(t.is_alive() for t in threads))
|
||||
winner = session_ctx.get_session_context()
|
||||
self.assertEqual(winner["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(winner["repository"], WORKSPACE_REPO)
|
||||
self.assertEqual(results, [winner] * thread_count)
|
||||
|
||||
|
||||
class TestRepositoryScopeUnits(unittest.TestCase):
|
||||
def test_absent_scope_is_not_enforced_for_read_diagnostics(self):
|
||||
scope = session_ctx.assess_repository_scope(
|
||||
workspace_slug=WORKSPACE_SLUG, allowed=[], profile_name="legacy"
|
||||
)
|
||||
self.assertTrue(scope["proven"])
|
||||
self.assertFalse(scope["scope_enforced"])
|
||||
|
||||
def test_require_scope_blocks_empty_or_missing_allowlist(self):
|
||||
scope = session_ctx.assess_repository_scope(
|
||||
workspace_slug=WORKSPACE_SLUG,
|
||||
allowed=[],
|
||||
profile_name="legacy",
|
||||
require_scope=True,
|
||||
)
|
||||
self.assertTrue(scope["block"])
|
||||
self.assertTrue(any("allowed_repositories" in r for r in scope["reasons"]))
|
||||
|
||||
def test_declared_scope_authorizes_only_listed_repository(self):
|
||||
allowed = session_ctx.declared_allowed_repositories(
|
||||
{"allowed_repositories": [WORKSPACE_SLUG]}
|
||||
)
|
||||
self.assertEqual(allowed, [WORKSPACE_SLUG])
|
||||
self.assertTrue(
|
||||
session_ctx.assess_repository_scope(
|
||||
workspace_slug=WORKSPACE_SLUG, allowed=allowed
|
||||
)["proven"]
|
||||
)
|
||||
self.assertTrue(
|
||||
session_ctx.assess_repository_scope(
|
||||
workspace_slug="Scaled-Tech-Consulting/Timesheet", allowed=allowed
|
||||
)["block"]
|
||||
)
|
||||
|
||||
def test_missing_workspace_slug_blocks_when_scope_declared(self):
|
||||
scope = session_ctx.assess_repository_scope(
|
||||
workspace_slug=None, allowed=[WORKSPACE_SLUG]
|
||||
)
|
||||
self.assertTrue(scope["block"])
|
||||
|
||||
def test_override_must_match_binding(self):
|
||||
self.assertTrue(
|
||||
session_ctx.assess_repository_override(
|
||||
requested_org=WORKSPACE_ORG,
|
||||
requested_repo="Other",
|
||||
bound_org=WORKSPACE_ORG,
|
||||
bound_repo=WORKSPACE_REPO,
|
||||
)["block"]
|
||||
)
|
||||
self.assertTrue(
|
||||
session_ctx.assess_repository_override(
|
||||
requested_org="Other-Org",
|
||||
requested_repo=WORKSPACE_REPO,
|
||||
bound_org=WORKSPACE_ORG,
|
||||
bound_repo=WORKSPACE_REPO,
|
||||
)["block"]
|
||||
)
|
||||
self.assertTrue(
|
||||
session_ctx.assess_repository_override(
|
||||
requested_org=WORKSPACE_ORG,
|
||||
requested_repo=WORKSPACE_REPO,
|
||||
bound_org=WORKSPACE_ORG,
|
||||
bound_repo=WORKSPACE_REPO,
|
||||
)["proven"]
|
||||
)
|
||||
|
||||
def test_malformed_allowlist_entries_are_ignored_in_non_strict_mode(self):
|
||||
self.assertEqual(
|
||||
session_ctx.declared_allowed_repositories(
|
||||
{"allowed_repositories": ["not-a-slug", 17, None, WORKSPACE_SLUG]}
|
||||
),
|
||||
[WORKSPACE_SLUG],
|
||||
)
|
||||
|
||||
def test_strict_mode_rejects_malformed_allowlist_entries(self):
|
||||
with self.assertRaises(ValueError):
|
||||
session_ctx.declared_allowed_repositories(
|
||||
{"allowed_repositories": ["not-a-slug", WORKSPACE_SLUG]},
|
||||
strict=True,
|
||||
)
|
||||
|
||||
|
||||
class TestRemotesDefaultNotCallerOverride(_ProductionOrderBase):
|
||||
def test_resolved_timesheet_default_does_not_block_when_bound_to_workspace(self):
|
||||
"""Tools historically pass REMOTES-filled Timesheet after _resolve; that
|
||||
must not be treated as an explicit override against Gitea-Tools."""
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
# Simulate create_issue after resolve: org/repo are REMOTES defaults
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Timesheet",
|
||||
)
|
||||
self.assertIsNone(blocked, getattr(blocked, "get", lambda *_: blocked)("reasons") if blocked else None)
|
||||
|
||||
def test_git_unavailable_blocks_mutation(self):
|
||||
def no_remote(_name):
|
||||
return None
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
with patch.object(srv, "_local_git_remote_url", side_effect=no_remote):
|
||||
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"workspace" in r.lower() or "allowed_repositories" in r or "unverified" in r
|
||||
for r in blocked["reasons"]
|
||||
),
|
||||
blocked["reasons"],
|
||||
)
|
||||
|
||||
def test_explicit_mismatch_still_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs",
|
||||
org=WORKSPACE_ORG,
|
||||
repo="Other-Tools",
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,745 @@
|
||||
"""#714: fail closed on cross-host MCP profile drift and capability substitution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_config # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
import session_context_binding as session_ctx # noqa: E402
|
||||
|
||||
|
||||
CONFIG_714 = {
|
||||
"version": 2,
|
||||
"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": {
|
||||
"prgs-author": {
|
||||
"enabled": True,
|
||||
"context": "prgs",
|
||||
"role": "author",
|
||||
"username": "jcwalker3",
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||
"execution_profile": "prgs-author",
|
||||
},
|
||||
"mdcps-reviewer": {
|
||||
"enabled": True,
|
||||
"context": "mdcps",
|
||||
"role": "reviewer",
|
||||
"username": "913443",
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_REVIEWER"},
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.merge",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"allowed_repositories": ["913443/eAgenda"],
|
||||
"execution_profile": "mdcps-reviewer",
|
||||
},
|
||||
"mdcps-author": {
|
||||
"enabled": True,
|
||||
"context": "mdcps",
|
||||
"role": "author",
|
||||
"username": "913443",
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_AUTHOR"},
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"allowed_repositories": ["913443/eAgenda"],
|
||||
"execution_profile": "mdcps-author",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestIssue714SessionContextImmutability(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG_714))
|
||||
self._remotes = patch.dict(
|
||||
mcp_server.REMOTES,
|
||||
{
|
||||
"dadeschools": {
|
||||
"host": "gitea.dadeschools.net",
|
||||
"org": "913443",
|
||||
"repo": "eAgenda",
|
||||
},
|
||||
"prgs": {
|
||||
"host": "gitea.prgs.cc",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
},
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._remotes.start()
|
||||
def _url(name):
|
||||
if name == "dadeschools":
|
||||
return "https://gitea.dadeschools.net/913443/eAgenda.git"
|
||||
if name == "prgs":
|
||||
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
return None
|
||||
self._url_patch = patch.object(mcp_server, "_local_git_remote_url", side_effect=_url)
|
||||
self._url_patch.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._MUTATION_AUTHORITY = None
|
||||
self._env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": "mdcps-reviewer",
|
||||
"GITEA_TOKEN_MDCPS_REVIEWER": "mdcps-reviewer-token",
|
||||
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
|
||||
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
self._url_patch.stop()
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._MUTATION_AUTHORITY = None
|
||||
self._dir.cleanup()
|
||||
|
||||
def _api_side_effect(self, method, url, header):
|
||||
# Token identity mapping for whoami endpoint
|
||||
auth = str(header)
|
||||
if "mdcps-reviewer-token" in auth or "mdcps-author-token" in auth:
|
||||
login = "913443"
|
||||
else:
|
||||
login = "jcwalker3"
|
||||
return {"login": login, "full_name": "Test", "id": 1, "email": "[email protected]"}
|
||||
|
||||
def test_pin_mdcps_reviewer_never_drifts_to_prgs_author(self):
|
||||
"""Reproduce the incident: pin mdcps-reviewer then resolve comment_issue."""
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
act = mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
self.assertTrue(act["success"])
|
||||
self.assertEqual(act["after_profile"], "mdcps-reviewer")
|
||||
|
||||
who1 = mcp_server.gitea_whoami(remote="dadeschools")
|
||||
self.assertEqual(who1["profile"]["profile_name"], "mdcps-reviewer")
|
||||
self.assertEqual(who1["username"], "913443")
|
||||
|
||||
# Capability that mdcps-reviewer lacks — must NOT switch to prgs-author
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="comment_issue", remote="dadeschools"
|
||||
)
|
||||
self.assertEqual(res["active_profile"], "mdcps-reviewer")
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertFalse(res.get("auto_profile_substitution", True))
|
||||
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||
self.assertIn("mdcps-author", res["matching_configured_profile"])
|
||||
|
||||
who2 = mcp_server.gitea_whoami(remote="dadeschools")
|
||||
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
||||
self.assertEqual(who2["profile"]["profile_name"], "mdcps-reviewer")
|
||||
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
|
||||
# Still pinned — never prgs-author
|
||||
self.assertEqual(
|
||||
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
||||
)
|
||||
|
||||
def test_repeated_calls_remain_on_mdcps_reviewer(self):
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
for _ in range(5):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="review_pr", remote="dadeschools"
|
||||
)
|
||||
self.assertEqual(res["active_profile"], "mdcps-reviewer")
|
||||
who = mcp_server.gitea_whoami(remote="dadeschools")
|
||||
self.assertEqual(who["profile"]["profile_name"], "mdcps-reviewer")
|
||||
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
||||
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
|
||||
|
||||
def test_dadeschools_request_cannot_resolve_through_prgs_author(self):
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="comment_issue", remote="dadeschools"
|
||||
)
|
||||
self.assertNotEqual(res["active_profile"], "prgs-author")
|
||||
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||
# Gate must not silently switch either
|
||||
blocked = mcp_server._profile_permission_block(
|
||||
"gitea.issue.comment", remote="dadeschools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertFalse(blocked.get("success", True))
|
||||
self.assertEqual(
|
||||
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
||||
)
|
||||
|
||||
def test_prgs_request_cannot_resolve_through_mdcps_profile(self):
|
||||
"""Reverse of the incident: a pinned MDCPS profile must never serve a
|
||||
prgs request, nor be silently swapped for the prgs-side profile."""
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-author", remote="dadeschools"
|
||||
)
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_issue", remote="prgs"
|
||||
)
|
||||
self.assertNotEqual(res["active_profile"], "prgs-author")
|
||||
self.assertEqual(res["active_profile"], "mdcps-author")
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertTrue(res["stop_required"])
|
||||
blocked = mcp_server._session_context_mutation_block(remote="prgs")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(any("cross-host" in r for r in blocked["reasons"]))
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "mdcps-author")
|
||||
|
||||
def test_unsupported_capability_fails_closed_structured(self):
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="comment_issue", remote="dadeschools"
|
||||
)
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertTrue(res["stop_required"])
|
||||
self.assertIn("reason", res)
|
||||
self.assertIn("exact_safe_next_action", res)
|
||||
self.assertIn("activate_profile", res["exact_safe_next_action"])
|
||||
# Unknown task still fail closed (structured denial after #723;
|
||||
# never raises into internal_error and never substitutes profile).
|
||||
unknown = mcp_server.gitea_resolve_task_capability(
|
||||
task="reopen_issue", remote="dadeschools"
|
||||
)
|
||||
self.assertFalse(unknown.get("allowed_in_current_session"))
|
||||
self.assertTrue(unknown.get("stop_required"))
|
||||
self.assertEqual(unknown.get("reason_code"), "unknown_task")
|
||||
self.assertEqual(unknown.get("mutation_performed"), False)
|
||||
self.assertEqual(unknown.get("active_profile"), "mdcps-reviewer")
|
||||
self.assertNotEqual(unknown.get("active_profile"), "prgs-author")
|
||||
|
||||
def test_identity_mismatch_blocks_mutation(self):
|
||||
"""Profile expects 913443 but authenticated as jcwalker3."""
|
||||
|
||||
def wrong_identity(method, url, header):
|
||||
return {
|
||||
"login": "jcwalker3",
|
||||
"full_name": "Wrong",
|
||||
"id": 9,
|
||||
"email": "[email protected]",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=wrong_identity):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
blocked = mcp_server._session_context_mutation_block(
|
||||
remote="dadeschools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("identity mismatch" in r for r in blocked["reasons"])
|
||||
)
|
||||
|
||||
def test_repository_host_mismatch_blocks_mutation(self):
|
||||
"""mdcps-reviewer cannot serve prgs remote."""
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
blocked = mcp_server._session_context_mutation_block(remote="prgs")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("cross-host" in r for r in blocked["reasons"])
|
||||
)
|
||||
|
||||
def test_drift_cannot_reach_write(self):
|
||||
"""Simulated mid-session profile override cannot pass permission gate."""
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
# Bind session as mdcps-reviewer
|
||||
self.assertEqual(
|
||||
session_ctx.get_session_context()["profile_name"],
|
||||
"mdcps-reviewer",
|
||||
)
|
||||
# Hostile override without activate_profile
|
||||
gitea_config._active_profile_override = "prgs-author"
|
||||
blocked = mcp_server._session_context_mutation_block(
|
||||
remote="dadeschools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(any("drift" in r or "cross-host" in r for r in blocked["reasons"]))
|
||||
|
||||
def test_static_prgs_author_still_works(self):
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": "prgs-author",
|
||||
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
gitea_config._active_profile_override = None
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_issue", remote="prgs"
|
||||
)
|
||||
self.assertEqual(res["active_profile"], "prgs-author")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertEqual(res["active_identity"], "jcwalker3")
|
||||
|
||||
def test_static_mdcps_author_still_works(self):
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": "mdcps-author",
|
||||
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
gitea_config._active_profile_override = None
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="comment_issue", remote="dadeschools"
|
||||
)
|
||||
self.assertEqual(res["active_profile"], "mdcps-author")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||
|
||||
|
||||
class TestSessionContextBindingUnit(unittest.TestCase):
|
||||
def test_profile_matches_remote_by_base_url(self):
|
||||
remotes = {
|
||||
"dadeschools": {"host": "gitea.dadeschools.net"},
|
||||
"prgs": {"host": "gitea.prgs.cc"},
|
||||
}
|
||||
mdcps = {"base_url": "https://gitea.dadeschools.net", "context": "mdcps"}
|
||||
prgs = {"base_url": "https://gitea.prgs.cc", "context": "prgs"}
|
||||
self.assertTrue(
|
||||
session_ctx.profile_matches_remote(mdcps, "dadeschools", remotes)
|
||||
)
|
||||
self.assertFalse(session_ctx.profile_matches_remote(mdcps, "prgs", remotes))
|
||||
self.assertTrue(session_ctx.profile_matches_remote(prgs, "prgs", remotes))
|
||||
|
||||
def test_bind_and_detect_drift(self):
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
source="test",
|
||||
)
|
||||
ok = session_ctx.assess_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
)
|
||||
self.assertTrue(ok["proven"])
|
||||
bad = session_ctx.assess_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="jcwalker3",
|
||||
)
|
||||
self.assertTrue(bad["block"])
|
||||
self.assertTrue(any("drift" in r for r in bad["reasons"]))
|
||||
|
||||
def test_bound_context_survives_multiple_calls_and_exceptions(self):
|
||||
original = session_ctx.bind_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
source="test",
|
||||
)
|
||||
|
||||
try:
|
||||
for _ in range(3):
|
||||
observed = session_ctx.seed_session_context_if_unbound(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
source="interleaved-test-call",
|
||||
)
|
||||
self.assertEqual(observed, original)
|
||||
raise RuntimeError("simulated caller failure")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
self.assertEqual(session_ctx.get_session_context(), original)
|
||||
drift = session_ctx.assess_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
require_bound=True,
|
||||
)
|
||||
self.assertTrue(drift["block"])
|
||||
|
||||
def test_parallel_calls_cannot_overwrite_established_binding(self):
|
||||
original = session_ctx.bind_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
source="test",
|
||||
)
|
||||
barrier = threading.Barrier(9)
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def competing_seed(index: int) -> None:
|
||||
barrier.wait()
|
||||
result = session_ctx.seed_session_context_if_unbound(
|
||||
profile_name=f"prgs-author-{index}",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity=f"other-{index}",
|
||||
source="parallel-test-call",
|
||||
)
|
||||
with results_lock:
|
||||
results.append(result)
|
||||
|
||||
threads = [threading.Thread(target=competing_seed, args=(i,)) for i in range(8)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
barrier.wait()
|
||||
for thread in threads:
|
||||
thread.join(timeout=5)
|
||||
|
||||
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||
self.assertEqual(results, [original] * 8)
|
||||
self.assertEqual(session_ctx.get_session_context(), original)
|
||||
|
||||
def test_concurrent_initialization_has_single_winning_binding(self):
|
||||
"""Racing first-binds from an unbound context: exactly one winner, and
|
||||
every racer observes that same complete binding (never a partial one)."""
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
thread_count = 8
|
||||
barrier = threading.Barrier(thread_count)
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def racing_seed(index: int) -> None:
|
||||
barrier.wait()
|
||||
result = session_ctx.seed_session_context_if_unbound(
|
||||
profile_name=f"prgs-author-{index}",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity=f"user-{index}",
|
||||
source="concurrent-init-test",
|
||||
)
|
||||
with results_lock:
|
||||
results.append(result)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=racing_seed, args=(i,))
|
||||
for i in range(thread_count)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=5)
|
||||
|
||||
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||
winner = session_ctx.get_session_context()
|
||||
self.assertIsNotNone(winner)
|
||||
self.assertEqual(len(results), thread_count)
|
||||
self.assertEqual(results, [winner] * thread_count)
|
||||
# A losing racer's identity must never be spliced into the winner.
|
||||
self.assertEqual(
|
||||
winner["identity"], winner["profile_name"].replace("prgs-author-", "user-")
|
||||
)
|
||||
|
||||
def test_repository_mismatch_blocks_before_mutation(self):
|
||||
"""Same host and profile, different repository, must still fail closed."""
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
repository="Gitea-Tools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
source="test",
|
||||
)
|
||||
same = session_ctx.assess_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
repository="Gitea-Tools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
require_bound=True,
|
||||
)
|
||||
self.assertTrue(same["proven"])
|
||||
other_repo = session_ctx.assess_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
repository="eAgenda",
|
||||
org="Scaled-Tech-Consulting",
|
||||
require_bound=True,
|
||||
)
|
||||
self.assertTrue(other_repo["block"])
|
||||
self.assertTrue(
|
||||
any("repository drift" in r for r in other_repo["reasons"])
|
||||
)
|
||||
other_org = session_ctx.assess_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
repository="Gitea-Tools",
|
||||
org="913443",
|
||||
require_bound=True,
|
||||
)
|
||||
self.assertTrue(other_org["block"])
|
||||
self.assertTrue(any("org drift" in r for r in other_org["reasons"]))
|
||||
|
||||
def test_returned_snapshot_cannot_mutate_binding(self):
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
source="test",
|
||||
)
|
||||
snapshot = session_ctx.get_session_context()
|
||||
snapshot["profile_name"] = "prgs-author"
|
||||
self.assertEqual(
|
||||
session_ctx.get_session_context()["profile_name"], "mdcps-reviewer"
|
||||
)
|
||||
|
||||
|
||||
class TestSessionContextTestBoundaryIsolation(unittest.TestCase):
|
||||
def test_mdcps_binding_starts_clean_and_does_not_escape_test(self):
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
source="test-boundary",
|
||||
)
|
||||
|
||||
def test_prgs_binding_starts_clean_and_does_not_escape_test(self):
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
source="test-boundary",
|
||||
)
|
||||
|
||||
def test_reset_helper_is_rejected_outside_pytest_boundary(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaises(RuntimeError):
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
|
||||
class TestIssue714MergeCoexistenceWithMaster(unittest.TestCase):
|
||||
"""Conflict-resolution regressions after merging advanced master into #715.
|
||||
|
||||
Preserves:
|
||||
- #714 immutable session / no auto profile substitution
|
||||
- #685 side-effect-free resolve (auto_recover=False)
|
||||
- #709 actor identity helper from master
|
||||
"""
|
||||
|
||||
def test_auto_switch_helpers_remain_fail_closed(self):
|
||||
self.assertFalse(mcp_server._try_auto_switch_for_operation("gitea.pr.review"))
|
||||
self.assertFalse(
|
||||
mcp_server._try_auto_switch_for_operation(
|
||||
"gitea.issue.comment", host="gitea.prgs.cc"
|
||||
)
|
||||
)
|
||||
# Active profile is prgs-author; cannot match mdcps review permission
|
||||
# and must not switch.
|
||||
with patch.object(
|
||||
mcp_server,
|
||||
"get_profile",
|
||||
return_value={
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": ["gitea.read", "gitea.issue.create"],
|
||||
"forbidden_operations": ["gitea.pr.approve"],
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"context": "prgs",
|
||||
},
|
||||
):
|
||||
matched = mcp_server._ensure_matching_profile(
|
||||
"gitea.pr.approve", "reviewer", remote="prgs"
|
||||
)
|
||||
self.assertIsNone(matched)
|
||||
self.assertNotEqual(
|
||||
gitea_config.selected_profile_name() or "prgs-author",
|
||||
"mdcps-reviewer",
|
||||
)
|
||||
|
||||
def test_authenticated_actor_helper_survives_merge(self):
|
||||
"""Master #709 F7 actor identity coexists with #714 immutability."""
|
||||
self.assertTrue(hasattr(mcp_server, "_authenticated_actor"))
|
||||
with patch(
|
||||
"mcp_server.get_auth_header", return_value={"Authorization": "token x"}
|
||||
), patch(
|
||||
"mcp_server.api_request",
|
||||
return_value={"id": 42, "login": "sysadmin"},
|
||||
):
|
||||
mcp_server._ACTOR_IDENTITY_CACHE.clear()
|
||||
actor = mcp_server._authenticated_actor("gitea.prgs.cc")
|
||||
self.assertEqual(actor.get("user_id"), 42)
|
||||
self.assertEqual(actor.get("login"), "sysadmin")
|
||||
# Cached; second call does not re-request
|
||||
with patch("mcp_server.api_request") as mock_api:
|
||||
actor2 = mcp_server._authenticated_actor("gitea.prgs.cc")
|
||||
mock_api.assert_not_called()
|
||||
self.assertEqual(actor2.get("user_id"), 42)
|
||||
|
||||
def test_resolve_still_report_only_after_master_merge(self):
|
||||
"""#685: resolve path must not pass auto_recover=True after conflict merge."""
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
profile = {
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"context": "prgs",
|
||||
}
|
||||
config = {
|
||||
"contexts": {
|
||||
"prgs": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"role": "author",
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"context": "prgs",
|
||||
}
|
||||
},
|
||||
}
|
||||
fake_binding = {
|
||||
"classification": "unbound",
|
||||
"active_worktree": None,
|
||||
"reasons": [],
|
||||
}
|
||||
with patch.dict(
|
||||
os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False
|
||||
), patch.object(
|
||||
mcp_server, "get_profile", return_value=profile
|
||||
), patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=config
|
||||
), patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="jcwalker3"
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"_assess_stale_active_binding",
|
||||
return_value=fake_binding,
|
||||
) as mock_assess, patch.object(
|
||||
mcp_server, "record_preflight_check", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
):
|
||||
result = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_issue", remote="prgs"
|
||||
)
|
||||
mock_assess.assert_called()
|
||||
for call in mock_assess.call_args_list:
|
||||
self.assertFalse(call.kwargs.get("auto_recover", True))
|
||||
self.assertEqual(result.get("mutation_performed"), False)
|
||||
# Session context must not have been rewritten by resolve
|
||||
# (report-only; any prior binding stays; unbound stays unbound unless
|
||||
# seed-on-read path is intentional — resolve must not activate peers).
|
||||
self.assertNotEqual(result.get("active_profile"), "mdcps-reviewer")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Regression coverage for issue #723 role and capability invariants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
import task_capability_map
|
||||
|
||||
|
||||
REVIEWER_PROFILE = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
}
|
||||
|
||||
CONFIG = {
|
||||
"profiles": {
|
||||
"prgs-reviewer": {
|
||||
"role": "reviewer",
|
||||
"allowed_operations": REVIEWER_PROFILE["allowed_operations"],
|
||||
"forbidden_operations": REVIEWER_PROFILE["forbidden_operations"],
|
||||
},
|
||||
"prgs-merger": {
|
||||
"role": "merger",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _reset_preflight() -> None:
|
||||
mcp_server._clear_preflight_capability_state()
|
||||
mcp_server._preflight_whoami_called = False
|
||||
mcp_server._preflight_whoami_violation = False
|
||||
mcp_server.capability_stop_terminal.clear()
|
||||
mcp_server.role_session_router.clear_route_state()
|
||||
|
||||
|
||||
class _ResolveHarness(unittest.TestCase):
|
||||
def setUp(self):
|
||||
_reset_preflight()
|
||||
|
||||
def tearDown(self):
|
||||
_reset_preflight()
|
||||
|
||||
def _resolve(
|
||||
self,
|
||||
task,
|
||||
profile=REVIEWER_PROFILE,
|
||||
required_role=None,
|
||||
init_side_effect=None,
|
||||
):
|
||||
patches = [
|
||||
patch.object(mcp_server, "get_profile", return_value=profile),
|
||||
patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=CONFIG
|
||||
),
|
||||
patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="tester"
|
||||
),
|
||||
patch.object(
|
||||
mcp_server,
|
||||
"init_review_decision_lock",
|
||||
return_value=None,
|
||||
side_effect=init_side_effect,
|
||||
),
|
||||
patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
),
|
||||
patch.object(
|
||||
mcp_server, "_check_mcp_runtimes_diagnostics", return_value=[]
|
||||
),
|
||||
]
|
||||
if required_role is not None:
|
||||
patches.append(
|
||||
patch.object(
|
||||
mcp_server.task_capability_map,
|
||||
"required_role",
|
||||
side_effect=lambda candidate: (
|
||||
required_role
|
||||
if candidate == task
|
||||
else task_capability_map.TASK_CAPABILITY_MAP[candidate][
|
||||
"role"
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
for context in patches:
|
||||
context.__enter__()
|
||||
try:
|
||||
return mcp_server.gitea_resolve_task_capability(
|
||||
task=task, remote="prgs"
|
||||
)
|
||||
finally:
|
||||
for context in reversed(patches):
|
||||
context.__exit__(None, None, None)
|
||||
|
||||
|
||||
class TestCapabilityRoleStampSafety(_ResolveHarness):
|
||||
def test_allowed_resolution_records_the_correct_stamp(self):
|
||||
result = self._resolve("review_pr")
|
||||
self.assertTrue(result["allowed_in_current_session"], result)
|
||||
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
||||
self.assertEqual(mcp_server._preflight_resolved_task, "review_pr")
|
||||
|
||||
def test_denied_resolution_records_no_stamp(self):
|
||||
with patch.object(
|
||||
mcp_server,
|
||||
"record_preflight_check",
|
||||
wraps=mcp_server.record_preflight_check,
|
||||
) as record:
|
||||
result = self._resolve("review_pr", required_role="merger")
|
||||
|
||||
self.assertFalse(result["allowed_in_current_session"], result)
|
||||
stamped_calls = [
|
||||
call
|
||||
for call in record.call_args_list
|
||||
if len(call.args) > 1 and call.args[1] is not None
|
||||
]
|
||||
self.assertEqual(
|
||||
stamped_calls,
|
||||
[],
|
||||
"a denied resolution must never transiently record a role stamp",
|
||||
)
|
||||
self.assertIsNone(mcp_server._preflight_resolved_role)
|
||||
self.assertIsNone(mcp_server._preflight_resolved_task)
|
||||
|
||||
def test_denied_resolution_clears_an_existing_stamp(self):
|
||||
allowed = self._resolve("review_pr")
|
||||
self.assertTrue(allowed["allowed_in_current_session"], allowed)
|
||||
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
||||
|
||||
denied = self._resolve("merge_pr")
|
||||
self.assertFalse(denied["allowed_in_current_session"], denied)
|
||||
self.assertIsNone(mcp_server._preflight_resolved_role)
|
||||
self.assertIsNone(mcp_server._preflight_resolved_task)
|
||||
|
||||
def test_denial_cannot_poison_a_later_allowed_task(self):
|
||||
denied = self._resolve("merge_pr")
|
||||
self.assertFalse(denied["allowed_in_current_session"], denied)
|
||||
|
||||
allowed = self._resolve("review_pr")
|
||||
self.assertTrue(allowed["allowed_in_current_session"], allowed)
|
||||
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
||||
self.assertEqual(mcp_server._preflight_resolved_task, "review_pr")
|
||||
|
||||
def test_unexpected_resolver_failure_leaves_no_stamp(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "malformed decision state"):
|
||||
self._resolve(
|
||||
"review_pr",
|
||||
init_side_effect=RuntimeError("malformed decision state"),
|
||||
)
|
||||
self.assertIsNone(mcp_server._preflight_resolved_role)
|
||||
self.assertIsNone(mcp_server._preflight_resolved_task)
|
||||
|
||||
|
||||
class TestStructuredWorkspaceRoleFailures(unittest.TestCase):
|
||||
def test_review_submission_returns_workspace_role_binding_failure(self):
|
||||
error = RuntimeError(
|
||||
"namespace workspace binding blocked: merger role in reviewer workspace"
|
||||
)
|
||||
with patch.object(
|
||||
mcp_server, "_verify_role_mutation_workspace", side_effect=error
|
||||
):
|
||||
result = mcp_server._evaluate_pr_review_submission(
|
||||
pr_number=721,
|
||||
action="approve",
|
||||
expected_head_sha="8" * 40,
|
||||
remote="prgs",
|
||||
live=True,
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
||||
self.assertTrue(
|
||||
any("workspace/role binding failed" in reason for reason in result["reasons"]),
|
||||
result,
|
||||
)
|
||||
self.assertTrue(any("merger role" in reason for reason in result["reasons"]))
|
||||
|
||||
def test_adopt_merger_lease_returns_workspace_role_binding_failure(self):
|
||||
error = RuntimeError("merger workspace binding rejected")
|
||||
with patch.object(
|
||||
mcp_server, "_profile_operation_gate", return_value=[]
|
||||
), patch.object(
|
||||
mcp_server, "_verify_role_mutation_workspace", side_effect=error
|
||||
), patch.object(mcp_server, "_resolve") as resolve:
|
||||
result = mcp_server.gitea_adopt_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/merge-pr-718",
|
||||
expected_head_sha="7" * 40,
|
||||
remote="prgs",
|
||||
)
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["adopted"])
|
||||
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
||||
self.assertEqual(result["pr_number"], 718)
|
||||
self.assertEqual(result["expected_head_sha"], "7" * 40)
|
||||
self.assertIsNone(result["live_head_sha"])
|
||||
self.assertTrue(any("binding rejected" in reason for reason in result["reasons"]))
|
||||
resolve.assert_not_called()
|
||||
|
||||
def test_unexpected_verifier_failure_remains_fail_closed(self):
|
||||
with patch.object(
|
||||
mcp_server,
|
||||
"_verify_role_mutation_workspace",
|
||||
side_effect=ValueError("unexpected verifier state"),
|
||||
), patch.object(mcp_server, "_resolve") as resolve:
|
||||
with self.assertRaisesRegex(ValueError, "unexpected verifier state"):
|
||||
mcp_server._evaluate_pr_review_submission(
|
||||
pr_number=721,
|
||||
action="approve",
|
||||
remote="prgs",
|
||||
live=True,
|
||||
)
|
||||
resolve.assert_not_called()
|
||||
|
||||
|
||||
class TestRuntimeCapabilityRoleFiltering(unittest.TestCase):
|
||||
def test_runtime_role_filter_denies_permission_bearing_wrong_role(self):
|
||||
allowed = REVIEWER_PROFILE["allowed_operations"] + ["gitea.pr.merge"]
|
||||
capabilities = mcp_server._build_runtime_task_capabilities(
|
||||
allowed,
|
||||
[],
|
||||
CONFIG,
|
||||
remote="prgs",
|
||||
active_role_kind="reviewer",
|
||||
)
|
||||
merge_entry = next(
|
||||
item
|
||||
for item in capabilities["task_capabilities"]
|
||||
if item["task"] == "merge_pr"
|
||||
)
|
||||
self.assertTrue(merge_entry["role_exclusive"])
|
||||
self.assertEqual(merge_entry["capability_view"], "role_filtered")
|
||||
self.assertFalse(merge_entry["allowed_in_current_session"])
|
||||
self.assertFalse(capabilities["can_merge_prs"])
|
||||
|
||||
def test_permission_only_view_is_explicit(self):
|
||||
capabilities = mcp_server._build_runtime_task_capabilities(
|
||||
["gitea.read", "gitea.pr.merge"],
|
||||
[],
|
||||
CONFIG,
|
||||
active_role_kind=None,
|
||||
)
|
||||
merge_entry = next(
|
||||
item
|
||||
for item in capabilities["task_capabilities"]
|
||||
if item["task"] == "merge_pr"
|
||||
)
|
||||
self.assertEqual(merge_entry["capability_view"], "permission_only")
|
||||
self.assertTrue(merge_entry["allowed_in_current_session"])
|
||||
|
||||
def test_matching_profiles_honor_declared_roles(self):
|
||||
capabilities = mcp_server._build_runtime_task_capabilities(
|
||||
["gitea.read"],
|
||||
[],
|
||||
CONFIG,
|
||||
active_role_kind="author",
|
||||
)
|
||||
review_entry = next(
|
||||
item
|
||||
for item in capabilities["task_capabilities"]
|
||||
if item["task"] == "review_pr"
|
||||
)
|
||||
merge_entry = next(
|
||||
item
|
||||
for item in capabilities["task_capabilities"]
|
||||
if item["task"] == "merge_pr"
|
||||
)
|
||||
self.assertEqual(
|
||||
review_entry["matching_configured_profiles"], ["prgs-reviewer"]
|
||||
)
|
||||
self.assertEqual(
|
||||
merge_entry["matching_configured_profiles"], ["prgs-merger"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Regression matrix for Issue #733.
|
||||
|
||||
``gitea_delete_branch`` accepts explicit ``org``/``repo`` but historically did
|
||||
not propagate them through the anti-stomp preflight: preflight resolved the
|
||||
remote-wide ``REMOTES`` default (bare ``prgs`` → ``Scaled-Tech-Consulting/
|
||||
Timesheet``) and fail-closed with ``wrong_repo`` even though the caller
|
||||
explicitly targeted ``Scaled-Tech-Consulting/Gitea-Tools``.
|
||||
|
||||
The fix has two parts, both exercised here:
|
||||
|
||||
1. ``gitea_delete_branch`` forwards the explicit ``org``/``repo`` into
|
||||
``verify_preflight_purity`` so the shared #604 anti-stomp resolution
|
||||
validates the *targeted* repository instead of the remote-wide default.
|
||||
2. A workspace-derived repository-binding gate
|
||||
(``_delete_branch_repository_binding_block``) validates explicit
|
||||
coordinates against the immutable workspace identity, rejecting wrong,
|
||||
substituted, or unverified targets — independent of the anti-stomp
|
||||
remote/repo guard, which by the #530 contract trusts explicit intent.
|
||||
|
||||
Every existing gate (reconciler-only ownership; author/reviewer/merger denial;
|
||||
protected/preservation blocks) is preserved.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server
|
||||
from mcp_server import gitea_delete_branch
|
||||
import remote_repo_guard
|
||||
import task_capability_map
|
||||
import role_session_router
|
||||
|
||||
FAKE_AUTH = "token fake"
|
||||
|
||||
# The workspace is bound to Gitea-Tools even though the prgs REMOTES default
|
||||
# repo is Timesheet (the exact #733 scenario).
|
||||
WORKSPACE_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||
LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
|
||||
RECONCILER = {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.pr.merge"],
|
||||
"audit_label": "prgs-reconciler",
|
||||
}
|
||||
|
||||
AUTHOR_WITH_DELETE = {
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.pr.create", "gitea.branch.push",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
REVIEWER_WITH_DELETE = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.branch.delete"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "prgs-reviewer",
|
||||
}
|
||||
|
||||
MERGER_WITH_DELETE = {
|
||||
"profile_name": "prgs-merger",
|
||||
"role": "merger",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.merge", "gitea.branch.delete"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "prgs-merger",
|
||||
}
|
||||
|
||||
|
||||
class _DeleteBranchBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._snap = (
|
||||
mcp_server._preflight_whoami_called,
|
||||
mcp_server._preflight_capability_called,
|
||||
)
|
||||
mcp_server._preflight_whoami_called = False
|
||||
mcp_server._preflight_capability_called = False
|
||||
# prgs default repo is Timesheet — the remote-wide default #733 must NOT
|
||||
# fall back to.
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {
|
||||
"host": "gitea.prgs.cc",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Timesheet",
|
||||
},
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
patch("gitea_config.load_config", return_value={}).start()
|
||||
patch("gitea_config.is_runtime_switching_enabled", return_value=False).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_api.return_value = {}
|
||||
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||
# Workspace is verifiably bound to Gitea-Tools.
|
||||
patch(
|
||||
"mcp_server._workspace_repository_slug", return_value=WORKSPACE_SLUG
|
||||
).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
(
|
||||
mcp_server._preflight_whoami_called,
|
||||
mcp_server._preflight_capability_called,
|
||||
) = self._snap
|
||||
|
||||
def _bind(self, profile, role):
|
||||
patch("mcp_server.get_profile", return_value=profile).start()
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role=role)
|
||||
|
||||
def _delete_calls(self):
|
||||
return [
|
||||
c for c in self.mock_api.call_args_list
|
||||
if c.args and c.args[0] == "DELETE"
|
||||
]
|
||||
|
||||
|
||||
class TestPositiveTimesheetDefaultExplicitGiteaTools(_DeleteBranchBase):
|
||||
"""AC: prgs default=Timesheet + explicit Gitea-Tools → preflight validates
|
||||
Gitea-Tools and permits an otherwise-eligible deletion."""
|
||||
|
||||
def test_explicit_gitea_tools_permits_delete_and_forwards_coords(self):
|
||||
self._bind(RECONCILER, "reconciler")
|
||||
with patch("mcp_server.verify_preflight_purity") as vpp:
|
||||
res = gitea_delete_branch(
|
||||
branch="feat/pr-sync-status",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertIn("deleted", res["message"])
|
||||
self.assertTrue(self._delete_calls())
|
||||
# Forwarding: preflight received the explicit Gitea-Tools coordinates
|
||||
# and the delete_branch task (not the remote-wide Timesheet default).
|
||||
self.assertTrue(vpp.called)
|
||||
kwargs = vpp.call_args.kwargs
|
||||
self.assertEqual(kwargs.get("task"), "delete_branch")
|
||||
self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting")
|
||||
self.assertEqual(kwargs.get("repo"), "Gitea-Tools")
|
||||
|
||||
|
||||
class TestNegativeRepositoryBinding(_DeleteBranchBase):
|
||||
"""AC negatives: wrong / substituted / unverified coordinates fail closed."""
|
||||
|
||||
def test_wrong_explicit_repo_fails_closed(self):
|
||||
self._bind(RECONCILER, "reconciler")
|
||||
with patch("mcp_server.verify_preflight_purity") as vpp:
|
||||
res = gitea_delete_branch(
|
||||
branch="feat/x",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Timesheet", # not the workspace-bound Gitea-Tools
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["blocker_kind"], "repository_binding")
|
||||
self.assertTrue(any("Timesheet" in r for r in res["reasons"]))
|
||||
self.assertFalse(self._delete_calls())
|
||||
# Fail closed BEFORE any preflight/deletion side effect.
|
||||
vpp.assert_not_called()
|
||||
|
||||
def test_repository_substitution_org_mismatch_rejected(self):
|
||||
self._bind(RECONCILER, "reconciler")
|
||||
res = gitea_delete_branch(
|
||||
branch="feat/x",
|
||||
remote="prgs",
|
||||
org="Some-Other-Org",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res["blocker_kind"], "repository_binding")
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
def test_explicit_coords_without_workspace_identity_fail_closed(self):
|
||||
self._bind(RECONCILER, "reconciler")
|
||||
with patch("mcp_server._workspace_repository_slug", return_value=None):
|
||||
res = gitea_delete_branch(
|
||||
branch="feat/x",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res["blocker_kind"], "repository_binding")
|
||||
self.assertTrue(any("unverified" in r for r in res["reasons"]))
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
def test_matching_explicit_passes_binding(self):
|
||||
self._bind(RECONCILER, "reconciler")
|
||||
block = mcp_server._delete_branch_repository_binding_block(
|
||||
"prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools"
|
||||
)
|
||||
self.assertIsNone(block)
|
||||
|
||||
def test_omitted_coords_pass_binding_and_are_revalidated_by_preflight(self):
|
||||
# Omitted coordinates pass the binding gate (nothing to corroborate) but
|
||||
# are forwarded as None so the anti-stomp resolution fails closed on the
|
||||
# remote-wide Timesheet default (see TestAntiStompResolution below).
|
||||
self._bind(RECONCILER, "reconciler")
|
||||
self.assertIsNone(
|
||||
mcp_server._delete_branch_repository_binding_block(
|
||||
"prgs", org=None, repo=None
|
||||
)
|
||||
)
|
||||
with patch("mcp_server.verify_preflight_purity") as vpp:
|
||||
gitea_delete_branch(branch="feat/x", remote="prgs")
|
||||
self.assertTrue(vpp.called)
|
||||
kwargs = vpp.call_args.kwargs
|
||||
self.assertIsNone(kwargs.get("org"))
|
||||
self.assertIsNone(kwargs.get("repo"))
|
||||
|
||||
|
||||
class TestRoleDenials(_DeleteBranchBase):
|
||||
"""AC: author, reviewer, and merger remain denied even holding the perm."""
|
||||
|
||||
def test_author_with_delete_perm_denied(self):
|
||||
self._bind(AUTHOR_WITH_DELETE, "author")
|
||||
res = gitea_delete_branch(
|
||||
branch="feat/x", remote="prgs",
|
||||
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||
self.assertEqual(res["active_role_kind"], "author")
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
def test_reviewer_with_delete_perm_denied(self):
|
||||
self._bind(REVIEWER_WITH_DELETE, "reviewer")
|
||||
res = gitea_delete_branch(
|
||||
branch="feat/x", remote="prgs",
|
||||
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
def test_merger_with_delete_perm_denied(self):
|
||||
self._bind(MERGER_WITH_DELETE, "merger")
|
||||
res = gitea_delete_branch(
|
||||
branch="feat/x", remote="prgs",
|
||||
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
|
||||
class TestProtectedAndPreservedBranches(_DeleteBranchBase):
|
||||
"""AC: protected and preservation/evidence branches remain blocked."""
|
||||
|
||||
def test_protected_branch_blocked(self):
|
||||
self._bind(RECONCILER, "reconciler")
|
||||
for branch in ("master", "main", "dev"):
|
||||
with self.subTest(branch=branch):
|
||||
res = gitea_delete_branch(
|
||||
branch=branch, remote="prgs",
|
||||
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertTrue(any("protected" in r for r in res["reasons"]))
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
def test_preservation_branch_blocked(self):
|
||||
self._bind(RECONCILER, "reconciler")
|
||||
for branch in ("chore/preserve-local-master", "feat/evidence-run"):
|
||||
with self.subTest(branch=branch):
|
||||
res = gitea_delete_branch(
|
||||
branch=branch, remote="prgs",
|
||||
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertTrue(
|
||||
any("preservation" in r for r in res["reasons"])
|
||||
)
|
||||
self.assertFalse(self._delete_calls())
|
||||
|
||||
|
||||
class TestAntiStompResolution(unittest.TestCase):
|
||||
"""The anti-stomp repository resolution validates the *targeted* repo and
|
||||
fails closed on the remote-wide default when coordinates are omitted."""
|
||||
|
||||
def setUp(self):
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {
|
||||
"host": "gitea.prgs.cc",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Timesheet",
|
||||
},
|
||||
})
|
||||
self._remotes.start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
|
||||
def _capture_resolution(self, org, repo):
|
||||
"""Drive the live anti-stomp runner and capture the org/repo it resolved
|
||||
and passed to the pure assessor."""
|
||||
passthrough = {
|
||||
"allowed": True, "block": False, "blockers": [], "reasons": [],
|
||||
"exact_next_action": "proceed", "blocker_kind": None, "checks": {},
|
||||
}
|
||||
with patch.dict(
|
||||
os.environ, {"GITEA_TEST_FORCE_ANTI_STOMP": "1"}, clear=False
|
||||
), patch(
|
||||
"mcp_server._local_git_remote_url", return_value=LOCAL_GITEA_TOOLS_URL
|
||||
), patch(
|
||||
"mcp_server.get_profile", return_value=RECONCILER
|
||||
), patch.object(
|
||||
mcp_server.anti_stomp_preflight,
|
||||
"assess_anti_stomp_preflight",
|
||||
return_value=passthrough,
|
||||
) as m:
|
||||
mcp_server._run_anti_stomp_preflight(
|
||||
"delete_branch", remote="prgs", org=org, repo=repo
|
||||
)
|
||||
self.assertTrue(m.called)
|
||||
return m.call_args.kwargs
|
||||
|
||||
def test_explicit_gitea_tools_resolved_and_marked_explicit(self):
|
||||
kw = self._capture_resolution("Scaled-Tech-Consulting", "Gitea-Tools")
|
||||
self.assertEqual(kw["resolved_org"], "Scaled-Tech-Consulting")
|
||||
self.assertEqual(kw["resolved_repo"], "Gitea-Tools")
|
||||
self.assertTrue(kw["org_explicit"])
|
||||
self.assertTrue(kw["repo_explicit"])
|
||||
|
||||
def test_missing_coords_resolve_remote_default_and_fail_closed(self):
|
||||
# Omitted coords resolve the remote-wide Timesheet default with
|
||||
# org/repo NOT explicit; the remote/repo guard then blocks against the
|
||||
# local Gitea-Tools remote — i.e. missing coordinates fail closed.
|
||||
kw = self._capture_resolution(None, None)
|
||||
self.assertEqual(kw["resolved_repo"], "Timesheet")
|
||||
self.assertFalse(kw["org_explicit"])
|
||||
self.assertFalse(kw["repo_explicit"])
|
||||
# Compose the guard exactly as the assessor would: Timesheet default,
|
||||
# not explicit, local remote is Gitea-Tools → blocked (wrong_repo).
|
||||
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote="prgs",
|
||||
resolved_org=kw["resolved_org"],
|
||||
resolved_repo=kw["resolved_repo"],
|
||||
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
||||
org_explicit=kw["org_explicit"],
|
||||
repo_explicit=kw["repo_explicit"],
|
||||
)
|
||||
self.assertTrue(assessment["block"])
|
||||
|
||||
|
||||
class TestCapabilityRoleMapConsistency(unittest.TestCase):
|
||||
"""AC: task-capability and role-routing maps remain consistent (#729)."""
|
||||
|
||||
def test_delete_branch_is_reconciler_in_both_maps(self):
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role("delete_branch"), "reconciler"
|
||||
)
|
||||
self.assertEqual(
|
||||
task_capability_map.required_permission("delete_branch"),
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
self.assertEqual(
|
||||
role_session_router.required_role_for_task("delete_branch"),
|
||||
"reconciler",
|
||||
)
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role("delete_branch"),
|
||||
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
|
||||
)
|
||||
|
||||
|
||||
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,618 @@
|
||||
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.
|
||||
|
||||
#768 extends the token with recorded/accepted heads and the head relation
|
||||
so a strict-descendant recovery can still exempt the owning PR after the
|
||||
remediation commit lands. Exact-head recovery (#753/#755) reports equal
|
||||
heads under the same shape.
|
||||
"""
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"pr_number": pr_number,
|
||||
"branch_name": branch,
|
||||
"head_sha": head,
|
||||
"recorded_head": head,
|
||||
"accepted_head": head,
|
||||
"head_relation": issue_lock_recovery.HEAD_RELATION_EQUAL,
|
||||
}
|
||||
|
||||
|
||||
# ───────────────────────── 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
|
||||
@@ -0,0 +1,551 @@
|
||||
"""Strict-descendant dead-session recovery (#768).
|
||||
|
||||
After a dead author session, a preserved clean remediation commit that strictly
|
||||
descends from the head recorded at lock time must be recoverable so the author
|
||||
can publish. Equality alone is still accepted (#753); every other divergence
|
||||
must keep failing closed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_lock_recovery # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import issue_lock_worktree # noqa: E402
|
||||
import issue_work_duplicate_gate # noqa: E402
|
||||
|
||||
ISSUE = 7680
|
||||
PR_NUMBER = 7681
|
||||
BRANCH = f"fix/issue-{ISSUE}-descendant-recovery"
|
||||
WORKTREE = "/scratch/wt-768"
|
||||
RECORDED = "a" * 40
|
||||
DESCENDANT = "c" * 40
|
||||
DIVERGED = "d" * 40
|
||||
BEHIND = "b" * 40
|
||||
IDENTITY = "example-user"
|
||||
PROFILE = "example-author"
|
||||
|
||||
|
||||
def dead_pid() -> int:
|
||||
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 ancestry_ok(
|
||||
*,
|
||||
ancestor: str = RECORDED,
|
||||
descendant: str = DESCENDANT,
|
||||
is_strict: bool = True,
|
||||
probe_ok: bool = True,
|
||||
ancestor_present: bool = True,
|
||||
reasons: list[str] | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"ancestor_sha": ancestor,
|
||||
"descendant_sha": descendant,
|
||||
"probe_ok": probe_ok,
|
||||
"ancestor_present": ancestor_present,
|
||||
"descendant_present": True,
|
||||
"is_ancestor": is_strict or ancestor == descendant,
|
||||
"is_strict_descendant": is_strict,
|
||||
"proof": f"git merge-base --is-ancestor {ancestor} {descendant} -> exit 0",
|
||||
"reasons": list(reasons or []),
|
||||
}
|
||||
|
||||
|
||||
def assess(**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": RECORDED,
|
||||
"remote_head_sha": RECORDED,
|
||||
"pr_head_sha": RECORDED,
|
||||
"pr_number": PR_NUMBER,
|
||||
"competing_live_locks": [],
|
||||
"candidate_branches": [BRANCH],
|
||||
"current_pid": os.getpid(),
|
||||
"head_ancestry": None,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
lock = kwargs.pop("lock", None)
|
||||
return issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||
make_lock() if lock is None else lock, **kwargs
|
||||
)
|
||||
|
||||
|
||||
class TestExactHeadRecoveryStillSucceeds(unittest.TestCase):
|
||||
def test_equal_heads_still_sanctioned(self):
|
||||
result = assess()
|
||||
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||
self.assertEqual(
|
||||
result["evidence"]["head_relation"],
|
||||
issue_lock_recovery.HEAD_RELATION_EQUAL,
|
||||
)
|
||||
self.assertEqual(result["evidence"]["recorded_head"], RECORDED)
|
||||
self.assertEqual(result["evidence"]["accepted_head"], RECORDED)
|
||||
|
||||
def test_exact_match_record_carries_relation(self):
|
||||
record = issue_lock_recovery.build_recovery_record(
|
||||
assess(), recovered_at="2026-07-20T00:00:00Z"
|
||||
)
|
||||
self.assertEqual(record["head_relation"], issue_lock_recovery.HEAD_RELATION_EQUAL)
|
||||
self.assertEqual(record["recorded_head"], RECORDED)
|
||||
self.assertEqual(record["accepted_head"], RECORDED)
|
||||
|
||||
|
||||
class TestStrictDescendantRecoverySucceeds(unittest.TestCase):
|
||||
def test_clean_strict_descendant_recovers(self):
|
||||
result = assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(),
|
||||
)
|
||||
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||
self.assertEqual(
|
||||
result["evidence"]["head_relation"],
|
||||
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||
)
|
||||
self.assertEqual(result["evidence"]["recorded_head"], RECORDED)
|
||||
self.assertEqual(result["evidence"]["accepted_head"], DESCENDANT)
|
||||
self.assertIsNotNone(result["evidence"]["ancestry_proof"])
|
||||
self.assertTrue(
|
||||
any("strictly descends" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_pr_still_at_recorded_head_is_ok_for_descendant(self):
|
||||
# Remediation is local only; open PR still points at the recorded head.
|
||||
result = assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(),
|
||||
)
|
||||
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||
|
||||
def test_recovery_record_names_both_heads_and_proof(self):
|
||||
result = assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(),
|
||||
)
|
||||
record = issue_lock_recovery.build_recovery_record(
|
||||
result, recovered_at="2026-07-20T00:00:00Z"
|
||||
)
|
||||
self.assertEqual(record["recorded_head"], RECORDED)
|
||||
self.assertEqual(record["accepted_head"], DESCENDANT)
|
||||
self.assertEqual(
|
||||
record["head_relation"],
|
||||
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||
)
|
||||
self.assertIn("strictly descends", record["ancestry_proof"] or "")
|
||||
self.assertEqual(record["prior_session_pid"], result["evidence"]["prior_session_pid"])
|
||||
self.assertEqual(record["replacement_session_pid"], os.getpid())
|
||||
|
||||
|
||||
class TestDescendantEvidenceReachesPublicationGates(unittest.TestCase):
|
||||
def _descendant_assessment(self):
|
||||
return assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(),
|
||||
)
|
||||
|
||||
def test_owning_pr_evidence_carries_accepted_head(self):
|
||||
token = issue_lock_recovery.owning_pr_recovery_evidence(
|
||||
self._descendant_assessment()
|
||||
)
|
||||
self.assertIsNotNone(token)
|
||||
assert token is not None
|
||||
self.assertEqual(token["head_sha"], RECORDED)
|
||||
self.assertEqual(token["accepted_head"], DESCENDANT)
|
||||
self.assertEqual(token["recorded_head"], RECORDED)
|
||||
self.assertEqual(
|
||||
token["head_relation"],
|
||||
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||
)
|
||||
|
||||
def test_persisted_lock_rebuilds_owning_pr_evidence(self):
|
||||
assessment = self._descendant_assessment()
|
||||
record = issue_lock_recovery.build_recovery_record(
|
||||
assessment, recovered_at="2026-07-20T00:00:00Z"
|
||||
)
|
||||
lock = make_lock(dead_session_recovery=record)
|
||||
token = issue_lock_recovery.recovered_owning_pr_from_lock(lock)
|
||||
self.assertIsNotNone(token)
|
||||
assert token is not None
|
||||
self.assertEqual(token["pr_number"], PR_NUMBER)
|
||||
self.assertEqual(token["head_sha"], RECORDED)
|
||||
self.assertEqual(token["accepted_head"], DESCENDANT)
|
||||
|
||||
def test_duplicate_gate_accepts_pr_at_recorded_or_accepted_head(self):
|
||||
token = issue_lock_recovery.owning_pr_recovery_evidence(
|
||||
self._descendant_assessment()
|
||||
)
|
||||
for live_sha in (RECORDED, DESCENDANT):
|
||||
with self.subTest(live_sha=live_sha):
|
||||
gate = issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
|
||||
ISSUE,
|
||||
open_prs=[
|
||||
{
|
||||
"number": PR_NUMBER,
|
||||
"title": f"Closes #{ISSUE}",
|
||||
"body": f"Closes #{ISSUE}",
|
||||
"head": {"ref": BRANCH, "sha": live_sha},
|
||||
}
|
||||
],
|
||||
branch_names=[BRANCH],
|
||||
claim_entry={"status": "unclaimed"},
|
||||
locked_branch=BRANCH,
|
||||
phase=issue_work_duplicate_gate.PHASE_COMMIT,
|
||||
recovered_owning_pr=token,
|
||||
)
|
||||
self.assertFalse(gate["block"], gate)
|
||||
self.assertTrue(gate["owning_pr_recovery_exempted"])
|
||||
|
||||
def test_duplicate_gate_still_rejects_foreign_head(self):
|
||||
token = issue_lock_recovery.owning_pr_recovery_evidence(
|
||||
self._descendant_assessment()
|
||||
)
|
||||
gate = issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
|
||||
ISSUE,
|
||||
open_prs=[
|
||||
{
|
||||
"number": PR_NUMBER,
|
||||
"title": f"Closes #{ISSUE}",
|
||||
"body": f"Closes #{ISSUE}",
|
||||
"head": {"ref": BRANCH, "sha": DIVERGED},
|
||||
}
|
||||
],
|
||||
branch_names=[BRANCH],
|
||||
claim_entry={"status": "unclaimed"},
|
||||
locked_branch=BRANCH,
|
||||
phase=issue_work_duplicate_gate.PHASE_COMMIT,
|
||||
recovered_owning_pr=token,
|
||||
)
|
||||
self.assertTrue(gate["block"])
|
||||
self.assertFalse(gate["owning_pr_recovery_exempted"])
|
||||
|
||||
|
||||
class TestDirtyDescendantRejected(unittest.TestCase):
|
||||
def test_dirty_descendant_refused(self):
|
||||
result = assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(),
|
||||
porcelain_status=" M issue_lock_recovery.py\n",
|
||||
)
|
||||
self.assertFalse(result["recovery_sanctioned"])
|
||||
self.assertTrue(any("dirty" in r.lower() for r in result["reasons"]))
|
||||
|
||||
|
||||
class TestDivergedAndBehindRejected(unittest.TestCase):
|
||||
def test_diverged_head_refused(self):
|
||||
result = assess(
|
||||
head_sha=DIVERGED,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(
|
||||
ancestor=RECORDED,
|
||||
descendant=DIVERGED,
|
||||
is_strict=False,
|
||||
reasons=[
|
||||
f"local head {DIVERGED} does not descend from recorded head "
|
||||
f"{RECORDED}"
|
||||
],
|
||||
),
|
||||
)
|
||||
self.assertFalse(result["recovery_sanctioned"])
|
||||
self.assertIsNone(result["evidence"].get("head_relation"))
|
||||
self.assertTrue(
|
||||
any("does not match remote" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_local_behind_recorded_refused(self):
|
||||
# merge-base --is-ancestor RECORDED BEHIND is false when BEHIND is ancestor.
|
||||
result = assess(
|
||||
head_sha=BEHIND,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(
|
||||
ancestor=RECORDED,
|
||||
descendant=BEHIND,
|
||||
is_strict=False,
|
||||
reasons=[
|
||||
f"local head {BEHIND} does not descend from recorded head "
|
||||
f"{RECORDED}"
|
||||
],
|
||||
),
|
||||
)
|
||||
self.assertFalse(result["recovery_sanctioned"])
|
||||
self.assertTrue(
|
||||
any("does not match remote" in r or "not a strict descendant" in r
|
||||
for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
|
||||
class TestUnrelatedAndMalformedAncestryRejected(unittest.TestCase):
|
||||
def test_missing_ancestry_observation_fails_closed(self):
|
||||
result = assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=None,
|
||||
)
|
||||
self.assertFalse(result["recovery_sanctioned"])
|
||||
self.assertTrue(
|
||||
any("ancestry" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_mismatched_probe_pair_fails_closed(self):
|
||||
# Observation for a different commit pair must not authorize this pair.
|
||||
result = assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(ancestor=DIVERGED, descendant=DESCENDANT),
|
||||
)
|
||||
self.assertFalse(result["recovery_sanctioned"])
|
||||
self.assertTrue(
|
||||
any("not the heads under assessment" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_rewritten_recorded_head_fails_closed(self):
|
||||
result = assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(ancestor_present=False, is_strict=False),
|
||||
)
|
||||
self.assertFalse(result["recovery_sanctioned"])
|
||||
self.assertTrue(
|
||||
any("no longer reachable" in r or "rewritten" in r
|
||||
for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_failed_probe_fails_closed(self):
|
||||
result = assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(
|
||||
probe_ok=False,
|
||||
is_strict=False,
|
||||
reasons=["ancestry probe failed with exit 128; ancestry unproven"],
|
||||
),
|
||||
)
|
||||
self.assertFalse(result["recovery_sanctioned"])
|
||||
|
||||
def test_pr_head_not_equal_to_recorded_blocks_descendant(self):
|
||||
result = assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=DIVERGED,
|
||||
head_ancestry=ancestry_ok(),
|
||||
)
|
||||
self.assertFalse(result["recovery_sanctioned"])
|
||||
self.assertTrue(
|
||||
any("open PR" in r and "does not match" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
|
||||
class TestLiveOwnerStillRejected(unittest.TestCase):
|
||||
def test_live_prior_pid_refused_even_with_descendant_proof(self):
|
||||
result = assess(
|
||||
lock=make_lock(session_pid=os.getpid()),
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(),
|
||||
)
|
||||
self.assertFalse(result["recovery_sanctioned"])
|
||||
self.assertTrue(
|
||||
any("still alive" in r or "live" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
|
||||
class TestDiagnosticsIdentifyDisposition(unittest.TestCase):
|
||||
def test_equal_disposition_named(self):
|
||||
result = assess()
|
||||
self.assertEqual(
|
||||
result["evidence"]["head_relation"],
|
||||
issue_lock_recovery.HEAD_RELATION_EQUAL,
|
||||
)
|
||||
|
||||
def test_descendant_disposition_named(self):
|
||||
result = assess(
|
||||
head_sha=DESCENDANT,
|
||||
remote_head_sha=RECORDED,
|
||||
pr_head_sha=RECORDED,
|
||||
head_ancestry=ancestry_ok(),
|
||||
)
|
||||
self.assertEqual(
|
||||
result["evidence"]["head_relation"],
|
||||
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||
)
|
||||
|
||||
def test_rejected_divergence_has_no_accepted_relation(self):
|
||||
result = assess(
|
||||
head_sha=DIVERGED,
|
||||
remote_head_sha=RECORDED,
|
||||
head_ancestry=None,
|
||||
)
|
||||
self.assertIsNone(result["evidence"].get("head_relation"))
|
||||
message = issue_lock_recovery.format_recovery_refusal(result)
|
||||
self.assertIn("fail closed", message)
|
||||
self.assertIn("does not match remote", message)
|
||||
|
||||
|
||||
class TestReadHeadAncestryRealGit(unittest.TestCase):
|
||||
def _git(self, repo: str, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", "-C", repo, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
def _init_repo_with_chain(self) -> tuple[str, str, str, str]:
|
||||
"""Return (repo, parent_sha, child_sha, sibling_sha)."""
|
||||
repo = tempfile.mkdtemp(prefix="issue-768-ancestry-")
|
||||
self._git(repo, "init")
|
||||
self._git(repo, "config", "user.email", "[email protected]")
|
||||
self._git(repo, "config", "user.name", "Test")
|
||||
path = Path(repo) / "f.txt"
|
||||
path.write_text("one\n")
|
||||
self._git(repo, "add", "f.txt")
|
||||
self._git(repo, "commit", "-m", "parent")
|
||||
parent = self._git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
path.write_text("two\n")
|
||||
self._git(repo, "add", "f.txt")
|
||||
self._git(repo, "commit", "-m", "child")
|
||||
child = self._git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
# Divergent sibling: branch from parent, then unique commit.
|
||||
self._git(repo, "checkout", "-B", "side", parent)
|
||||
path.write_text("side\n")
|
||||
self._git(repo, "add", "f.txt")
|
||||
self._git(repo, "commit", "-m", "sibling")
|
||||
sibling = self._git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
self._git(repo, "checkout", "-B", "main", child)
|
||||
return repo, parent, child, sibling
|
||||
|
||||
def test_strict_descendant_observation(self):
|
||||
repo, parent, child, _sibling = self._init_repo_with_chain()
|
||||
obs = issue_lock_worktree.read_head_ancestry(
|
||||
repo, ancestor_sha=parent, descendant_sha=child
|
||||
)
|
||||
self.assertTrue(obs["probe_ok"])
|
||||
self.assertTrue(obs["ancestor_present"])
|
||||
self.assertTrue(obs["is_ancestor"])
|
||||
self.assertTrue(obs["is_strict_descendant"])
|
||||
self.assertEqual(obs["ancestor_sha"], parent)
|
||||
self.assertEqual(obs["descendant_sha"], child)
|
||||
|
||||
def test_equal_heads_not_strict_descendant(self):
|
||||
repo, parent, _child, _sibling = self._init_repo_with_chain()
|
||||
obs = issue_lock_worktree.read_head_ancestry(
|
||||
repo, ancestor_sha=parent, descendant_sha=parent
|
||||
)
|
||||
self.assertTrue(obs["probe_ok"])
|
||||
self.assertTrue(obs["is_ancestor"])
|
||||
self.assertFalse(obs["is_strict_descendant"])
|
||||
|
||||
def test_diverged_not_ancestor(self):
|
||||
repo, _parent, child, sibling = self._init_repo_with_chain()
|
||||
# child and sibling share a parent but neither descends from the other.
|
||||
obs = issue_lock_worktree.read_head_ancestry(
|
||||
repo, ancestor_sha=child, descendant_sha=sibling
|
||||
)
|
||||
self.assertTrue(obs["probe_ok"])
|
||||
self.assertFalse(obs["is_ancestor"])
|
||||
self.assertFalse(obs["is_strict_descendant"])
|
||||
|
||||
def test_missing_sha_fails_closed(self):
|
||||
repo, _parent, child, _ = self._init_repo_with_chain()
|
||||
obs = issue_lock_worktree.read_head_ancestry(
|
||||
repo, ancestor_sha="0" * 40, descendant_sha=child
|
||||
)
|
||||
self.assertFalse(obs["probe_ok"])
|
||||
self.assertFalse(obs["ancestor_present"])
|
||||
|
||||
def test_end_to_end_real_git_descendant_recovery(self):
|
||||
repo, parent, child, _sibling = self._init_repo_with_chain()
|
||||
obs = issue_lock_worktree.read_head_ancestry(
|
||||
repo, ancestor_sha=parent, descendant_sha=child
|
||||
)
|
||||
result = assess(
|
||||
worktree_path=repo,
|
||||
head_sha=child,
|
||||
remote_head_sha=parent,
|
||||
pr_head_sha=parent,
|
||||
head_ancestry=obs,
|
||||
lock=make_lock(worktree_path=repo),
|
||||
)
|
||||
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||
self.assertEqual(
|
||||
result["evidence"]["head_relation"],
|
||||
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,635 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import install_deterministic_remote_urls # noqa: E402
|
||||
|
||||
install_deterministic_remote_urls()
|
||||
"""#781: sanctioned issue title/body editing, and the documentation drift guard.
|
||||
|
||||
Two defects are covered here. The first is that no MCP path could edit an issue
|
||||
title or body at all, so an authorized correction had to be recorded as a
|
||||
comment. The second is why nobody noticed: documentation named a tool that was
|
||||
never registered, and nothing compared the two lists.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import anti_stomp_preflight # noqa: E402
|
||||
import edit_issue # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
import mcp_tool_inventory # noqa: E402
|
||||
import task_capability_map # noqa: E402
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
CONFIG = {
|
||||
"version": 2,
|
||||
"contexts": {
|
||||
"ctx": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"edit-author": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "author-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
|
||||
"forbidden_operations": [],
|
||||
"allowed_repositories": [
|
||||
"Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"Example-Org/Example-Repo",
|
||||
"913443/eAgenda",
|
||||
],
|
||||
"execution_profile": "edit-author",
|
||||
},
|
||||
"read-only-author": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "reader-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": ["gitea.read"],
|
||||
"forbidden_operations": ["gitea.issue.comment"],
|
||||
"allowed_repositories": [
|
||||
"Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"Example-Org/Example-Repo",
|
||||
"913443/eAgenda",
|
||||
],
|
||||
"execution_profile": "read-only-author",
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
}
|
||||
|
||||
ISSUE_NUMBER = 9
|
||||
ORIGINAL_TITLE = "fix(mcp): original title"
|
||||
ORIGINAL_BODY = "Original body.\n"
|
||||
NEW_TITLE = "fix(mcp): corrected title"
|
||||
NEW_BODY = "Corrected body.\n"
|
||||
|
||||
|
||||
def _registered_tool_names() -> set[str]:
|
||||
manager = mcp_server.mcp._tool_manager
|
||||
return set((getattr(manager, "_tools", None) or {}).keys())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule: request validation
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestValidateEditRequest(unittest.TestCase):
|
||||
def test_no_field_is_rejected(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
edit_issue.validate_edit_request()
|
||||
self.assertIn("At least one field", str(ctx.exception))
|
||||
|
||||
def test_blank_title_is_rejected(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
edit_issue.validate_edit_request(title=" ")
|
||||
self.assertIn("cannot be blank", str(ctx.exception))
|
||||
|
||||
def test_non_string_title_is_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
edit_issue.validate_edit_request(title=42)
|
||||
|
||||
def test_non_string_body_is_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
edit_issue.validate_edit_request(body=["not", "a", "string"])
|
||||
|
||||
def test_empty_body_is_a_legitimate_edit(self):
|
||||
self.assertEqual(edit_issue.validate_edit_request(body=""), {"body": ""})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule: planning against the pre-image
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestPlanIssueEdit(unittest.TestCase):
|
||||
def _current(self, **overrides):
|
||||
issue = {
|
||||
"number": ISSUE_NUMBER,
|
||||
"title": ORIGINAL_TITLE,
|
||||
"body": ORIGINAL_BODY,
|
||||
"state": "open",
|
||||
"labels": [{"name": "type:bug"}, {"name": "mcp"}],
|
||||
"assignees": [{"login": "author-user"}],
|
||||
"milestone": {"title": "v1.2.0"},
|
||||
}
|
||||
issue.update(overrides)
|
||||
return issue
|
||||
|
||||
def test_title_only_sends_only_the_title(self):
|
||||
plan = edit_issue.plan_issue_edit(self._current(), title=NEW_TITLE)
|
||||
self.assertEqual(plan["payload"], {"title": NEW_TITLE})
|
||||
self.assertEqual(plan["requested_fields"], ["title"])
|
||||
self.assertFalse(plan["no_op"])
|
||||
|
||||
def test_body_only_sends_only_the_body(self):
|
||||
plan = edit_issue.plan_issue_edit(self._current(), body=NEW_BODY)
|
||||
self.assertEqual(plan["payload"], {"body": NEW_BODY})
|
||||
|
||||
def test_combined_edit_sends_both(self):
|
||||
plan = edit_issue.plan_issue_edit(
|
||||
self._current(), title=NEW_TITLE, body=NEW_BODY
|
||||
)
|
||||
self.assertEqual(plan["payload"], {"title": NEW_TITLE, "body": NEW_BODY})
|
||||
self.assertEqual(plan["requested_fields"], ["body", "title"])
|
||||
|
||||
def test_identical_content_is_an_explicit_no_op(self):
|
||||
plan = edit_issue.plan_issue_edit(
|
||||
self._current(), title=ORIGINAL_TITLE, body=ORIGINAL_BODY
|
||||
)
|
||||
self.assertTrue(plan["no_op"])
|
||||
self.assertEqual(plan["payload"], {})
|
||||
self.assertTrue(plan["reasons"])
|
||||
self.assertTrue(plan["safe_next_action"])
|
||||
|
||||
def test_partially_unchanged_request_sends_only_the_difference(self):
|
||||
plan = edit_issue.plan_issue_edit(
|
||||
self._current(), title=ORIGINAL_TITLE, body=NEW_BODY
|
||||
)
|
||||
self.assertFalse(plan["no_op"])
|
||||
self.assertEqual(plan["payload"], {"body": NEW_BODY})
|
||||
self.assertEqual(plan["unchanged_fields"], ["title"])
|
||||
|
||||
def test_missing_body_is_compared_as_empty(self):
|
||||
current = self._current()
|
||||
current.pop("body")
|
||||
plan = edit_issue.plan_issue_edit(current, body="")
|
||||
self.assertTrue(plan["no_op"])
|
||||
|
||||
def test_preserved_snapshot_captures_untouched_fields(self):
|
||||
plan = edit_issue.plan_issue_edit(self._current(), title=NEW_TITLE)
|
||||
before = plan["preserved_before"]
|
||||
self.assertEqual(before["state"], "open")
|
||||
self.assertEqual(before["labels"], ["type:bug", "mcp"])
|
||||
self.assertEqual(before["assignees"], ["author-user"])
|
||||
self.assertEqual(before["milestone"], "v1.2.0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule: pull requests are refused
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAssessIssueTarget(unittest.TestCase):
|
||||
def test_issue_is_accepted(self):
|
||||
target = edit_issue.assess_issue_target(
|
||||
{"number": 9, "title": "t"}, issue_number=9
|
||||
)
|
||||
self.assertTrue(target["is_issue"])
|
||||
self.assertEqual(target["reasons"], [])
|
||||
|
||||
def test_pull_request_is_refused_with_a_next_action(self):
|
||||
target = edit_issue.assess_issue_target(
|
||||
{"number": 9, "pull_request": {"merged": False}}, issue_number=9
|
||||
)
|
||||
self.assertFalse(target["is_issue"])
|
||||
self.assertTrue(target["is_pull_request"])
|
||||
self.assertIn("gitea_edit_pr", target["safe_next_action"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule: read-after-write verification
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestVerifyIssueEdit(unittest.TestCase):
|
||||
def _plan(self, **kwargs):
|
||||
current = {
|
||||
"number": ISSUE_NUMBER,
|
||||
"title": ORIGINAL_TITLE,
|
||||
"body": ORIGINAL_BODY,
|
||||
"state": "open",
|
||||
"labels": [{"name": "type:bug"}],
|
||||
"assignees": [],
|
||||
"milestone": None,
|
||||
}
|
||||
return edit_issue.plan_issue_edit(current, **kwargs)
|
||||
|
||||
def test_applied_content_verifies(self):
|
||||
plan = self._plan(title=NEW_TITLE)
|
||||
observed = {
|
||||
"title": NEW_TITLE,
|
||||
"body": ORIGINAL_BODY,
|
||||
"state": "open",
|
||||
"labels": [{"name": "type:bug"}],
|
||||
"assignees": [],
|
||||
"milestone": None,
|
||||
}
|
||||
result = edit_issue.verify_issue_edit(observed, plan=plan)
|
||||
self.assertTrue(result["verified"])
|
||||
self.assertTrue(result["preserved_intact"])
|
||||
self.assertEqual(result["applied"], {"title": NEW_TITLE})
|
||||
|
||||
def test_unapplied_content_fails_closed(self):
|
||||
plan = self._plan(title=NEW_TITLE)
|
||||
observed = {
|
||||
"title": ORIGINAL_TITLE,
|
||||
"state": "open",
|
||||
"labels": [{"name": "type:bug"}],
|
||||
}
|
||||
result = edit_issue.verify_issue_edit(observed, plan=plan)
|
||||
self.assertFalse(result["verified"])
|
||||
self.assertEqual(result["mismatches"][0]["field"], "title")
|
||||
self.assertTrue(result["safe_next_action"])
|
||||
|
||||
def test_dropped_label_fails_closed(self):
|
||||
plan = self._plan(title=NEW_TITLE)
|
||||
observed = {"title": NEW_TITLE, "state": "open", "labels": []}
|
||||
result = edit_issue.verify_issue_edit(observed, plan=plan)
|
||||
self.assertFalse(result["verified"])
|
||||
self.assertFalse(result["preserved_intact"])
|
||||
self.assertEqual(result["preserved_changed"][0]["field"], "labels")
|
||||
|
||||
def test_changed_state_fails_closed(self):
|
||||
plan = self._plan(body=NEW_BODY)
|
||||
observed = {
|
||||
"body": NEW_BODY,
|
||||
"state": "closed",
|
||||
"labels": [{"name": "type:bug"}],
|
||||
}
|
||||
result = edit_issue.verify_issue_edit(observed, plan=plan)
|
||||
self.assertFalse(result["verified"])
|
||||
self.assertEqual(result["preserved_changed"][0]["field"], "state")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool: gitea_edit_issue against a fake Gitea
|
||||
# ---------------------------------------------------------------------------
|
||||
class _EditIssueToolHarness(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._remotes = patch.dict(
|
||||
mcp_server.REMOTES,
|
||||
{
|
||||
"prgs": {
|
||||
"host": "gitea.example.com",
|
||||
"org": "Example-Org",
|
||||
"repo": "Example-Repo",
|
||||
}
|
||||
},
|
||||
)
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
|
||||
self.issue = {
|
||||
"number": ISSUE_NUMBER,
|
||||
"title": ORIGINAL_TITLE,
|
||||
"body": ORIGINAL_BODY,
|
||||
"state": "open",
|
||||
"labels": [{"name": "type:bug"}, {"name": "mcp"}],
|
||||
"assignees": [{"login": "author-user"}],
|
||||
"milestone": {"title": "v1.2.0"},
|
||||
"html_url": "https://gitea.example.com/Example-Org/Example-Repo/issues/9",
|
||||
}
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
self.patched_payloads: list[dict] = []
|
||||
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
patch("mcp_server.get_auth_header", return_value="token author-pass").start()
|
||||
patch("mcp_server.api_request", side_effect=self._api).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def tearDown(self):
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir.cleanup()
|
||||
|
||||
def _api(self, method, url, auth, payload=None):
|
||||
self.calls.append((method, url))
|
||||
if url.endswith("/user"):
|
||||
return {"login": "author-user"}
|
||||
if "/issues/" in url:
|
||||
if method == "GET":
|
||||
return dict(self.issue)
|
||||
if method == "PATCH":
|
||||
self.patched_payloads.append(dict(payload or {}))
|
||||
self.issue.update(payload or {})
|
||||
return dict(self.issue)
|
||||
raise AssertionError(f"unexpected API call: {method} {url}")
|
||||
|
||||
def _env(self, profile: str = "edit-author") -> dict:
|
||||
return {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": profile,
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||
"PYTEST_CURRENT_TEST", "issue_781_edit_issue"
|
||||
),
|
||||
}
|
||||
|
||||
def _edit(self, profile: str = "edit-author", **kwargs):
|
||||
with patch.dict(os.environ, self._env(profile), clear=True):
|
||||
return mcp_server.gitea_edit_issue(
|
||||
issue_number=ISSUE_NUMBER, remote="prgs", **kwargs
|
||||
)
|
||||
|
||||
def _patch_methods(self) -> list[str]:
|
||||
return [method for method, _url in self.calls if method == "PATCH"]
|
||||
|
||||
|
||||
class TestEditIssueSucceeds(_EditIssueToolHarness):
|
||||
def test_title_only_edit(self):
|
||||
result = self._edit(title=NEW_TITLE)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["verified"])
|
||||
self.assertEqual(result["changed_fields"], ["title"])
|
||||
self.assertEqual(self.patched_payloads, [{"title": NEW_TITLE}])
|
||||
self.assertEqual(self.issue["title"], NEW_TITLE)
|
||||
self.assertEqual(self.issue["body"], ORIGINAL_BODY)
|
||||
|
||||
def test_body_only_edit(self):
|
||||
result = self._edit(body=NEW_BODY)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(self.patched_payloads, [{"body": NEW_BODY}])
|
||||
self.assertEqual(self.issue["title"], ORIGINAL_TITLE)
|
||||
self.assertEqual(self.issue["body"], NEW_BODY)
|
||||
|
||||
def test_combined_edit(self):
|
||||
result = self._edit(title=NEW_TITLE, body=NEW_BODY)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(
|
||||
self.patched_payloads, [{"title": NEW_TITLE, "body": NEW_BODY}]
|
||||
)
|
||||
self.assertEqual(result["applied"], {"title": NEW_TITLE, "body": NEW_BODY})
|
||||
|
||||
def test_body_can_be_cleared(self):
|
||||
result = self._edit(body="")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(self.issue["body"], "")
|
||||
|
||||
def test_targets_the_issue_endpoint_never_the_pull_endpoint(self):
|
||||
self._edit(title=NEW_TITLE)
|
||||
patched = [url for method, url in self.calls if method == "PATCH"]
|
||||
self.assertTrue(patched)
|
||||
for url in patched:
|
||||
self.assertIn("/issues/", url)
|
||||
self.assertNotIn("/pulls/", url)
|
||||
|
||||
def test_labels_state_assignee_and_milestone_are_provably_unchanged(self):
|
||||
result = self._edit(title=NEW_TITLE)
|
||||
proof = result["read_after_write"]
|
||||
self.assertTrue(proof["preserved_intact"])
|
||||
self.assertEqual(proof["preserved_before"], proof["preserved_after"])
|
||||
self.assertEqual(proof["preserved_after"]["labels"], ["type:bug", "mcp"])
|
||||
self.assertEqual(proof["preserved_after"]["state"], "open")
|
||||
self.assertEqual(proof["preserved_after"]["assignees"], ["author-user"])
|
||||
self.assertEqual(proof["preserved_after"]["milestone"], "v1.2.0")
|
||||
|
||||
def test_read_after_write_re_reads_the_issue(self):
|
||||
self._edit(title=NEW_TITLE)
|
||||
issue_calls = [method for method, url in self.calls if "/issues/" in url]
|
||||
self.assertEqual(issue_calls, ["GET", "PATCH", "GET"])
|
||||
|
||||
|
||||
class TestEditIssueFailsClosed(_EditIssueToolHarness):
|
||||
def test_no_op_request_is_rejected_without_a_patch(self):
|
||||
result = self._edit(title=ORIGINAL_TITLE)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertTrue(result["no_op"])
|
||||
self.assertEqual(self._patch_methods(), [])
|
||||
self.assertTrue(result["reasons"])
|
||||
self.assertTrue(result["safe_next_action"])
|
||||
|
||||
def test_invalid_request_raises_before_any_api_call(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._edit()
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_blank_title_raises_before_any_api_call(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._edit(title=" ")
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_authorization_failure_blocks_before_any_api_call(self):
|
||||
result = self._edit(profile="read-only-author", title=NEW_TITLE)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertIn("permission_report", result)
|
||||
self.assertEqual(
|
||||
result["permission_report"]["missing_permission"],
|
||||
task_capability_map.required_permission("edit_issue"),
|
||||
)
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_pull_request_target_is_refused_without_a_patch(self):
|
||||
self.issue["pull_request"] = {"merged": False}
|
||||
result = self._edit(title=NEW_TITLE)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertEqual(self._patch_methods(), [])
|
||||
self.assertIn("gitea_edit_pr", result["safe_next_action"])
|
||||
|
||||
def test_pre_read_transport_error_is_reported(self):
|
||||
def boom(method, url, auth, payload=None):
|
||||
raise RuntimeError("connection reset by peer")
|
||||
|
||||
with patch("mcp_server.api_request", side_effect=boom):
|
||||
result = self._edit(title=NEW_TITLE)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertTrue(result["reasons"])
|
||||
self.assertTrue(result["safe_next_action"])
|
||||
|
||||
def test_patch_transport_error_is_reported_not_swallowed(self):
|
||||
def flaky(method, url, auth, payload=None):
|
||||
if method == "PATCH":
|
||||
raise RuntimeError("gitea exploded")
|
||||
return self._api(method, url, auth, payload)
|
||||
|
||||
with patch("mcp_server.api_request", side_effect=flaky):
|
||||
result = self._edit(title=NEW_TITLE)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertIn("issue edit failed", result["reasons"][0])
|
||||
self.assertEqual(self.issue["title"], ORIGINAL_TITLE)
|
||||
|
||||
def test_read_back_transport_error_reports_an_unverified_edit(self):
|
||||
state = {"gets": 0}
|
||||
|
||||
def flaky(method, url, auth, payload=None):
|
||||
if method == "GET" and "/issues/" in url:
|
||||
state["gets"] += 1
|
||||
if state["gets"] > 1:
|
||||
raise RuntimeError("read timed out")
|
||||
return self._api(method, url, auth, payload)
|
||||
|
||||
with patch("mcp_server.api_request", side_effect=flaky):
|
||||
result = self._edit(title=NEW_TITLE)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(result["performed"])
|
||||
self.assertFalse(result["verified"])
|
||||
self.assertTrue(result["safe_next_action"])
|
||||
|
||||
def test_unapplied_edit_fails_verification(self):
|
||||
def sticky(method, url, auth, payload=None):
|
||||
if method == "PATCH":
|
||||
self.calls.append((method, url))
|
||||
# Report success but store nothing.
|
||||
return dict(self.issue)
|
||||
return self._api(method, url, auth, payload)
|
||||
|
||||
with patch("mcp_server.api_request", side_effect=sticky):
|
||||
result = self._edit(title=NEW_TITLE)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(result["performed"])
|
||||
self.assertFalse(result["verified"])
|
||||
self.assertEqual(
|
||||
result["read_after_write"]["mismatches"][0]["field"], "title"
|
||||
)
|
||||
|
||||
def test_edit_that_drops_a_label_fails_verification(self):
|
||||
def label_eating(method, url, auth, payload=None):
|
||||
if method == "PATCH":
|
||||
self.calls.append((method, url))
|
||||
self.issue.update(payload or {})
|
||||
self.issue["labels"] = []
|
||||
return dict(self.issue)
|
||||
return self._api(method, url, auth, payload)
|
||||
|
||||
with patch("mcp_server.api_request", side_effect=label_eating):
|
||||
result = self._edit(title=NEW_TITLE)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["read_after_write"]["preserved_intact"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration and gate wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestEditIssueRegistrationAndGates(unittest.TestCase):
|
||||
def test_tool_is_registered(self):
|
||||
self.assertIn("gitea_edit_issue", _registered_tool_names())
|
||||
|
||||
def test_resolver_task_exists_with_author_role(self):
|
||||
self.assertEqual(
|
||||
task_capability_map.required_permission("edit_issue"),
|
||||
"gitea.issue.comment",
|
||||
)
|
||||
self.assertEqual(task_capability_map.required_role("edit_issue"), "author")
|
||||
|
||||
def test_tool_gate_matches_the_resolver_task(self):
|
||||
self.assertEqual(
|
||||
task_capability_map.ISSUE_MUTATION_TOOL_TASKS["gitea_edit_issue"],
|
||||
"edit_issue",
|
||||
)
|
||||
self.assertEqual(
|
||||
task_capability_map.tool_required_permission("gitea_edit_issue"),
|
||||
task_capability_map.required_permission("edit_issue"),
|
||||
)
|
||||
|
||||
def test_declared_as_an_anti_stomp_mutation_task(self):
|
||||
self.assertIn("edit_issue", anti_stomp_preflight.MUTATION_TASKS)
|
||||
|
||||
def test_edit_pr_remains_pull_request_only(self):
|
||||
import inspect
|
||||
|
||||
params = inspect.signature(mcp_server.gitea_edit_pr).parameters
|
||||
self.assertIn("pr_number", params)
|
||||
self.assertNotIn("issue_number", params)
|
||||
|
||||
def test_edit_issue_cannot_change_state_or_labels(self):
|
||||
import inspect
|
||||
|
||||
params = inspect.signature(mcp_server.gitea_edit_issue).parameters
|
||||
self.assertEqual(
|
||||
[name for name in params if name in ("title", "body")],
|
||||
["title", "body"],
|
||||
)
|
||||
for forbidden in ("state", "labels", "assignee", "assignees", "milestone"):
|
||||
self.assertNotIn(forbidden, params)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The drift guard itself
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestInventoryDriftRule(unittest.TestCase):
|
||||
def test_missing_markers_fail_closed(self):
|
||||
with self.assertRaises(ValueError):
|
||||
mcp_tool_inventory.parse_documented_inventory("no markers here")
|
||||
|
||||
def test_documented_but_unregistered_is_drift(self):
|
||||
result = mcp_tool_inventory.assess_inventory_drift(
|
||||
["gitea_edit_issue", "gitea_view_issue"], ["gitea_view_issue"]
|
||||
)
|
||||
self.assertFalse(result["in_sync"])
|
||||
self.assertEqual(result["documented_not_registered"], ["gitea_edit_issue"])
|
||||
self.assertTrue(result["safe_next_action"])
|
||||
|
||||
def test_registered_but_undocumented_is_drift(self):
|
||||
result = mcp_tool_inventory.assess_inventory_drift(
|
||||
["gitea_view_issue"], ["gitea_view_issue", "gitea_edit_issue"]
|
||||
)
|
||||
self.assertFalse(result["in_sync"])
|
||||
self.assertEqual(result["registered_not_documented"], ["gitea_edit_issue"])
|
||||
|
||||
def test_unsorted_inventory_is_drift(self):
|
||||
result = mcp_tool_inventory.assess_inventory_drift(
|
||||
["gitea_view_issue", "gitea_edit_issue"],
|
||||
["gitea_view_issue", "gitea_edit_issue"],
|
||||
)
|
||||
self.assertFalse(result["in_sync"])
|
||||
self.assertFalse(result["sorted"])
|
||||
|
||||
def test_module_names_are_not_treated_as_tools(self):
|
||||
self.assertFalse(mcp_tool_inventory.looks_like_tool_name("gitea_auth"))
|
||||
self.assertTrue(mcp_tool_inventory.looks_like_tool_name("gitea_view_issue"))
|
||||
|
||||
def test_unregistered_doc_reference_is_reported(self):
|
||||
result = mcp_tool_inventory.assess_doc_references(
|
||||
{"skills/example.md": {"gitea_edit_issue"}}, ["gitea_view_issue"]
|
||||
)
|
||||
self.assertFalse(result["clean"])
|
||||
self.assertEqual(result["unregistered"][0]["tool"], "gitea_edit_issue")
|
||||
|
||||
def test_rendered_block_round_trips(self):
|
||||
block = mcp_tool_inventory.render_inventory_block(
|
||||
["gitea_view_issue", "gitea_edit_issue"]
|
||||
)
|
||||
self.assertEqual(
|
||||
mcp_tool_inventory.parse_documented_inventory(block),
|
||||
["gitea_edit_issue", "gitea_view_issue"],
|
||||
)
|
||||
|
||||
|
||||
class TestDocumentationMatchesRegistry(unittest.TestCase):
|
||||
"""The live guard: docs and the registry must not drift apart."""
|
||||
|
||||
def test_documented_inventory_equals_registered_tools(self):
|
||||
doc = REPO_ROOT / mcp_tool_inventory.INVENTORY_DOC_PATH
|
||||
self.assertTrue(doc.exists(), f"{doc} is missing")
|
||||
documented = mcp_tool_inventory.parse_documented_inventory(
|
||||
doc.read_text(encoding="utf-8")
|
||||
)
|
||||
result = mcp_tool_inventory.assess_inventory_drift(
|
||||
documented, _registered_tool_names()
|
||||
)
|
||||
self.assertTrue(result["in_sync"], "; ".join(result["reasons"]))
|
||||
|
||||
def test_every_tool_named_in_the_skills_is_registered(self):
|
||||
references: dict[str, set[str]] = {}
|
||||
pattern = str(REPO_ROOT / "skills" / "**" / "*.md")
|
||||
paths = glob.glob(pattern, recursive=True)
|
||||
self.assertTrue(paths, "no skill documents found to check")
|
||||
for path in paths:
|
||||
text = Path(path).read_text(encoding="utf-8")
|
||||
names = mcp_tool_inventory.extract_tool_references(text)
|
||||
if names:
|
||||
references[str(Path(path).relative_to(REPO_ROOT))] = names
|
||||
result = mcp_tool_inventory.assess_doc_references(
|
||||
references, _registered_tool_names()
|
||||
)
|
||||
self.assertTrue(result["clean"], "; ".join(result["reasons"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,3 +1,7 @@
|
||||
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
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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
|
||||
"""Tests for the pre-create issue content gate (Issue #582)."""
|
||||
import sys
|
||||
import unittest
|
||||
@@ -15,9 +19,10 @@ from issue_content_gate import ( # noqa: E402
|
||||
from mcp_server import gitea_create_issue # noqa: E402
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||
}
|
||||
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeAndPointers(unittest.TestCase):
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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
|
||||
"""Tests for pre-create issue duplicate gate (Issue #207)."""
|
||||
import sys
|
||||
import unittest
|
||||
@@ -19,9 +23,10 @@ from mcp_server import gitea_create_issue # noqa: E402
|
||||
from review_proofs import assess_duplicate_search_proof as proof_assess # noqa: E402
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||
}
|
||||
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
|
||||
)
|
||||
CANONICAL_TITLE = (
|
||||
"Add hard queue-target resolution wall before PR inventory "
|
||||
"or empty-queue claims"
|
||||
@@ -162,7 +167,7 @@ class TestCreateIssueMCPGate(unittest.TestCase):
|
||||
mock_role_check.return_value = (True, [])
|
||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
||||
result = gitea_create_issue(title="Unique new issue", body="body text")
|
||||
result = gitea_create_issue(title="Unique new issue", body="body text", remote="prgs")
|
||||
self.assertEqual(result["number"], 1)
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user