Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3edeba4d7f | ||
|
|
0425bf9a43 | ||
|
|
eac7afe5cb | ||
|
|
fdab6b6c69 | ||
|
|
277ec5269d | ||
|
|
b05075fd25 | ||
|
|
61c3a57df5 | ||
|
|
c908ed6050 | ||
|
|
0a38d92382 | ||
|
|
15a8a76e99 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+515
-107
@@ -393,6 +393,35 @@ def _configured_canonical_root() -> tuple[str | None, str | None]:
|
||||
return crr.configured_canonical_root(profile, os.environ)
|
||||
|
||||
|
||||
def _canonical_local_git_root() -> str:
|
||||
"""Local git working root for *target-repository* operations (#741).
|
||||
|
||||
Two distinct roots exist and must never be conflated:
|
||||
|
||||
* ``PROJECT_ROOT`` — the Gitea-Tools **installation** checkout, i.e. where
|
||||
this server script lives. Server implementation/version parity, workflow
|
||||
and skill file loading, and self-code staleness detection are anchored
|
||||
here and must stay that way.
|
||||
* the **canonical target repository root** — the working root of the
|
||||
repository whose issues/PRs/branches the namespace actually mutates.
|
||||
|
||||
Every local git invocation that reads or proves *target repository* state
|
||||
(remote identity, ancestry, worktree inventory, cleanup) must run here, or
|
||||
a cross-repository namespace silently operates on Gitea-Tools instead.
|
||||
|
||||
Returns the resolved canonical target root when one is configured, else
|
||||
``PROJECT_ROOT`` — which preserves the single-repository default exactly.
|
||||
A configured-but-invalid root is *not* silently replaced by the install
|
||||
checkout: it is returned as-is so downstream git calls fail rather than
|
||||
succeed against the wrong repository, and
|
||||
:func:`_enforce_canonical_repository_root` fails the mutation closed first.
|
||||
"""
|
||||
configured, _source = _configured_canonical_root()
|
||||
if not configured:
|
||||
return PROJECT_ROOT
|
||||
return crr.resolve_repo_toplevel(configured) or os.path.realpath(configured)
|
||||
|
||||
|
||||
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
||||
"""Resolve the namespace-scoped workspace root inspected by pre-flight guards.
|
||||
|
||||
@@ -1448,6 +1477,12 @@ def _verify_role_mutation_workspace(
|
||||
git_state = issue_lock_worktree.read_worktree_git_state(
|
||||
_resolve_preflight_workspace_path(worktree_path)
|
||||
)
|
||||
# #741: thread the configured canonical root exactly as
|
||||
# _resolve_namespace_mutation_context does. Omitting it here made the two
|
||||
# paths disagree: the #274 branches-only / worktree-membership guards fell
|
||||
# back to the install checkout and validated Gitea-Tools/branches/ instead
|
||||
# of the target repository the namespace is actually bound to.
|
||||
_configured_root, _configured_source = _configured_canonical_root()
|
||||
assessment = nwb.assess_namespace_mutation_workspace(
|
||||
role_kind=role,
|
||||
worktree_path=worktree_path,
|
||||
@@ -1458,6 +1493,7 @@ def _verify_role_mutation_workspace(
|
||||
),
|
||||
profile_name=get_profile().get("profile_name"),
|
||||
current_branch=git_state.get("current_branch"),
|
||||
configured_canonical_root=_configured_root,
|
||||
)
|
||||
if assessment["block"]:
|
||||
raise RuntimeError(
|
||||
@@ -1614,6 +1650,7 @@ import issue_lock_worktree # noqa: E402
|
||||
import issue_lock_provenance # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import issue_lock_adoption # noqa: E402
|
||||
import issue_lock_recovery # noqa: E402
|
||||
import stacked_pr_support # noqa: E402
|
||||
import merge_approval_gate # noqa: E402
|
||||
import review_quarantine # noqa: E402 # #695 contaminated formal-review quarantine
|
||||
@@ -1648,6 +1685,45 @@ def _workspace_repository_slug(remote: str | None) -> str | None:
|
||||
return session_ctx.format_repository_slug(parsed[0], parsed[1])
|
||||
|
||||
|
||||
def _canonical_repository_slug(
|
||||
profile: dict,
|
||||
remote: str | None,
|
||||
) -> tuple[str | None, list[str]]:
|
||||
"""Repository identity of the *configured* canonical root, if any (#739 F2).
|
||||
|
||||
Returns ``(slug, reasons)``. A namespace with no configured
|
||||
``canonical_repository_root`` yields ``(None, [])`` so callers keep the
|
||||
install-derived single-repository default. A configured root that cannot be
|
||||
resolved to a repository identity yields ``(None, reasons)`` — a fail-closed
|
||||
signal, never a silent fallback to the installation checkout, because that
|
||||
fallback is exactly what lets a cross-repository namespace validate against
|
||||
the wrong repository.
|
||||
|
||||
Env-over-profile precedence and the existence / git-toplevel / resolvable
|
||||
remote-identity validation all live in ``crr``.
|
||||
"""
|
||||
configured_value, configured_source = crr.configured_canonical_root(
|
||||
profile, os.environ
|
||||
)
|
||||
if not configured_value:
|
||||
return None, []
|
||||
assessment = crr.assess_canonical_repository_root(
|
||||
configured_value=configured_value,
|
||||
source=configured_source,
|
||||
expected_slug=None,
|
||||
process_project_root=PROJECT_ROOT,
|
||||
remote=remote,
|
||||
require_binding=True,
|
||||
)
|
||||
slug = assessment.get("resolved_slug")
|
||||
if assessment.get("block") or not slug:
|
||||
return None, list(assessment.get("reasons") or [
|
||||
"configured canonical repository root has no resolvable "
|
||||
"repository identity (fail closed)"
|
||||
])
|
||||
return slug, []
|
||||
|
||||
|
||||
def _trusted_session_repository(
|
||||
profile: dict,
|
||||
remote: str | None,
|
||||
@@ -1686,32 +1762,10 @@ def _trusted_session_repository(
|
||||
# self-authorizing). Env-over-profile precedence and fail-closed validation
|
||||
# (existence, git toplevel, resolvable remote identity) are handled by
|
||||
# ``crr``; unconfigured single-repo namespaces keep the install-derived slug.
|
||||
configured_value, configured_source = crr.configured_canonical_root(
|
||||
profile, os.environ
|
||||
)
|
||||
if configured_value:
|
||||
assessment = crr.assess_canonical_repository_root(
|
||||
configured_value=configured_value,
|
||||
source=configured_source,
|
||||
expected_slug=None,
|
||||
process_project_root=PROJECT_ROOT,
|
||||
remote=remote,
|
||||
require_binding=True,
|
||||
)
|
||||
slug = assessment.get("resolved_slug")
|
||||
if assessment.get("block") or not slug:
|
||||
return {
|
||||
"org": None,
|
||||
"repository": None,
|
||||
"reasons": assessment.get("reasons")
|
||||
or [
|
||||
"configured canonical repository root has no resolvable "
|
||||
"repository identity; session repository cannot be "
|
||||
"authorized (fail closed)"
|
||||
],
|
||||
}
|
||||
else:
|
||||
slug = _workspace_repository_slug(remote)
|
||||
canonical_slug, canonical_reasons = _canonical_repository_slug(profile, remote)
|
||||
if canonical_reasons:
|
||||
return {"org": None, "repository": None, "reasons": canonical_reasons}
|
||||
slug = canonical_slug or _workspace_repository_slug(remote)
|
||||
scope = session_ctx.assess_repository_scope(
|
||||
workspace_slug=slug,
|
||||
allowed=allowed,
|
||||
@@ -2386,9 +2440,24 @@ def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
|
||||
filled_org = False
|
||||
filled_repo = False
|
||||
if not org_explicit or not repo_explicit:
|
||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(
|
||||
_local_git_remote_url(remote)
|
||||
)
|
||||
parsed = None
|
||||
# #741: for a cross-repository namespace, prefer the canonical root's
|
||||
# own repository identity. `_local_git_remote_url` looks a remote up by
|
||||
# *name*, but a target checkout commonly names its remote `origin`
|
||||
# rather than `prgs`; that lookup then returns None and the omitted
|
||||
# coordinates silently fell through to the REMOTES default target —
|
||||
# i.e. a completely unrelated repository. `_canonical_repository_slug`
|
||||
# probes the candidate remote names against the canonical root.
|
||||
try:
|
||||
canonical_slug, _reasons = _canonical_repository_slug(get_profile(), remote)
|
||||
except Exception:
|
||||
canonical_slug = None
|
||||
if canonical_slug:
|
||||
parsed = session_ctx.parse_repository_slug(canonical_slug)
|
||||
if not parsed:
|
||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(
|
||||
_local_git_remote_url(remote)
|
||||
)
|
||||
if parsed:
|
||||
if not org_explicit:
|
||||
resolved_org = parsed[0]
|
||||
@@ -2396,6 +2465,40 @@ def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
|
||||
if not repo_explicit:
|
||||
resolved_repo = parsed[1]
|
||||
filled_repo = True
|
||||
# #741: explicit caller coordinates may *confirm* a cross-repository
|
||||
# canonical binding but must never override it. Both-explicit coordinates
|
||||
# short-circuit the #530 guard (remote_repo_guard.assess_remote_repo_match),
|
||||
# so without this check a caller could name any repository and skip
|
||||
# validation entirely. Unconfigured (single-repository) namespaces yield no
|
||||
# canonical slug and are unaffected. An unresolvable configured root is not
|
||||
# blocked here — reads must keep working; the fail-closed decision belongs
|
||||
# to _enforce_canonical_repository_root at the mutation gate.
|
||||
if org_explicit or repo_explicit:
|
||||
try:
|
||||
canonical_slug, _canonical_reasons = _canonical_repository_slug(
|
||||
get_profile(), remote
|
||||
)
|
||||
except Exception:
|
||||
canonical_slug = None
|
||||
bound = (
|
||||
session_ctx.parse_repository_slug(canonical_slug)
|
||||
if canonical_slug
|
||||
else None
|
||||
)
|
||||
if bound:
|
||||
override = session_ctx.assess_repository_override(
|
||||
requested_org=org,
|
||||
requested_repo=repo,
|
||||
bound_org=bound[0],
|
||||
bound_repo=bound[1],
|
||||
)
|
||||
if override.get("block"):
|
||||
raise RuntimeError(
|
||||
"Canonical repository binding guard (#741): "
|
||||
+ "; ".join(override.get("reasons") or [])
|
||||
+ ". Explicit org/repo may confirm the configured canonical "
|
||||
"repository binding but can never override it."
|
||||
)
|
||||
_enforce_remote_repo_guard(
|
||||
remote,
|
||||
resolved_org,
|
||||
@@ -3050,11 +3153,14 @@ def gitea_lock_issue(
|
||||
return blocked
|
||||
|
||||
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
|
||||
worktree_path, PROJECT_ROOT
|
||||
worktree_path, _canonical_local_git_root()
|
||||
)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
existing_issue_lock = _load_existing_issue_lock(
|
||||
remote=remote, org=o, repo=r, issue_number=issue_number
|
||||
)
|
||||
active_lease_block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
_load_existing_issue_lock(remote=remote, org=o, repo=r, issue_number=issue_number),
|
||||
existing_issue_lock,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
@@ -3096,6 +3202,72 @@ def gitea_lock_issue(
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
# ── Dead-session lock recovery assessment (#753) ──
|
||||
# When the MCP session that took a lock exits, the lock goes non-live
|
||||
# (stale by dead PID) even inside its lease TTL, and the branch it owns is
|
||||
# ahead of its base by construction — so the base-equivalence gate below
|
||||
# makes normal re-lock unreachable for every PR that already exists.
|
||||
#
|
||||
# This grants a waiver ONLY for that case, proven against the durable lock
|
||||
# record plus live git/Gitea observation. A refused assessment never raises:
|
||||
# it simply withholds the waiver, leaving the pre-existing guard to fail
|
||||
# closed exactly as before. Recovery can only ever add permission.
|
||||
recovery_assessment: dict | None = None
|
||||
if (
|
||||
existing_issue_lock
|
||||
and existing_issue_lock.get("issue_number") == issue_number
|
||||
and not issue_lock_store.is_lease_live(existing_issue_lock)
|
||||
):
|
||||
recovery_auth = _auth(h)
|
||||
try:
|
||||
recovery_branches = api_get_all(
|
||||
f"{repo_api_url(h, o, r)}/branches", recovery_auth
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not list branches to verify issue-lock recovery: {exc}"
|
||||
)
|
||||
recovery_remote_head: str | None = None
|
||||
recovery_candidates: list[str] = []
|
||||
for entry in recovery_branches:
|
||||
entry_name = _branch_entry_name(entry)
|
||||
if entry_name == branch_name:
|
||||
recovery_remote_head = _branch_entry_commit_sha(entry)
|
||||
if issue_lock_adoption.branch_carries_issue_marker(entry_name, issue_number):
|
||||
recovery_candidates.append(entry_name)
|
||||
recovery_pr_head: str | None = None
|
||||
recovery_pr_number: int | None = None
|
||||
for pull in _list_open_pulls(h, o, r, recovery_auth):
|
||||
pull_head = pull.get("head") or {}
|
||||
if str(pull_head.get("ref") or "") == branch_name:
|
||||
recovery_pr_head = pull_head.get("sha")
|
||||
recovery_pr_number = pull.get("number")
|
||||
break
|
||||
recovery_claimant = _work_lease_claimant(h)
|
||||
recovery_assessment = issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||
existing_issue_lock,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
identity=recovery_claimant.get("username"),
|
||||
profile=recovery_claimant.get("profile"),
|
||||
current_branch=git_state.get("current_branch"),
|
||||
porcelain_status=git_state.get("porcelain_status") or "",
|
||||
head_sha=git_state.get("head_sha"),
|
||||
remote_head_sha=recovery_remote_head,
|
||||
pr_head_sha=recovery_pr_head,
|
||||
pr_number=recovery_pr_number,
|
||||
competing_live_locks=issue_lock_store.list_live_locks(),
|
||||
candidate_branches=recovery_candidates,
|
||||
current_pid=os.getpid(),
|
||||
)
|
||||
|
||||
recovery_sanctioned = bool(
|
||||
recovery_assessment and recovery_assessment.get("recovery_sanctioned")
|
||||
)
|
||||
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
||||
worktree_path=resolved_worktree,
|
||||
current_branch=git_state.get("current_branch"),
|
||||
@@ -3103,10 +3275,20 @@ def gitea_lock_issue(
|
||||
base_equivalent=git_state.get("base_equivalent"),
|
||||
inspected_git_root=git_state.get("inspected_git_root"),
|
||||
base_branch=git_state.get("base_branch"),
|
||||
recovery_sanctioned=recovery_sanctioned,
|
||||
)
|
||||
if lock_assessment["block"]:
|
||||
reasons = list(lock_assessment.get("reasons") or [])
|
||||
# Surface why recovery was unavailable, so a blocked caller sees the
|
||||
# exact missing evidence instead of only the base-equivalence text.
|
||||
if recovery_assessment and recovery_assessment.get("is_candidate"):
|
||||
reasons.append(
|
||||
issue_lock_recovery.format_recovery_refusal(recovery_assessment)
|
||||
)
|
||||
raise RuntimeError(
|
||||
issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment)
|
||||
issue_lock_worktree.format_issue_lock_worktree_error(
|
||||
{**lock_assessment, "reasons": reasons}
|
||||
)
|
||||
)
|
||||
|
||||
auth = _auth(h)
|
||||
@@ -3169,6 +3351,14 @@ def gitea_lock_issue(
|
||||
}
|
||||
if stacked_approved:
|
||||
data["approved_stacked_base"] = stacked_approved
|
||||
if recovery_sanctioned and recovery_assessment:
|
||||
# #753 AC2/AC6: record that this claim was recovered after session
|
||||
# death, with the prior and replacement session identity, so the
|
||||
# takeover is auditable and never looks like an original claim.
|
||||
data["dead_session_recovery"] = issue_lock_recovery.build_recovery_record(
|
||||
recovery_assessment,
|
||||
recovered_at=_work_lease_timestamp(_work_lease_now()),
|
||||
)
|
||||
|
||||
lock_file_path = _save_issue_lock(data)
|
||||
lock_record = issue_lock_store.read_lock_file(lock_file_path) or data
|
||||
@@ -3202,6 +3392,14 @@ def gitea_lock_issue(
|
||||
"lock_freshness": freshness,
|
||||
"lock_proof": lock_proof,
|
||||
}
|
||||
if recovery_sanctioned and recovery_assessment:
|
||||
result["dead_session_recovery"] = data["dead_session_recovery"]
|
||||
result["message"] = (
|
||||
f"Recovered the durable lock for issue #{issue_number} on branch "
|
||||
f"'{branch_name}' after the owning MCP session (pid "
|
||||
f"{recovery_assessment['evidence'].get('prior_session_pid')}) exited; "
|
||||
"ownership evidence matched exactly (fail-closed check complete)."
|
||||
)
|
||||
if stacked_approved:
|
||||
result["approved_stacked_base"] = stacked_approved
|
||||
result["message"] = (
|
||||
@@ -3352,7 +3550,7 @@ def gitea_create_pr(
|
||||
locked_worktree = lock_data.get("worktree_path")
|
||||
|
||||
worktree_check = issue_lock_worktree.verify_pr_worktree_matches_lock(
|
||||
locked_worktree, worktree_path, PROJECT_ROOT
|
||||
locked_worktree, worktree_path, _canonical_local_git_root()
|
||||
)
|
||||
if worktree_check["block"]:
|
||||
raise ValueError(worktree_check["reasons"][0])
|
||||
@@ -8482,13 +8680,24 @@ def gitea_merge_pr(
|
||||
|
||||
|
||||
def _local_git_remote_url(remote_name: str) -> str | None:
|
||||
"""Best-effort local ``git remote get-url`` for trust-gate corroboration."""
|
||||
"""Best-effort local ``git remote get-url`` for trust-gate corroboration.
|
||||
|
||||
#741: this runs in the **canonical target repository root**, not the
|
||||
Gitea-Tools installation checkout. Every consumer of this function derives
|
||||
*target repository identity* from it (``_resolve``, the #530 remote/repo
|
||||
guard, the anti-stomp org/repo fill, ``_workspace_repository_slug``), so
|
||||
running it in ``PROJECT_ROOT`` inverted those guards for a cross-repository
|
||||
namespace: the genuinely bound target was rejected while Gitea-Tools was
|
||||
accepted. Single-repository namespaces are unaffected —
|
||||
:func:`_canonical_local_git_root` returns ``PROJECT_ROOT`` when no
|
||||
cross-repository binding is configured.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "remote", "get-url", remote_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=PROJECT_ROOT,
|
||||
cwd=_canonical_local_git_root(),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
@@ -8768,8 +8977,28 @@ def _delete_branch_repository_binding_block(
|
||||
(unproven target).
|
||||
|
||||
Returns a structured block dict, or ``None`` when the target is authorized.
|
||||
|
||||
#739 F2: the trusted identity is the session's configured
|
||||
``canonical_repository_root`` when the namespace declares one. Deriving it
|
||||
from ``_workspace_repository_slug`` alone reads the *installation* checkout
|
||||
(``_local_git_remote_url`` runs in ``PROJECT_ROOT``, always Gitea-Tools),
|
||||
which inverts the guard for a cross-repository namespace: the genuinely
|
||||
bound target is rejected and Gitea-Tools is accepted. A configured root that
|
||||
cannot be resolved fails closed rather than falling back to the install
|
||||
identity; unconfigured namespaces keep the install-derived default.
|
||||
"""
|
||||
workspace_slug = _workspace_repository_slug(remote)
|
||||
canonical_slug, canonical_reasons = _canonical_repository_slug(
|
||||
get_profile(), remote
|
||||
)
|
||||
if canonical_reasons:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": canonical_reasons,
|
||||
"blocker_kind": "repository_binding",
|
||||
}
|
||||
workspace_slug = canonical_slug or _workspace_repository_slug(remote)
|
||||
workspace_parts = (
|
||||
session_ctx.parse_repository_slug(workspace_slug) if workspace_slug else None
|
||||
)
|
||||
@@ -9038,7 +9267,7 @@ def gitea_cleanup_merged_pr_branch(
|
||||
else f"origin/{target_branch}"
|
||||
)
|
||||
head_on_target = merged_cleanup_reconcile.is_head_ancestor_of_ref(
|
||||
PROJECT_ROOT,
|
||||
_canonical_local_git_root(),
|
||||
head_sha,
|
||||
target_ref,
|
||||
)
|
||||
@@ -9073,7 +9302,7 @@ def gitea_cleanup_merged_pr_branch(
|
||||
repo=r,
|
||||
branch=head_branch,
|
||||
pr_number=pr_number,
|
||||
project_root=PROJECT_ROOT,
|
||||
project_root=_canonical_local_git_root(),
|
||||
auth=auth,
|
||||
base_api=base,
|
||||
)
|
||||
@@ -9568,7 +9797,7 @@ def gitea_capture_branches_worktree_snapshot(
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
root = PROJECT_ROOT
|
||||
root = _canonical_local_git_root()
|
||||
if worktree_path:
|
||||
root = os.path.realpath(os.path.abspath(worktree_path))
|
||||
git_root = _get_git_root(root)
|
||||
@@ -9678,14 +9907,14 @@ def gitea_reconcile_merged_cleanups(
|
||||
h, o, r, auth, head_ref
|
||||
)
|
||||
head_on_master[int(pr["number"])] = merged_cleanup_reconcile.is_head_ancestor_of_ref(
|
||||
PROJECT_ROOT, head_sha, master_ref
|
||||
_canonical_local_git_root(), head_sha, master_ref
|
||||
)
|
||||
|
||||
delete_capability_allowed = not _profile_operation_gate("gitea.branch.delete")
|
||||
|
||||
# #534: discover reviewer scratch trees and active leases for those PRs.
|
||||
scratch_candidates = merged_cleanup_reconcile.discover_reviewer_scratch_worktrees(
|
||||
PROJECT_ROOT
|
||||
_canonical_local_git_root()
|
||||
)
|
||||
active_reviewer_leases: dict[int, bool] = {}
|
||||
pr_states: dict[int, dict] = {}
|
||||
@@ -9711,7 +9940,7 @@ def gitea_reconcile_merged_cleanups(
|
||||
}
|
||||
|
||||
report = merged_cleanup_reconcile.build_reconciliation_report(
|
||||
project_root=PROJECT_ROOT,
|
||||
project_root=_canonical_local_git_root(),
|
||||
closed_prs=merged_closed,
|
||||
open_prs=open_prs,
|
||||
remote_branch_exists=remote_branch_exists,
|
||||
@@ -9751,7 +9980,7 @@ def gitea_reconcile_merged_cleanups(
|
||||
repo=r,
|
||||
branch=head_branch,
|
||||
pr_number=pr_num_int,
|
||||
project_root=PROJECT_ROOT,
|
||||
project_root=_canonical_local_git_root(),
|
||||
auth=auth,
|
||||
base_api=base,
|
||||
)
|
||||
@@ -9835,7 +10064,7 @@ def gitea_reconcile_merged_cleanups(
|
||||
|
||||
if local_assessment.get("safe_to_remove_worktree"):
|
||||
result = merged_cleanup_reconcile.remove_local_worktree(
|
||||
PROJECT_ROOT,
|
||||
_canonical_local_git_root(),
|
||||
head_branch,
|
||||
worktree_path=local_assessment.get("worktree_path"),
|
||||
)
|
||||
@@ -9845,7 +10074,7 @@ def gitea_reconcile_merged_cleanups(
|
||||
if not scratch.get("safe_to_remove_worktree"):
|
||||
continue
|
||||
result = merged_cleanup_reconcile.remove_reviewer_scratch_worktree(
|
||||
PROJECT_ROOT, scratch.get("worktree_path") or ""
|
||||
_canonical_local_git_root(), scratch.get("worktree_path") or ""
|
||||
)
|
||||
actions.append({"action": "remove_reviewer_scratch_worktree", **result})
|
||||
|
||||
@@ -9943,7 +10172,7 @@ def gitea_assess_already_landed_reconciliation(
|
||||
|
||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
||||
pr=pr,
|
||||
project_root=PROJECT_ROOT,
|
||||
project_root=_canonical_local_git_root(),
|
||||
remote=remote,
|
||||
target_branch=target_branch,
|
||||
)
|
||||
@@ -10039,14 +10268,14 @@ def gitea_scan_already_landed_open_prs(
|
||||
}
|
||||
|
||||
target_fetch = reconciliation_workflow.fetch_target_branch(
|
||||
PROJECT_ROOT, remote, target_branch
|
||||
_canonical_local_git_root(), remote, target_branch
|
||||
)
|
||||
|
||||
candidates: list[dict] = []
|
||||
for pr in open_prs:
|
||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
||||
pr=pr,
|
||||
project_root=PROJECT_ROOT,
|
||||
project_root=_canonical_local_git_root(),
|
||||
remote=remote,
|
||||
target_branch=target_branch,
|
||||
target_fetch=target_fetch,
|
||||
@@ -10143,7 +10372,7 @@ def gitea_audit_worktree_cleanup(
|
||||
active_issue_branches.add(str(lock["branch_name"]).strip())
|
||||
|
||||
report = worktree_cleanup_audit.audit_branches_directory(
|
||||
PROJECT_ROOT,
|
||||
_canonical_local_git_root(),
|
||||
open_pr_branches=open_pr_branches,
|
||||
active_issue_branches=active_issue_branches,
|
||||
now=datetime.now(timezone.utc),
|
||||
@@ -10226,7 +10455,7 @@ def gitea_reconcile_already_landed_pr(
|
||||
|
||||
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
|
||||
pr=pr,
|
||||
project_root=PROJECT_ROOT,
|
||||
project_root=_canonical_local_git_root(),
|
||||
remote=remote,
|
||||
target_branch=target_branch,
|
||||
)
|
||||
@@ -10431,7 +10660,7 @@ def gitea_reconcile_superseded_by_merged_pr(
|
||||
)
|
||||
|
||||
target_fetch = already_landed_reconcile.fetch_target_branch(
|
||||
PROJECT_ROOT, remote, target_branch
|
||||
_canonical_local_git_root(), remote, target_branch
|
||||
)
|
||||
superseding_head_sha = (
|
||||
(superseding_pr.get("head") or {}).get("sha")
|
||||
@@ -10441,7 +10670,7 @@ def gitea_reconcile_superseded_by_merged_pr(
|
||||
ancestor = None
|
||||
if target_fetch.get("success"):
|
||||
ancestor = already_landed_reconcile.is_head_ancestor_of_ref(
|
||||
PROJECT_ROOT,
|
||||
_canonical_local_git_root(),
|
||||
superseding_head_sha,
|
||||
target_fetch.get("target_ref") or f"{remote}/{target_branch}",
|
||||
)
|
||||
@@ -11999,6 +12228,13 @@ def gitea_heartbeat_reviewer_pr_lease(
|
||||
verify_preflight_purity(remote, task="review_pr")
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
# Slide the sliding TTL forward (#747): the heartbeat is the liveness proof,
|
||||
# so renewal is stated explicitly rather than inherited from the acquisition
|
||||
# default.
|
||||
beat_at = datetime.now(timezone.utc)
|
||||
renewed_expiry = beat_at + timedelta(
|
||||
minutes=reviewer_pr_lease.LEASE_RENEWAL_MINUTES
|
||||
)
|
||||
body = reviewer_pr_lease.format_lease_body(
|
||||
repo=f"{o}/{r}",
|
||||
pr_number=pr_number,
|
||||
@@ -12011,6 +12247,8 @@ def gitea_heartbeat_reviewer_pr_lease(
|
||||
candidate_head=candidate_head or session.get("candidate_head"),
|
||||
target_branch=session.get("target_branch") or "master",
|
||||
target_branch_sha=target_branch_sha or session.get("target_branch_sha"),
|
||||
last_activity=beat_at,
|
||||
ttl_minutes=reviewer_pr_lease.LEASE_RENEWAL_MINUTES,
|
||||
)
|
||||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
with _audited(
|
||||
@@ -12051,6 +12289,13 @@ def gitea_heartbeat_reviewer_pr_lease(
|
||||
"phase": phase,
|
||||
"comment_id": posted.get("id"),
|
||||
"session_lease": updated,
|
||||
# Report the renewed window so an operator can tell "held and live"
|
||||
# from "held and dying" (#747).
|
||||
"ttl_minutes": reviewer_pr_lease.LEASE_RENEWAL_MINUTES,
|
||||
"expires_at": renewed_expiry.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
"seconds_remaining": reviewer_pr_lease.LEASE_RENEWAL_MINUTES * 60,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
@@ -14226,6 +14471,16 @@ def gitea_get_runtime_context(
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
"""
|
||||
# #739 F1: normalize the remote *before* any host lookup, identity
|
||||
# resolution, or session seeding. ``remote`` defaults to 'dadeschools'
|
||||
# across the whole tool surface, so a caller that omits it on a prgs-hosted
|
||||
# namespace would otherwise pin the argument default: the identity lookup
|
||||
# below would authenticate against the wrong host, and because first-bind is
|
||||
# first-write-wins a later, correct ``gitea_whoami(remote="prgs")`` could not
|
||||
# repair the binding. ``gitea_whoami`` has always normalized here; this is
|
||||
# the same call, not a new policy. Explicit non-default remotes pass through
|
||||
# untouched, and a dadeschools-hosted profile still resolves to dadeschools.
|
||||
remote = _effective_remote(remote)
|
||||
profile = get_profile()
|
||||
config = gitea_config.load_config()
|
||||
reveal = _reveal_endpoints()
|
||||
@@ -14403,13 +14658,23 @@ def gitea_assess_master_parity(
|
||||
Never mutates and makes no network calls. Read-only operations are never
|
||||
blocked by staleness; only mutating operations fail closed while stale.
|
||||
|
||||
The top-level ``startup_head``/``current_head``/``in_parity`` fields keep
|
||||
their original meaning: the *Gitea-Tools server implementation* only. #739
|
||||
F3 adds two separately labelled dimensions so a cross-repository namespace
|
||||
can tell them apart — ``server_implementation`` (this installation, restated
|
||||
under an explicit name) and ``target_repository`` (the configured canonical
|
||||
target checkout and its last-known remote master). Only the server dimension
|
||||
gates mutations.
|
||||
|
||||
Returns:
|
||||
dict with 'in_parity', 'stale', 'restart_required', 'startup_head',
|
||||
'current_head', 'mutation_gate_enforced', 'summary', 'reasons', and a
|
||||
'report' recovery payload when stale.
|
||||
'current_head', 'mutation_gate_enforced', 'summary', 'reasons',
|
||||
'server_implementation', 'target_repository', and a 'report' recovery
|
||||
payload when stale.
|
||||
"""
|
||||
parity = _current_master_parity()
|
||||
enforced = not master_parity_gate.gate_disabled()
|
||||
canonical_root, canonical_source = _configured_canonical_root()
|
||||
out = {
|
||||
"in_parity": parity["in_parity"],
|
||||
"stale": parity["stale"],
|
||||
@@ -14421,6 +14686,17 @@ def gitea_assess_master_parity(
|
||||
"summary": master_parity_gate.format_parity(parity),
|
||||
"reasons": parity["reasons"],
|
||||
"process_root": PROJECT_ROOT,
|
||||
"server_implementation": {
|
||||
"installation_root": PROJECT_ROOT,
|
||||
"startup_head": parity["startup_head"],
|
||||
"current_head": parity["current_head"],
|
||||
"stale": parity["stale"],
|
||||
"determinable": parity["determinable"],
|
||||
},
|
||||
"target_repository": master_parity_gate.assess_target_repository_parity(
|
||||
canonical_root=canonical_root,
|
||||
source=canonical_source,
|
||||
),
|
||||
}
|
||||
if parity["stale"] and enforced:
|
||||
out["report"] = master_parity_gate.parity_report(parity)
|
||||
@@ -15187,46 +15463,151 @@ def _count_commits_behind(
|
||||
return None
|
||||
|
||||
|
||||
def _matching_protection_rule(protections: Any, branch: str) -> dict | None:
|
||||
"""Select the branch-protection rule governing ``branch`` (#751)."""
|
||||
if not isinstance(protections, list):
|
||||
return None
|
||||
for rule in protections:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
|
||||
# Exact match or glob-ish contains for common patterns.
|
||||
if name == branch or name in ("*", f"{branch}"):
|
||||
return rule
|
||||
# Fallback: any protection that mentions the base branch.
|
||||
for rule in protections:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
|
||||
if name and (branch in name or name.endswith(branch)):
|
||||
return rule
|
||||
return None
|
||||
|
||||
|
||||
def _branch_protection_policy(
|
||||
base_url: str,
|
||||
auth: dict,
|
||||
*,
|
||||
base_branch: str,
|
||||
) -> dict:
|
||||
"""Read the live branch-protection policy for ``base_branch`` (#751).
|
||||
|
||||
Exposes both the current-base rule (``block_on_outdated_branch``) and the
|
||||
status-check requirement (``enable_status_check`` / ``status_check_contexts``)
|
||||
from the same payload, so the checks assessor can tell "no checks required"
|
||||
apart from "required checks pending".
|
||||
|
||||
``determinable`` is False only when the policy genuinely could not be read
|
||||
(missing branch or API failure) — never merely because no rule exists. A
|
||||
successful read that finds no protection for the branch is authoritative
|
||||
evidence that status checks are not required.
|
||||
"""
|
||||
policy: dict[str, Any] = {
|
||||
"determinable": False,
|
||||
"protection_found": False,
|
||||
"requires_current_base": None,
|
||||
"checks_enabled": None,
|
||||
"required_contexts": [],
|
||||
"base_branch": (base_branch or "").strip() or None,
|
||||
}
|
||||
branch = (base_branch or "").strip()
|
||||
if not branch:
|
||||
return policy
|
||||
|
||||
def _apply(rule: dict) -> None:
|
||||
policy["protection_found"] = True
|
||||
if "block_on_outdated_branch" in rule:
|
||||
policy["requires_current_base"] = bool(
|
||||
rule.get("block_on_outdated_branch")
|
||||
)
|
||||
if "enable_status_check" in rule:
|
||||
policy["checks_enabled"] = bool(rule.get("enable_status_check"))
|
||||
contexts = rule.get("status_check_contexts")
|
||||
if isinstance(contexts, list):
|
||||
policy["required_contexts"] = [
|
||||
str(ctx).strip() for ctx in contexts if str(ctx or "").strip()
|
||||
]
|
||||
|
||||
try:
|
||||
protections = api_request(
|
||||
"GET", f"{base_url}/branch_protections", auth
|
||||
)
|
||||
rule = _matching_protection_rule(protections, branch)
|
||||
if rule is not None:
|
||||
_apply(rule)
|
||||
if not policy["protection_found"]:
|
||||
# Branch payload may embed effective protection.
|
||||
br = api_request("GET", f"{base_url}/branches/{branch}", auth) or {}
|
||||
prot = br.get("protection") or br.get("effective_branch_protection") or {}
|
||||
if isinstance(prot, dict) and prot:
|
||||
_apply(prot)
|
||||
policy["determinable"] = True
|
||||
except Exception:
|
||||
# Genuine read failure — leave determinable False so callers fail closed.
|
||||
return policy
|
||||
|
||||
if not policy["protection_found"]:
|
||||
# Authoritative absence: no protection governs the branch, so no status
|
||||
# check is required by policy.
|
||||
policy["checks_enabled"] = False
|
||||
elif policy["checks_enabled"] is None:
|
||||
# Protection exists but omits the status-check field entirely: Gitea
|
||||
# only enforces contexts when the flag is set, so absence means off.
|
||||
policy["checks_enabled"] = False
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
def _branch_protection_requires_current_base(
|
||||
base_url: str,
|
||||
auth: dict,
|
||||
*,
|
||||
base_branch: str,
|
||||
) -> bool | None:
|
||||
"""Read Gitea branch protection ``block_on_outdated_branch`` when present."""
|
||||
branch = (base_branch or "").strip()
|
||||
if not branch:
|
||||
return None
|
||||
"""Read Gitea branch protection ``block_on_outdated_branch`` when present.
|
||||
|
||||
Thin accessor over :func:`_branch_protection_policy`; return semantics are
|
||||
unchanged (``None`` when the rule is absent or unreadable).
|
||||
"""
|
||||
policy = _branch_protection_policy(base_url, auth, base_branch=base_branch)
|
||||
return policy.get("requires_current_base")
|
||||
|
||||
|
||||
def _commit_checks_snapshot(
|
||||
base_url: str,
|
||||
auth: dict,
|
||||
*,
|
||||
sha: str,
|
||||
) -> dict:
|
||||
"""Read the combined commit status *and its context collection* (#751).
|
||||
|
||||
The combined ``state`` alone is not decisive: Gitea reports ``pending`` for
|
||||
a commit with an empty status-context collection, which is indistinguishable
|
||||
from executing CI unless the collection itself is inspected.
|
||||
"""
|
||||
snapshot: dict[str, Any] = {
|
||||
"determinable": False,
|
||||
"combined_state": None,
|
||||
"statuses": [],
|
||||
}
|
||||
head = (sha or "").strip()
|
||||
if not head:
|
||||
return snapshot
|
||||
try:
|
||||
# Prefer the named protection rule matching the base branch.
|
||||
protections = api_request(
|
||||
"GET", f"{base_url}/branch_protections", auth
|
||||
) or []
|
||||
if isinstance(protections, list):
|
||||
for rule in protections:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
|
||||
# Exact match or glob-ish contains for common patterns.
|
||||
if name == branch or name in ("*", f"{branch}"):
|
||||
if "block_on_outdated_branch" in rule:
|
||||
return bool(rule.get("block_on_outdated_branch"))
|
||||
# Fallback: any protection that mentions the base branch.
|
||||
for rule in protections:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
name = (rule.get("branch_name") or rule.get("rule_name") or "").strip()
|
||||
if branch in name or name.endswith(branch):
|
||||
if "block_on_outdated_branch" in rule:
|
||||
return bool(rule.get("block_on_outdated_branch"))
|
||||
# Branch payload may embed effective protection.
|
||||
br = api_request("GET", f"{base_url}/branches/{branch}", auth) or {}
|
||||
prot = br.get("protection") or br.get("effective_branch_protection") or {}
|
||||
if isinstance(prot, dict) and "block_on_outdated_branch" in prot:
|
||||
return bool(prot.get("block_on_outdated_branch"))
|
||||
payload = api_request(
|
||||
"GET", f"{base_url}/commits/{head}/status", auth
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
return snapshot
|
||||
if payload is None:
|
||||
return snapshot
|
||||
snapshot["determinable"] = True
|
||||
if not isinstance(payload, dict):
|
||||
return snapshot
|
||||
snapshot["combined_state"] = (payload.get("state") or "").strip().lower() or None
|
||||
statuses = payload.get("statuses")
|
||||
snapshot["statuses"] = statuses if isinstance(statuses, list) else []
|
||||
return snapshot
|
||||
|
||||
|
||||
def _prove_author_ownership_for_pr(
|
||||
@@ -15471,11 +15852,14 @@ def gitea_assess_pr_sync_status(
|
||||
# Fail closed on unknown behind-count only when SHAs differ.
|
||||
commits_behind = 0 if pr_head_sha == base_head_sha else None
|
||||
|
||||
# Single live read of the base-branch protection policy; it carries both
|
||||
# the current-base rule and the status-check requirement (#751).
|
||||
protection_policy = _branch_protection_policy(
|
||||
base, auth, base_branch=base_branch
|
||||
)
|
||||
if branch_protection_requires_current_base is None:
|
||||
branch_protection_requires_current_base = (
|
||||
_branch_protection_requires_current_base(
|
||||
base, auth, base_branch=base_branch
|
||||
)
|
||||
branch_protection_requires_current_base = protection_policy.get(
|
||||
"requires_current_base"
|
||||
)
|
||||
# When protection cannot be read, fail closed by requiring current base
|
||||
# whenever the PR is behind (safer for protected repos). Callers may pass
|
||||
@@ -15542,23 +15926,30 @@ def gitea_assess_pr_sync_status(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Live checks derivation (#751) ────────────────────────────────────
|
||||
# Classify from the actual status-context collection plus the live
|
||||
# protection policy. The combined ``state`` is never treated as proof that
|
||||
# CI is executing, because Gitea reports ``pending`` for an empty
|
||||
# collection. ``checks_required`` is always derived from live evidence and
|
||||
# is deliberately not a caller-supplied input, so no session can declare
|
||||
# checks optional without proof.
|
||||
checks_snapshot = _commit_checks_snapshot(base, auth, sha=pr_head_sha or "")
|
||||
checks_classification = pr_sync_status.classify_commit_checks(
|
||||
combined_state=checks_snapshot.get("combined_state"),
|
||||
statuses=checks_snapshot.get("statuses"),
|
||||
checks_enabled=protection_policy.get("checks_enabled"),
|
||||
required_contexts=protection_policy.get("required_contexts"),
|
||||
policy_determinable=bool(protection_policy.get("determinable")),
|
||||
status_determinable=bool(checks_snapshot.get("determinable")),
|
||||
)
|
||||
checks_required = bool(checks_classification.get("checks_required", True))
|
||||
caller_supplied_checks_status = checks_status
|
||||
if checks_status is None:
|
||||
checks_status = "unknown"
|
||||
# Combined status on PR head when Actions/status API is available.
|
||||
if pr_head_sha:
|
||||
try:
|
||||
st = api_request(
|
||||
"GET",
|
||||
f"{base}/commits/{pr_head_sha}/status",
|
||||
auth,
|
||||
) or {}
|
||||
state = (st.get("state") or "").strip().lower()
|
||||
if state:
|
||||
checks_status = state
|
||||
elif not st:
|
||||
checks_status = "none"
|
||||
except Exception:
|
||||
checks_status = "unknown"
|
||||
checks_status = checks_classification.get("checks_status")
|
||||
elif checks_classification.get("checks_status") != pr_sync_status.CHECKS_UNKNOWN:
|
||||
# Live evidence outranks a caller-supplied value; the override only
|
||||
# applies when live status could not be classified at all.
|
||||
checks_status = checks_classification.get("checks_status")
|
||||
|
||||
assessment = pr_sync_status.assess_pr_sync_status(
|
||||
host=h,
|
||||
@@ -15579,9 +15970,26 @@ def gitea_assess_pr_sync_status(
|
||||
active_reviewer_lease=active_reviewer_lease,
|
||||
active_merger_lease=active_merger_lease,
|
||||
prepared_verdict_head_sha=prepared_verdict_head_sha,
|
||||
checks_required=checks_required,
|
||||
)
|
||||
assessment["remote"] = remote if remote in REMOTES else None
|
||||
assessment["base_branch"] = base_branch
|
||||
# Evidence for the checks decision (#751) — no secrets, read-only.
|
||||
assessment["checks_evidence"] = {
|
||||
"combined_state": checks_classification.get("combined_state"),
|
||||
"context_count": checks_classification.get("context_count"),
|
||||
"observed_contexts": checks_classification.get("observed_contexts"),
|
||||
"required_contexts": checks_classification.get("required_contexts"),
|
||||
"missing_required_contexts": checks_classification.get(
|
||||
"missing_required_contexts"
|
||||
),
|
||||
"policy_determinable": checks_classification.get("policy_determinable"),
|
||||
"status_determinable": checks_classification.get("status_determinable"),
|
||||
"protection_found": protection_policy.get("protection_found"),
|
||||
"checks_enabled": protection_policy.get("checks_enabled"),
|
||||
"caller_supplied_checks_status": caller_supplied_checks_status,
|
||||
"reasons": checks_classification.get("reasons"),
|
||||
}
|
||||
assessment["success"] = True
|
||||
assessment["performed"] = False
|
||||
return assessment
|
||||
@@ -15667,7 +16075,7 @@ def gitea_update_pr_branch_by_merge(
|
||||
|
||||
control_clean = True
|
||||
try:
|
||||
porcelain = _get_workspace_porcelain(PROJECT_ROOT)
|
||||
porcelain = _get_workspace_porcelain(_canonical_local_git_root())
|
||||
# Untracked-only dirt is ignored; tracked edits contaminate control.
|
||||
tracked_dirty = [
|
||||
line for line in (porcelain or "").splitlines()
|
||||
|
||||
@@ -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,400 @@
|
||||
"""Dead-session author issue-lock recovery (#753).
|
||||
|
||||
A durable author issue lock records the PID of the MCP session that took it.
|
||||
When that process exits, ``issue_lock_store.assess_lock_freshness`` classifies
|
||||
the lock as ``stale`` (``live=False``) even while its lease is still within TTL,
|
||||
so every ownership check that requires a *live* lock fails closed.
|
||||
|
||||
Re-taking the lock through ``gitea_lock_issue`` is unreachable for real work:
|
||||
``issue_lock_worktree.assess_issue_lock_worktree`` demands the worktree be
|
||||
base-equivalent to ``master``/``main``/``dev``, and a branch that already
|
||||
carries commits is ahead of its base by construction. The existing
|
||||
``assess_expired_lock_reclaim`` affordance does not apply either, because
|
||||
``assess_same_issue_lease_conflict`` only consults it once the lease has
|
||||
*expired* — a dead PID under an unexpired lease never reaches it.
|
||||
|
||||
This module is the pure evidence assessor for that one narrow case. It grants
|
||||
recovery only when every element of durable ownership still matches exactly and
|
||||
the recorded process is demonstrably dead. It never trusts caller assertions:
|
||||
every field is compared against durable lock state or live observation supplied
|
||||
by the caller. It performs no mutation and no network I/O.
|
||||
|
||||
Recovery deliberately does **not** relax base-equivalence for brand-new issue
|
||||
claims — only for a lock whose own prior record already proves the branch,
|
||||
worktree, head, and author.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
from issue_lock_store import is_process_alive
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
# Outcome values
|
||||
RECOVERY_SANCTIONED = "RECOVERY_SANCTIONED"
|
||||
NO_CANDIDATE = "NO_CANDIDATE"
|
||||
REFUSED = "REFUSED"
|
||||
|
||||
# Durable fields a lock must carry before it can be considered at all.
|
||||
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
|
||||
|
||||
|
||||
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
try:
|
||||
return os.path.realpath(left) == os.path.realpath(right)
|
||||
except OSError:
|
||||
return left == right
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]:
|
||||
claimant = lock.get("claimant")
|
||||
if not isinstance(claimant, Mapping):
|
||||
lease = lock.get("work_lease")
|
||||
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
|
||||
return dict(claimant) if isinstance(claimant, Mapping) else {}
|
||||
|
||||
|
||||
def _recorded_pid(lock: Mapping[str, Any]) -> Any:
|
||||
pid = lock.get("session_pid")
|
||||
if pid is None:
|
||||
pid = lock.get("pid")
|
||||
return pid
|
||||
|
||||
|
||||
def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
|
||||
"""Names of durable fields that are missing or unusable."""
|
||||
missing: list[str] = []
|
||||
for field in REQUIRED_LOCK_FIELDS:
|
||||
if not _text(lock.get(field)):
|
||||
missing.append(field)
|
||||
pid = _recorded_pid(lock)
|
||||
if pid is None or _text(pid) == "":
|
||||
missing.append("session_pid/pid")
|
||||
else:
|
||||
try:
|
||||
if int(pid) <= 0:
|
||||
missing.append("session_pid/pid")
|
||||
except (TypeError, ValueError):
|
||||
missing.append("session_pid/pid")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_dead_session_lock_recovery(
|
||||
existing_lock: Mapping[str, Any] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
identity: str | None,
|
||||
profile: str | None,
|
||||
current_branch: str | None,
|
||||
porcelain_status: str,
|
||||
head_sha: str | None,
|
||||
remote_head_sha: str | None,
|
||||
pr_head_sha: str | None = None,
|
||||
pr_number: int | None = None,
|
||||
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
|
||||
candidate_branches: Iterable[str] | None = None,
|
||||
current_pid: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether a dead-session author lock may be natively recovered.
|
||||
|
||||
Returns a dict with ``recovery_sanctioned`` (bool), ``outcome``, ``reasons``
|
||||
(why it was refused, or the positive proof when sanctioned), and
|
||||
``evidence`` (a redaction-safe record for auditing).
|
||||
|
||||
``NO_CANDIDATE`` means no recovery was attempted at all — there is no
|
||||
existing lock, or the lock does not describe this issue. The caller must
|
||||
treat that exactly as it treated the pre-#753 world. ``REFUSED`` means a
|
||||
candidate existed but the evidence did not agree; the caller fails closed.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
evidence: dict[str, Any] = {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"worktree_path": worktree_path,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
}
|
||||
|
||||
if not existing_lock:
|
||||
return _result(
|
||||
NO_CANDIDATE, False, ["no existing durable lock for this issue"], evidence
|
||||
)
|
||||
|
||||
lock = dict(existing_lock)
|
||||
|
||||
# ── Candidate identification ────────────────────────────────────────────
|
||||
# Recovery only ever applies to a lock that already claims THIS issue.
|
||||
# Anything else is not a recovery candidate and must not be reinterpreted.
|
||||
if lock.get("issue_number") != issue_number:
|
||||
return _result(
|
||||
NO_CANDIDATE,
|
||||
False,
|
||||
[
|
||||
f"existing lock targets issue #{lock.get('issue_number')}, "
|
||||
f"not #{issue_number}; not a recovery candidate"
|
||||
],
|
||||
evidence,
|
||||
)
|
||||
|
||||
# A malformed/incomplete durable record can never prove ownership.
|
||||
missing = _malformed_reasons(lock)
|
||||
if missing:
|
||||
return _result(
|
||||
REFUSED,
|
||||
False,
|
||||
[
|
||||
"durable lock record is incomplete and cannot prove ownership "
|
||||
f"(missing/unusable: {', '.join(missing)})"
|
||||
],
|
||||
evidence,
|
||||
)
|
||||
|
||||
recorded_pid = _recorded_pid(lock)
|
||||
evidence["prior_session_pid"] = recorded_pid
|
||||
evidence["replacement_session_pid"] = (
|
||||
current_pid if current_pid is not None else os.getpid()
|
||||
)
|
||||
|
||||
# ── Repository scope ────────────────────────────────────────────────────
|
||||
for field, expected in (("remote", remote), ("org", org), ("repo", repo)):
|
||||
actual = _text(lock.get(field))
|
||||
if actual != _text(expected):
|
||||
reasons.append(
|
||||
f"lock {field} '{actual}' does not match requested '{_text(expected)}'"
|
||||
)
|
||||
|
||||
# ── Branch identity ─────────────────────────────────────────────────────
|
||||
locked_branch = _text(lock.get("branch_name"))
|
||||
if locked_branch != _text(branch_name):
|
||||
reasons.append(
|
||||
f"lock branch '{locked_branch}' does not match requested "
|
||||
f"'{_text(branch_name)}'"
|
||||
)
|
||||
evidence["locked_branch"] = locked_branch
|
||||
|
||||
# The worktree must actually be sitting on the locked branch. Without this
|
||||
# a clean worktree parked elsewhere could stand in for the real work.
|
||||
checked_out = _text(current_branch)
|
||||
if not checked_out:
|
||||
reasons.append(
|
||||
"worktree is not on a named branch (detached HEAD); locked-branch "
|
||||
"occupancy could not be proven"
|
||||
)
|
||||
elif checked_out != locked_branch:
|
||||
reasons.append(
|
||||
f"worktree is on branch '{checked_out}', not the locked branch "
|
||||
f"'{locked_branch}'"
|
||||
)
|
||||
|
||||
# ── Worktree identity ───────────────────────────────────────────────────
|
||||
locked_worktree = _text(lock.get("worktree_path"))
|
||||
if not _same_realpath(locked_worktree, worktree_path):
|
||||
reasons.append(
|
||||
f"lock worktree '{locked_worktree}' does not match declared "
|
||||
f"'{_text(worktree_path)}'"
|
||||
)
|
||||
evidence["locked_worktree_path"] = locked_worktree
|
||||
|
||||
# ── Cleanliness (never waived) ──────────────────────────────────────────
|
||||
dirty_files = parse_dirty_tracked_files(porcelain_status)
|
||||
if dirty_files:
|
||||
reasons.append(
|
||||
"worktree has tracked local edits; recovery requires a clean "
|
||||
f"worktree (dirty files: {', '.join(dirty_files)})"
|
||||
)
|
||||
evidence["dirty_files"] = dirty_files
|
||||
|
||||
# ── Head agreement: local == remote == PR ───────────────────────────────
|
||||
local_head = _text(head_sha)
|
||||
remote_head = _text(remote_head_sha)
|
||||
if not local_head:
|
||||
reasons.append("local head SHA could not be determined")
|
||||
if not remote_head:
|
||||
reasons.append(
|
||||
f"remote head for branch '{locked_branch}' could not be determined"
|
||||
)
|
||||
if local_head and remote_head and local_head != remote_head:
|
||||
reasons.append(
|
||||
f"local head {local_head} does not match remote branch head {remote_head}"
|
||||
)
|
||||
evidence["local_head"] = local_head or None
|
||||
evidence["remote_head"] = remote_head or None
|
||||
|
||||
pr_head = _text(pr_head_sha)
|
||||
if pr_head:
|
||||
evidence["pr_head"] = pr_head
|
||||
evidence["pr_number"] = pr_number
|
||||
if local_head and pr_head != local_head:
|
||||
reasons.append(
|
||||
f"open PR #{pr_number} head {pr_head} does not match local head "
|
||||
f"{local_head}"
|
||||
)
|
||||
|
||||
# ── Author identity ─────────────────────────────────────────────────────
|
||||
claimant = _lock_claimant(lock)
|
||||
locked_identity = _text(claimant.get("username"))
|
||||
locked_profile = _text(claimant.get("profile"))
|
||||
evidence["locked_identity"] = locked_identity or None
|
||||
evidence["locked_profile"] = locked_profile or None
|
||||
if not locked_identity or not locked_profile:
|
||||
reasons.append(
|
||||
"durable lock does not record a claimant identity/profile; "
|
||||
"author ownership could not be proven"
|
||||
)
|
||||
if not _text(identity) or not _text(profile):
|
||||
reasons.append(
|
||||
"active session identity/profile is unknown; author ownership "
|
||||
"could not be proven"
|
||||
)
|
||||
if locked_identity and _text(identity) and locked_identity != _text(identity):
|
||||
reasons.append(
|
||||
f"lock claimant '{locked_identity}' does not match active identity "
|
||||
f"'{_text(identity)}'"
|
||||
)
|
||||
if locked_profile and _text(profile) and locked_profile != _text(profile):
|
||||
reasons.append(
|
||||
f"lock profile '{locked_profile}' does not match active profile "
|
||||
f"'{_text(profile)}'"
|
||||
)
|
||||
|
||||
# ── The defining condition: the recorded owner must be dead ─────────────
|
||||
prior_alive = is_process_alive(recorded_pid)
|
||||
evidence["prior_pid_alive"] = prior_alive
|
||||
if prior_alive:
|
||||
reasons.append(
|
||||
f"prior owner pid {recorded_pid} is still alive; this is not a "
|
||||
"dead-session recovery"
|
||||
)
|
||||
if current_pid is not None and recorded_pid is not None:
|
||||
try:
|
||||
if int(recorded_pid) == int(current_pid):
|
||||
reasons.append(
|
||||
"recorded pid is the current session; nothing to recover"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# ── Competing ownership ─────────────────────────────────────────────────
|
||||
competing: list[dict[str, Any]] = []
|
||||
for entry in competing_live_locks or ():
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
same_issue = entry.get("issue_number") == issue_number
|
||||
same_branch = _text(entry.get("branch_name")) == locked_branch
|
||||
if not (same_issue or same_branch):
|
||||
continue
|
||||
# The lock we are recovering is not competition with itself.
|
||||
if (
|
||||
same_issue
|
||||
and same_branch
|
||||
and _same_realpath(_text(entry.get("worktree_path")), worktree_path)
|
||||
):
|
||||
continue
|
||||
competing.append(
|
||||
{
|
||||
"issue_number": entry.get("issue_number"),
|
||||
"branch_name": entry.get("branch_name"),
|
||||
"worktree_path": entry.get("worktree_path"),
|
||||
"pid": entry.get("pid"),
|
||||
}
|
||||
)
|
||||
if competing:
|
||||
described = ", ".join(
|
||||
f"issue #{c['issue_number']} branch '{c['branch_name']}'" for c in competing
|
||||
)
|
||||
reasons.append(f"competing live lock or lease exists ({described})")
|
||||
evidence["competing_live_locks"] = competing
|
||||
|
||||
# ── Ambiguous branch claims ─────────────────────────────────────────────
|
||||
others = [
|
||||
name
|
||||
for name in (candidate_branches or ())
|
||||
if _text(name) and _text(name) != locked_branch
|
||||
]
|
||||
if others:
|
||||
reasons.append(
|
||||
"multiple branches claim this issue "
|
||||
f"({', '.join(sorted(set(others)))}); ownership is ambiguous"
|
||||
)
|
||||
evidence["other_candidate_branches"] = sorted(set(others))
|
||||
|
||||
if reasons:
|
||||
return _result(REFUSED, False, reasons, evidence)
|
||||
|
||||
return _result(
|
||||
RECOVERY_SANCTIONED,
|
||||
True,
|
||||
[
|
||||
f"durable lock for issue #{issue_number} matches branch "
|
||||
f"'{locked_branch}', worktree '{locked_worktree}', head {local_head}, "
|
||||
f"and claimant '{locked_identity}'; recorded pid {recorded_pid} is dead"
|
||||
],
|
||||
evidence,
|
||||
)
|
||||
|
||||
|
||||
def _result(
|
||||
outcome: str,
|
||||
sanctioned: bool,
|
||||
reasons: list[str],
|
||||
evidence: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"recovery_sanctioned": sanctioned,
|
||||
"is_candidate": outcome != NO_CANDIDATE,
|
||||
"reasons": reasons,
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
|
||||
def build_recovery_record(
|
||||
assessment: Mapping[str, Any],
|
||||
*,
|
||||
recovered_at: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Durable, secret-free provenance for a completed recovery (#753 AC2/AC6)."""
|
||||
evidence = dict(assessment.get("evidence") or {})
|
||||
return {
|
||||
"recovered": True,
|
||||
"reason": "owning MCP session exited; durable ownership evidence matched",
|
||||
"recovered_at": recovered_at,
|
||||
"prior_session_pid": evidence.get("prior_session_pid"),
|
||||
"replacement_session_pid": evidence.get("replacement_session_pid"),
|
||||
"prior_pid_alive": evidence.get("prior_pid_alive"),
|
||||
"branch_name": evidence.get("locked_branch"),
|
||||
"worktree_path": evidence.get("locked_worktree_path"),
|
||||
"local_head": evidence.get("local_head"),
|
||||
"remote_head": evidence.get("remote_head"),
|
||||
"pr_head": evidence.get("pr_head"),
|
||||
"pr_number": evidence.get("pr_number"),
|
||||
"identity": evidence.get("locked_identity"),
|
||||
"profile": evidence.get("locked_profile"),
|
||||
"proof": list(assessment.get("reasons") or []),
|
||||
}
|
||||
|
||||
|
||||
def format_recovery_refusal(assessment: Mapping[str, Any]) -> str:
|
||||
"""Single fail-closed message for a refused recovery attempt."""
|
||||
reasons = list(assessment.get("reasons") or []) or [
|
||||
"dead-session lock recovery evidence did not agree"
|
||||
]
|
||||
return (
|
||||
"Dead-session issue-lock recovery refused: "
|
||||
+ "; ".join(reasons)
|
||||
+ " (fail closed)"
|
||||
)
|
||||
+21
-2
@@ -92,8 +92,19 @@ def assess_issue_lock_worktree(
|
||||
inspected_git_root: str | None = None,
|
||||
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 +122,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 +154,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 +213,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 +225,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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+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.
|
||||
|
||||
+2
-1
@@ -36,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:
|
||||
|
||||
+46
-7
@@ -29,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
|
||||
|
||||
@@ -80,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"
|
||||
)
|
||||
@@ -169,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:
|
||||
@@ -180,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"
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
"""Complete canonical-root consumption for cross-repository MCP namespaces.
|
||||
|
||||
#706 introduced an immutable, configured ``canonical_repository_root`` and
|
||||
routed the #274 filesystem guards and the session repository *slug* through it.
|
||||
Three consumption paths were left behind, and this module drives each one
|
||||
through its production entry point:
|
||||
|
||||
* **A — remote initialization.** ``gitea_get_runtime_context`` never normalized
|
||||
its ``remote`` argument, while ``gitea_whoami`` did. A fresh process whose
|
||||
first native call is the runtime-context path therefore pinned the
|
||||
``dadeschools`` argument default instead of the profile's configured remote,
|
||||
and — because first-bind is first-write-wins — no later correct call could
|
||||
repair it.
|
||||
|
||||
* **C — reconciler branch deletion.** The delete-branch repository-binding
|
||||
guard derived its expected slug from ``_workspace_repository_slug``, which
|
||||
reads the git remote of the *installation* checkout (always Gitea-Tools),
|
||||
rather than from the session's configured canonical root.
|
||||
|
||||
* **D — parity evidence.** ``gitea_assess_master_parity`` proves Gitea-Tools
|
||||
*server implementation* parity only, which is intentional. It carried no
|
||||
target-repository dimension at all, so a cross-repository namespace had no
|
||||
evidence that its target checkout was current. The existing
|
||||
``startup_head``/``current_head`` semantics are preserved unchanged and the
|
||||
target-repository assessment is reported under separately labelled fields.
|
||||
|
||||
Groups B and E assert *intentional* behaviour (the #274 guards already consume
|
||||
the canonical root; the configuration surface already validates a
|
||||
repository-specific namespace) so that a regression in either is caught.
|
||||
|
||||
Real git repositories are used throughout; no network calls are made.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import canonical_repository_root as crr # noqa: E402
|
||||
import gitea_config # noqa: E402
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import master_parity_gate # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
import session_context_binding as session_ctx # noqa: E402
|
||||
|
||||
INSTALL_ORG = "Scaled-Tech-Consulting"
|
||||
INSTALL_REPO = "Gitea-Tools"
|
||||
INSTALL_SLUG = f"{INSTALL_ORG}/{INSTALL_REPO}"
|
||||
INSTALL_URL = f"https://gitea.prgs.cc/{INSTALL_SLUG}.git"
|
||||
|
||||
TARGET_ORG = "Scaled-Tech-Consulting"
|
||||
TARGET_REPO = "mcp-control-plane"
|
||||
TARGET_SLUG = f"{TARGET_ORG}/{TARGET_REPO}"
|
||||
TARGET_URL = f"https://gitea.prgs.cc/{TARGET_SLUG}.git"
|
||||
|
||||
|
||||
def _git(cwd: str, *args: str) -> str:
|
||||
res = subprocess.run(
|
||||
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
|
||||
)
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
_git(str(path), "init", "-q")
|
||||
_git(str(path), "config", "user.email", "[email protected]")
|
||||
_git(str(path), "config", "user.name", "Test")
|
||||
_git(str(path), "remote", "add", remote_name, remote_url)
|
||||
(path / "README.md").write_text("seed\n")
|
||||
_git(str(path), "add", "README.md")
|
||||
_git(str(path), "commit", "-q", "-m", "seed")
|
||||
return os.path.realpath(str(path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared profile/config construction
|
||||
# ---------------------------------------------------------------------------
|
||||
_AUTHOR_OPS = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
_AUTHOR_FORBIDDEN = [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
]
|
||||
# A profile that may approve or merge must forbid authoring (gitea_config's
|
||||
# reviewer-identity deadlock rule).
|
||||
_REVIEWER_OPS = [
|
||||
"gitea.read",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
]
|
||||
_REVIEWER_FORBIDDEN = ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"]
|
||||
_MERGER_OPS = ["gitea.read", "gitea.pr.merge", "gitea.pr.comment"]
|
||||
_MERGER_FORBIDDEN = [
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
]
|
||||
_RECONCILER_OPS = [
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.delete",
|
||||
]
|
||||
_RECONCILER_FORBIDDEN = [
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
]
|
||||
|
||||
_ROLE_MATRIX = {
|
||||
"author": (_AUTHOR_OPS, _AUTHOR_FORBIDDEN),
|
||||
"reviewer": (_REVIEWER_OPS, _REVIEWER_FORBIDDEN),
|
||||
"merger": (_MERGER_OPS, _MERGER_FORBIDDEN),
|
||||
"reconciler": (_RECONCILER_OPS, _RECONCILER_FORBIDDEN),
|
||||
}
|
||||
|
||||
|
||||
def _profile(
|
||||
role: str,
|
||||
*,
|
||||
canonical_root: str | None = None,
|
||||
allowed_repositories: list[str] | None = None,
|
||||
username: str = "jcwalker3",
|
||||
) -> dict:
|
||||
allowed, forbidden = _ROLE_MATRIX[role]
|
||||
profile = {
|
||||
"enabled": True,
|
||||
"context": "prgs",
|
||||
"role": role,
|
||||
"username": username,
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"auth": {"type": "env", "name": f"GITEA_TOKEN_PRGS_{role.upper()}"},
|
||||
"allowed_operations": list(allowed),
|
||||
"forbidden_operations": list(forbidden),
|
||||
"execution_profile": f"prgs-{role}",
|
||||
}
|
||||
if canonical_root is not None:
|
||||
profile["canonical_repository_root"] = canonical_root
|
||||
if allowed_repositories is not None:
|
||||
profile["allowed_repositories"] = list(allowed_repositories)
|
||||
return profile
|
||||
|
||||
|
||||
def _config(profiles: dict) -> dict:
|
||||
return {
|
||||
"version": 2,
|
||||
"rules": {"allow_runtime_switching": True},
|
||||
"contexts": {
|
||||
"prgs": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||
},
|
||||
"mdcps": {
|
||||
"enabled": True,
|
||||
"gitea": {
|
||||
"enabled": True,
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
"profiles": profiles,
|
||||
}
|
||||
|
||||
|
||||
class _ServerHarness(unittest.TestCase):
|
||||
"""Temp profiles.json + pinned install remote + no network."""
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.tmp = self._dir.name
|
||||
self.config_path = os.path.join(self.tmp, "profiles.json")
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
srv._MUTATION_AUTHORITY = None
|
||||
# The install checkout's remote is Gitea-Tools regardless of the
|
||||
# developer's layout; pin it so "install-derived" is deterministic.
|
||||
self._remote_url = patch.object(
|
||||
srv, "_local_git_remote_url", side_effect=self._install_remote_url
|
||||
)
|
||||
self._remote_url.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._remote_url.stop()
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
srv._MUTATION_AUTHORITY = None
|
||||
self._dir.cleanup()
|
||||
|
||||
def _install_remote_url(self, remote_name):
|
||||
return INSTALL_URL if remote_name in ("prgs", "origin") else None
|
||||
|
||||
def _write_config(self, profiles: dict) -> None:
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(_config(profiles)))
|
||||
|
||||
def _env(self, profile_name: str, **extra) -> dict:
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
"GITEA_TOKEN_PRGS_AUTHOR": "t",
|
||||
"GITEA_TOKEN_PRGS_REVIEWER": "t",
|
||||
"GITEA_TOKEN_PRGS_MERGER": "t",
|
||||
"GITEA_TOKEN_PRGS_RECONCILER": "t",
|
||||
}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
def _api(self, method, url, header):
|
||||
return {
|
||||
"login": "jcwalker3",
|
||||
"full_name": "Test",
|
||||
"id": 1,
|
||||
"email": "[email protected]",
|
||||
}
|
||||
|
||||
def _live(self):
|
||||
return patch("gitea_mcp_server.api_request", side_effect=self._api)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# A. Remote initialization
|
||||
# ===========================================================================
|
||||
class TestRemoteInitializationFirstCall(_ServerHarness):
|
||||
"""A prgs namespace must never pin the dadeschools argument default."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._write_config(
|
||||
{"prgs-author": _profile("author", allowed_repositories=[INSTALL_SLUG])}
|
||||
)
|
||||
|
||||
def test_runtime_context_first_call_does_not_bind_default_remote(self):
|
||||
"""Runtime-context as the FIRST native call, relying on the default."""
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||
srv.gitea_get_runtime_context()
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertIsNotNone(ctx, "first call must establish a session binding")
|
||||
self.assertEqual(ctx["remote"], "prgs")
|
||||
self.assertEqual(ctx["host"], "gitea.prgs.cc")
|
||||
self.assertEqual(ctx["org"], INSTALL_ORG)
|
||||
self.assertEqual(ctx["repository"], INSTALL_REPO)
|
||||
|
||||
def test_runtime_context_first_call_reports_effective_remote(self):
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||
result = srv.gitea_get_runtime_context()
|
||||
self.assertEqual(result["remote"], "prgs")
|
||||
|
||||
def test_whoami_first_call_remains_correct(self):
|
||||
"""Control: the already-normalizing path is unchanged."""
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||
srv.gitea_whoami()
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertEqual(ctx["remote"], "prgs")
|
||||
self.assertEqual(ctx["host"], "gitea.prgs.cc")
|
||||
|
||||
def test_explicit_remote_argument_still_honoured(self):
|
||||
"""Normalization only fills the default; explicit values are untouched."""
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||
result = srv.gitea_get_runtime_context(remote="prgs")
|
||||
self.assertEqual(result["remote"], "prgs")
|
||||
|
||||
def test_mdcps_profile_default_is_not_rewritten_to_prgs(self):
|
||||
"""A dadeschools-hosted profile keeps the dadeschools remote."""
|
||||
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
|
||||
profile["context"] = "mdcps"
|
||||
profile["base_url"] = "https://gitea.dadeschools.net"
|
||||
self._write_config({"prgs-author": profile})
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False), self._live():
|
||||
result = srv.gitea_get_runtime_context()
|
||||
self.assertEqual(result["remote"], "dadeschools")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# B. Remote/repository guard (intentional behaviour — regression fence)
|
||||
# ===========================================================================
|
||||
class TestCanonicalRootGuardBinding(_ServerHarness):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||
self.install_root = _init_repo(Path(self.tmp) / "install", INSTALL_URL)
|
||||
|
||||
def test_canonical_target_root_resolves_to_target_slug(self):
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=self.target_root,
|
||||
source="profile.canonical_repository_root",
|
||||
expected_slug=None,
|
||||
process_project_root=self.install_root,
|
||||
remote="prgs",
|
||||
require_binding=True,
|
||||
)
|
||||
self.assertFalse(got.get("block"), got.get("reasons"))
|
||||
self.assertEqual(got["resolved_slug"], TARGET_SLUG)
|
||||
|
||||
def test_install_root_identity_remains_gitea_tools(self):
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=None,
|
||||
source=None,
|
||||
expected_slug=None,
|
||||
process_project_root=self.install_root,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertEqual(
|
||||
os.path.realpath(got["canonical_repo_root"]), self.install_root
|
||||
)
|
||||
|
||||
def test_guards_validate_against_canonical_target_root(self):
|
||||
ctx = nwb.resolve_namespace_mutation_context(
|
||||
role_kind="author",
|
||||
worktree_path=None,
|
||||
process_project_root=self.install_root,
|
||||
configured_canonical_root=self.target_root,
|
||||
)
|
||||
self.assertEqual(
|
||||
os.path.realpath(ctx["canonical_repo_root"]), self.target_root
|
||||
)
|
||||
|
||||
def test_explicit_coordinates_cannot_override_canonical_binding(self):
|
||||
"""Explicit org/repo may confirm, never authorize, a binding."""
|
||||
confirm = session_ctx.assess_repository_override(
|
||||
requested_org=TARGET_ORG,
|
||||
requested_repo=TARGET_REPO,
|
||||
bound_org=TARGET_ORG,
|
||||
bound_repo=TARGET_REPO,
|
||||
)
|
||||
self.assertFalse(confirm.get("block"))
|
||||
override = session_ctx.assess_repository_override(
|
||||
requested_org=TARGET_ORG,
|
||||
requested_repo=TARGET_REPO,
|
||||
bound_org=INSTALL_ORG,
|
||||
bound_repo=INSTALL_REPO,
|
||||
)
|
||||
self.assertTrue(override.get("block"))
|
||||
|
||||
def test_gitea_tools_rooted_namespace_cannot_reach_target_repository(self):
|
||||
"""No canonical root configured → session stays pinned to Gitea-Tools."""
|
||||
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
|
||||
resolved = srv._trusted_session_repository(
|
||||
profile, "prgs", for_mutation=True
|
||||
)
|
||||
self.assertEqual(resolved["repository"], INSTALL_REPO)
|
||||
self.assertNotEqual(resolved["repository"], TARGET_REPO)
|
||||
|
||||
def test_target_rooted_namespace_binds_target_repository(self):
|
||||
profile = _profile(
|
||||
"author",
|
||||
canonical_root=self.target_root,
|
||||
allowed_repositories=[TARGET_SLUG],
|
||||
)
|
||||
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
|
||||
self.assertEqual(resolved["org"], TARGET_ORG)
|
||||
self.assertEqual(resolved["repository"], TARGET_REPO)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# C. Reconciler branch deletion
|
||||
# ===========================================================================
|
||||
class TestDeleteBranchRepositoryBinding(_ServerHarness):
|
||||
"""The binding guard must consult the canonical root, not PROJECT_ROOT.
|
||||
|
||||
No branch is ever deleted here: only the pre-deletion binding guard is
|
||||
exercised.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||
self._write_config(
|
||||
{
|
||||
"prgs-reconciler": _profile(
|
||||
"reconciler",
|
||||
canonical_root=self.target_root,
|
||||
allowed_repositories=[TARGET_SLUG],
|
||||
),
|
||||
"gt-reconciler": _profile(
|
||||
"reconciler", allowed_repositories=[INSTALL_SLUG]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def test_target_rooted_reconciler_validates_target_deletion(self):
|
||||
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
|
||||
block = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||
)
|
||||
self.assertIsNone(block, block)
|
||||
|
||||
def test_target_rooted_reconciler_fails_closed_for_install_repository(self):
|
||||
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
|
||||
block = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=INSTALL_ORG, repo=INSTALL_REPO
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertEqual(block["blocker_kind"], "repository_binding")
|
||||
self.assertFalse(block["performed"])
|
||||
|
||||
def test_install_rooted_reconciler_fails_closed_for_target_repository(self):
|
||||
with patch.dict(os.environ, self._env("gt-reconciler"), clear=False):
|
||||
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
|
||||
block = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertEqual(block["blocker_kind"], "repository_binding")
|
||||
|
||||
def test_env_configured_canonical_root_is_honoured(self):
|
||||
env = self._env("gt-reconciler")
|
||||
env[crr.CANONICAL_ROOT_ENV] = self.target_root
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
allowed = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||
)
|
||||
blocked = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=INSTALL_ORG, repo=INSTALL_REPO
|
||||
)
|
||||
self.assertIsNone(allowed, allowed)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
def test_unresolvable_canonical_root_fails_closed(self):
|
||||
env = self._env("gt-reconciler")
|
||||
env[crr.CANONICAL_ROOT_ENV] = os.path.join(self.tmp, "does-not-exist")
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
block = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=TARGET_ORG, repo=TARGET_REPO
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertEqual(block["blocker_kind"], "repository_binding")
|
||||
|
||||
def test_no_request_parameter_can_override_canonical_identity(self):
|
||||
"""Every explicit coordinate that is not the canonical target is
|
||||
refused; none of them can *establish* the binding."""
|
||||
# Both repositories share an org, so a wrong *org* case must name a
|
||||
# genuinely different owner to be meaningful.
|
||||
cases = [
|
||||
(INSTALL_ORG, INSTALL_REPO),
|
||||
(TARGET_ORG, INSTALL_REPO),
|
||||
(TARGET_ORG, "some-other-repo"),
|
||||
("someone-else", TARGET_REPO),
|
||||
]
|
||||
with patch.dict(os.environ, self._env("prgs-reconciler"), clear=False):
|
||||
for org, repo in cases:
|
||||
with self.subTest(org=org, repo=repo):
|
||||
block = srv._delete_branch_repository_binding_block(
|
||||
"prgs", org=org, repo=repo
|
||||
)
|
||||
self.assertIsNotNone(block, f"{org}/{repo} must fail closed")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# D. Parity semantics
|
||||
# ===========================================================================
|
||||
class TestTargetRepositoryParityAssessment(unittest.TestCase):
|
||||
"""Server-implementation parity is preserved; target parity is additive."""
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.tmp = self._dir.name
|
||||
|
||||
def tearDown(self):
|
||||
self._dir.cleanup()
|
||||
|
||||
def _target(self) -> str:
|
||||
return _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||
|
||||
def test_unconfigured_target_is_reported_not_stale(self):
|
||||
got = master_parity_gate.assess_target_repository_parity(
|
||||
canonical_root=None, source=None
|
||||
)
|
||||
self.assertFalse(got["configured"])
|
||||
self.assertFalse(got["stale"])
|
||||
self.assertIsNone(got["canonical_repository_root"])
|
||||
|
||||
def test_configured_target_reports_checkout_head_and_slug(self):
|
||||
root = self._target()
|
||||
head = _git(root, "rev-parse", "HEAD")
|
||||
got = master_parity_gate.assess_target_repository_parity(
|
||||
canonical_root=root, source="profile.canonical_repository_root"
|
||||
)
|
||||
self.assertTrue(got["configured"])
|
||||
self.assertEqual(got["canonical_repository_root"], root)
|
||||
self.assertEqual(got["checkout_head"], head)
|
||||
self.assertEqual(got["repository_slug"], TARGET_SLUG)
|
||||
|
||||
def test_target_stale_when_remote_tracking_ref_is_ahead(self):
|
||||
root = self._target()
|
||||
head = _git(root, "rev-parse", "HEAD")
|
||||
# Simulate a fetched remote-tracking ref that has advanced.
|
||||
_git(root, "checkout", "-q", "-b", "advanced")
|
||||
(Path(root) / "next.md").write_text("next\n")
|
||||
_git(root, "add", "next.md")
|
||||
_git(root, "commit", "-q", "-m", "advance")
|
||||
advanced = _git(root, "rev-parse", "HEAD")
|
||||
_git(root, "update-ref", "refs/remotes/origin/master", advanced)
|
||||
_git(root, "checkout", "-q", "--detach", head)
|
||||
got = master_parity_gate.assess_target_repository_parity(
|
||||
canonical_root=root, source="profile.canonical_repository_root"
|
||||
)
|
||||
self.assertEqual(got["checkout_head"], head)
|
||||
self.assertEqual(got["remote_tracking_head"], advanced)
|
||||
self.assertTrue(got["stale"])
|
||||
|
||||
def test_missing_root_is_not_determinable_and_fails_closed(self):
|
||||
got = master_parity_gate.assess_target_repository_parity(
|
||||
canonical_root=os.path.join(self.tmp, "absent"),
|
||||
source="profile.canonical_repository_root",
|
||||
)
|
||||
self.assertTrue(got["configured"])
|
||||
self.assertFalse(got["determinable"])
|
||||
self.assertTrue(got["reasons"])
|
||||
|
||||
|
||||
class TestParityToolEvidenceDimensions(_ServerHarness):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||
self._write_config(
|
||||
{
|
||||
"prgs-author": _profile(
|
||||
"author",
|
||||
canonical_root=self.target_root,
|
||||
allowed_repositories=[TARGET_SLUG],
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def test_existing_server_parity_semantics_are_unchanged(self):
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False):
|
||||
result = srv.gitea_assess_master_parity(remote="prgs")
|
||||
# startup_head/current_head keep their Gitea-Tools implementation
|
||||
# meaning and must not be redefined to the target repository.
|
||||
self.assertEqual(result["process_root"], srv.PROJECT_ROOT)
|
||||
self.assertEqual(
|
||||
result["startup_head"], srv._STARTUP_PARITY.get("startup_head")
|
||||
)
|
||||
self.assertIn("in_parity", result)
|
||||
|
||||
def test_evidence_distinguishes_server_and_target_dimensions(self):
|
||||
with patch.dict(os.environ, self._env("prgs-author"), clear=False):
|
||||
result = srv.gitea_assess_master_parity(remote="prgs")
|
||||
server = result["server_implementation"]
|
||||
self.assertEqual(server["installation_root"], srv.PROJECT_ROOT)
|
||||
self.assertEqual(server["current_head"], result["current_head"])
|
||||
self.assertIn("stale", server)
|
||||
|
||||
target = result["target_repository"]
|
||||
self.assertTrue(target["configured"])
|
||||
self.assertEqual(target["canonical_repository_root"], self.target_root)
|
||||
self.assertEqual(target["repository_slug"], TARGET_SLUG)
|
||||
self.assertEqual(
|
||||
target["checkout_head"], _git(self.target_root, "rev-parse", "HEAD")
|
||||
)
|
||||
self.assertIn("stale", target)
|
||||
|
||||
def test_unconfigured_namespace_reports_target_as_unconfigured(self):
|
||||
self._write_config(
|
||||
{"prgs-author": _profile("author", allowed_repositories=[INSTALL_SLUG])}
|
||||
)
|
||||
env = self._env("prgs-author")
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
os.environ.pop(crr.CANONICAL_ROOT_ENV, None)
|
||||
result = srv.gitea_assess_master_parity(remote="prgs")
|
||||
self.assertFalse(result["target_repository"]["configured"])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# E. Startup / configuration validation for a candidate namespace set
|
||||
# ===========================================================================
|
||||
class TestCandidateNamespaceConfiguration(_ServerHarness):
|
||||
"""Four repository-specific profiles for the target repository."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.target_root = _init_repo(Path(self.tmp) / "mcp-control-plane", TARGET_URL)
|
||||
self.profiles = {
|
||||
f"mcpcp-{role}": _profile(
|
||||
role,
|
||||
canonical_root=self.target_root,
|
||||
allowed_repositories=[TARGET_SLUG],
|
||||
)
|
||||
for role in ("author", "reviewer", "merger", "reconciler")
|
||||
}
|
||||
self._write_config(self.profiles)
|
||||
|
||||
def test_configuration_audit_succeeds(self):
|
||||
with patch.dict(os.environ, self._env("mcpcp-author"), clear=False):
|
||||
audit = srv.gitea_audit_config()
|
||||
self.assertTrue(audit.get("configured"))
|
||||
names = {row["name"] for row in audit["profiles"]}
|
||||
self.assertEqual(names, set(self.profiles))
|
||||
|
||||
def test_no_inline_credentials_are_present(self):
|
||||
raw = json.loads(Path(self.config_path).read_text())
|
||||
for name, profile in raw["profiles"].items():
|
||||
with self.subTest(profile=name):
|
||||
self.assertNotIn("token", profile)
|
||||
self.assertNotIn("password", profile)
|
||||
self.assertNotIn("secret", profile)
|
||||
self.assertIn(profile["auth"]["type"], ("env", "keychain"))
|
||||
|
||||
def test_bind_time_validation_succeeds_for_target_repository(self):
|
||||
for name, profile in self.profiles.items():
|
||||
with self.subTest(profile=name):
|
||||
resolved = srv._trusted_session_repository(
|
||||
profile, "prgs", for_mutation=True
|
||||
)
|
||||
self.assertEqual(resolved["reasons"], [])
|
||||
self.assertEqual(resolved["org"], TARGET_ORG)
|
||||
self.assertEqual(resolved["repository"], TARGET_REPO)
|
||||
|
||||
def test_wrong_repository_root_fails_closed(self):
|
||||
wrong_root = _init_repo(Path(self.tmp) / "wrong", INSTALL_URL)
|
||||
profile = _profile(
|
||||
"author", canonical_root=wrong_root, allowed_repositories=[TARGET_SLUG]
|
||||
)
|
||||
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
|
||||
self.assertIsNone(resolved["repository"])
|
||||
self.assertTrue(resolved["reasons"])
|
||||
|
||||
def test_existing_install_profiles_are_unchanged_in_behaviour(self):
|
||||
profile = _profile("author", allowed_repositories=[INSTALL_SLUG])
|
||||
resolved = srv._trusted_session_repository(profile, "prgs", for_mutation=True)
|
||||
self.assertEqual(resolved["org"], INSTALL_ORG)
|
||||
self.assertEqual(resolved["repository"], INSTALL_REPO)
|
||||
|
||||
def _denied(self, profile: dict, operation: str) -> bool:
|
||||
ok, _ = gitea_config.check_operation(
|
||||
operation,
|
||||
profile["allowed_operations"],
|
||||
profile["forbidden_operations"],
|
||||
)
|
||||
return not ok
|
||||
|
||||
def test_role_separation_is_enforced(self):
|
||||
expectations = {
|
||||
"author": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"reviewer": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||
"merger": [
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"reconciler": [
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
}
|
||||
for role, denied_ops in expectations.items():
|
||||
profile = self.profiles[f"mcpcp-{role}"]
|
||||
for op in denied_ops:
|
||||
with self.subTest(role=role, operation=op):
|
||||
self.assertTrue(
|
||||
self._denied(profile, op),
|
||||
f"{role} must not be permitted {op}",
|
||||
)
|
||||
|
||||
def test_each_role_retains_its_own_capability(self):
|
||||
permitted = {
|
||||
"author": "gitea.pr.create",
|
||||
"reviewer": "gitea.pr.approve",
|
||||
"merger": "gitea.pr.merge",
|
||||
"reconciler": "gitea.branch.delete",
|
||||
}
|
||||
for role, op in permitted.items():
|
||||
with self.subTest(role=role, operation=op):
|
||||
self.assertFalse(self._denied(self.profiles[f"mcpcp-{role}"], op))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,768 @@
|
||||
"""Complete PROJECT_ROOT elimination in cross-repository MCP operations (#741).
|
||||
|
||||
#706 introduced the immutable ``canonical_repository_root``; #739/#740 routed
|
||||
three consumption paths through it. This module covers the paths #740 left
|
||||
behind, whose shape is uniform: the *filesystem* guards were migrated to the
|
||||
canonical root, but *repository identity* still bottomed out in
|
||||
``_local_git_remote_url``'s hardcoded ``cwd=PROJECT_ROOT`` — always the
|
||||
Gitea-Tools installation checkout.
|
||||
|
||||
The consequences asserted here:
|
||||
|
||||
* **Identity inversion.** ``_resolve``, the #530 remote/repo guard and the
|
||||
anti-stomp org/repo fill all derived the *target* repository from the
|
||||
*install* checkout, so a cross-repository namespace resolved Gitea-Tools
|
||||
coordinates while its branch/parity facts came from the target repo.
|
||||
* **Guard disagreement.** ``_verify_role_mutation_workspace`` omitted
|
||||
``configured_canonical_root``, so the #274 branches-only / worktree-membership
|
||||
guards validated ``Gitea-Tools/branches/`` rather than the bound target.
|
||||
* **Explicit-coordinate override.** Both-explicit ``org``/``repo`` short-circuit
|
||||
``assess_remote_repo_match``, so caller coordinates bypassed validation
|
||||
entirely rather than merely *confirming* the binding.
|
||||
* **Configuration fail-open.** ``_flatten_identity`` silently dropped
|
||||
``canonical_repository_root``, so a v2-``environments`` namespace fell back to
|
||||
the install root; the v1 path never validated the field at all.
|
||||
|
||||
A role-by-operation matrix drives author, reviewer, merger and reconciler
|
||||
against: correct cross-repository target, wrong repository, wrong worktree,
|
||||
explicit matching coordinates, explicit mismatched coordinates, missing
|
||||
canonical root, invalid canonical root, immutable binding after first bind, and
|
||||
request-override attempts.
|
||||
|
||||
Real git repositories are used throughout. No network calls are made, no branch
|
||||
is deleted, and no merge is performed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import canonical_repository_root as crr # noqa: E402
|
||||
import gitea_config # noqa: E402
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
import session_context_binding as session_ctx # noqa: E402
|
||||
|
||||
INSTALL_ORG = "Scaled-Tech-Consulting"
|
||||
INSTALL_REPO = "Gitea-Tools"
|
||||
INSTALL_SLUG = f"{INSTALL_ORG}/{INSTALL_REPO}"
|
||||
INSTALL_URL = f"https://gitea.prgs.cc/{INSTALL_SLUG}.git"
|
||||
|
||||
TARGET_ORG = "Scaled-Tech-Consulting"
|
||||
TARGET_REPO = "mcp-control-plane"
|
||||
TARGET_SLUG = f"{TARGET_ORG}/{TARGET_REPO}"
|
||||
TARGET_URL = f"https://gitea.prgs.cc/{TARGET_SLUG}.git"
|
||||
|
||||
THIRD_REPO = "Timesheet"
|
||||
THIRD_SLUG = f"{INSTALL_ORG}/{THIRD_REPO}"
|
||||
|
||||
# tests/conftest.py installs an autouse fixture
|
||||
# (mutation_profile_fixture.install_deterministic_remote_urls) that *permanently
|
||||
# reassigns* srv._local_git_remote_url to a stub mapping remote names to fixed
|
||||
# URLs. That stub deliberately ignores the working directory, which is exactly
|
||||
# the behaviour this module must verify — so these tests would silently assert
|
||||
# against the stub rather than production code in a full-suite run. Capture the
|
||||
# genuine implementation at import time (before any fixture executes) and
|
||||
# reinstall it per test.
|
||||
_REAL_LOCAL_GIT_REMOTE_URL = srv._local_git_remote_url
|
||||
|
||||
|
||||
def _git(cwd: str, *args: str) -> str:
|
||||
res = subprocess.run(
|
||||
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
|
||||
)
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
_git(str(path), "init", "-q")
|
||||
_git(str(path), "config", "user.email", "[email protected]")
|
||||
_git(str(path), "config", "user.name", "Test")
|
||||
_git(str(path), "remote", "add", remote_name, remote_url)
|
||||
# Distinct content per repo: identical seed content, author and timestamp
|
||||
# otherwise produce byte-identical commits and therefore an identical SHA,
|
||||
# which would make the parity-dimension assertions vacuously true.
|
||||
(path / "README.md").write_text(f"seed {remote_url}\n")
|
||||
_git(str(path), "add", "README.md")
|
||||
_git(str(path), "commit", "-q", "-m", f"seed {remote_name}")
|
||||
return os.path.realpath(str(path))
|
||||
|
||||
|
||||
_ROLE_OPS = {
|
||||
"author": (
|
||||
[
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes"],
|
||||
),
|
||||
"reviewer": (
|
||||
[
|
||||
"gitea.read",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||
),
|
||||
"merger": (
|
||||
["gitea.read", "gitea.pr.merge", "gitea.pr.comment"],
|
||||
[
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
),
|
||||
"reconciler": (
|
||||
["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.branch.delete"],
|
||||
[
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
ROLES = tuple(_ROLE_OPS)
|
||||
|
||||
|
||||
def _profile(role: str, *, canonical_root: str | None = None) -> dict:
|
||||
allowed, forbidden = _ROLE_OPS[role]
|
||||
profile = {
|
||||
"enabled": True,
|
||||
"context": "prgs",
|
||||
"role": role,
|
||||
"username": "jcwalker3",
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"auth": {"type": "env", "name": f"GITEA_TOKEN_PRGS_{role.upper()}"},
|
||||
"allowed_operations": list(allowed),
|
||||
"forbidden_operations": list(forbidden),
|
||||
"execution_profile": f"prgs-{role}",
|
||||
"allowed_repositories": [TARGET_SLUG],
|
||||
}
|
||||
if canonical_root is not None:
|
||||
profile["canonical_repository_root"] = canonical_root
|
||||
return profile
|
||||
|
||||
|
||||
def _config(profiles: dict) -> dict:
|
||||
return {
|
||||
"version": 2,
|
||||
"rules": {"allow_runtime_switching": True},
|
||||
"contexts": {
|
||||
"prgs": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||
}
|
||||
},
|
||||
"profiles": profiles,
|
||||
}
|
||||
|
||||
|
||||
class _CrossRepoHarness(unittest.TestCase):
|
||||
"""Real install + target git repos, temp profiles.json, no network."""
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.tmp = self._dir.name
|
||||
self.config_path = os.path.join(self.tmp, "profiles.json")
|
||||
|
||||
# The real install checkout carries a remote literally named `prgs`;
|
||||
# a freshly cloned target repository normally names its remote `origin`.
|
||||
# Reproducing that asymmetry is the point: identity derivation must not
|
||||
# depend on the remote happening to share the `remote=` argument's name.
|
||||
self.install_root = _init_repo(
|
||||
Path(self.tmp) / "install", INSTALL_URL, remote_name="prgs"
|
||||
)
|
||||
self.target_root = _init_repo(
|
||||
Path(self.tmp) / "target", TARGET_URL, remote_name="origin"
|
||||
)
|
||||
self.third_root = _init_repo(
|
||||
Path(self.tmp) / "third", f"https://gitea.prgs.cc/{THIRD_SLUG}.git"
|
||||
)
|
||||
self.not_a_repo = os.path.join(self.tmp, "plain-dir")
|
||||
os.makedirs(self.not_a_repo, exist_ok=True)
|
||||
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
srv._MUTATION_AUTHORITY = None
|
||||
|
||||
# PROJECT_ROOT is wherever the server file physically lives; pin it to a
|
||||
# real Gitea-Tools-identified checkout so "install-derived" is
|
||||
# deterministic regardless of the developer's layout.
|
||||
self._project_root = patch.object(srv, "PROJECT_ROOT", self.install_root)
|
||||
self._project_root.start()
|
||||
# Undo the autouse deterministic-remote stub for this module only.
|
||||
self._real_remote_url = patch.object(
|
||||
srv, "_local_git_remote_url", _REAL_LOCAL_GIT_REMOTE_URL
|
||||
)
|
||||
self._real_remote_url.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._real_remote_url.stop()
|
||||
self._project_root.stop()
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
srv._MUTATION_AUTHORITY = None
|
||||
self._dir.cleanup()
|
||||
|
||||
def _write_config(self, profiles: dict) -> None:
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(_config(profiles)))
|
||||
|
||||
def _env(self, role: str, **extra) -> dict:
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": f"prgs-{role}",
|
||||
"GITEA_TOKEN_PRGS_AUTHOR": "t",
|
||||
"GITEA_TOKEN_PRGS_REVIEWER": "t",
|
||||
"GITEA_TOKEN_PRGS_MERGER": "t",
|
||||
"GITEA_TOKEN_PRGS_RECONCILER": "t",
|
||||
}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
def _bind(self, role: str, canonical_root: str | None):
|
||||
"""Activate *role* with *canonical_root* and return an env patch ctx."""
|
||||
self._write_config(
|
||||
{f"prgs-{role}": _profile(role, canonical_root=canonical_root)}
|
||||
)
|
||||
return patch.dict(os.environ, self._env(role), clear=True)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. The central helper (single source of root resolution)
|
||||
# ===========================================================================
|
||||
class TestCanonicalLocalGitRoot(_CrossRepoHarness):
|
||||
"""_canonical_local_git_root is the one place a target root is derived."""
|
||||
|
||||
def test_unconfigured_namespace_uses_installation_root(self):
|
||||
with self._bind("author", None):
|
||||
self.assertEqual(srv._canonical_local_git_root(), self.install_root)
|
||||
|
||||
def test_configured_namespace_uses_target_root(self):
|
||||
with self._bind("author", self.target_root):
|
||||
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
|
||||
|
||||
def test_configured_root_resolves_to_git_toplevel(self):
|
||||
nested = os.path.join(self.target_root, "branches", "wt")
|
||||
os.makedirs(nested, exist_ok=True)
|
||||
with self._bind("author", nested):
|
||||
# A subdirectory of the target repo still resolves to its toplevel.
|
||||
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
|
||||
|
||||
def test_invalid_root_never_silently_becomes_installation_root(self):
|
||||
"""A configured-but-broken root must not fall back to Gitea-Tools."""
|
||||
with self._bind("author", self.not_a_repo):
|
||||
resolved = srv._canonical_local_git_root()
|
||||
self.assertNotEqual(resolved, self.install_root)
|
||||
self.assertEqual(resolved, os.path.realpath(self.not_a_repo))
|
||||
|
||||
def test_env_override_beats_profile_binding(self):
|
||||
self._write_config(
|
||||
{"prgs-author": _profile("author", canonical_root=self.third_root)}
|
||||
)
|
||||
env = self._env("author", GITEA_CANONICAL_REPOSITORY_ROOT=self.target_root)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. Repository identity — the upstream inversion
|
||||
# ===========================================================================
|
||||
class TestRepositoryIdentityDerivation(_CrossRepoHarness):
|
||||
"""_local_git_remote_url and its consumers must read the target repo."""
|
||||
|
||||
def test_remote_url_reads_target_not_installation(self):
|
||||
with self._bind("author", self.target_root):
|
||||
self.assertEqual(srv._local_git_remote_url("origin"), TARGET_URL)
|
||||
|
||||
def test_remote_url_unconfigured_still_reads_installation(self):
|
||||
with self._bind("author", None):
|
||||
# The install checkout's remote is named `prgs`, matching production.
|
||||
self.assertEqual(srv._local_git_remote_url("prgs"), INSTALL_URL)
|
||||
|
||||
def test_workspace_slug_follows_canonical_root(self):
|
||||
with self._bind("author", self.target_root):
|
||||
self.assertEqual(srv._workspace_repository_slug("origin"), TARGET_SLUG)
|
||||
|
||||
def test_workspace_slug_unconfigured_is_installation(self):
|
||||
with self._bind("author", None):
|
||||
self.assertEqual(srv._workspace_repository_slug("prgs"), INSTALL_SLUG)
|
||||
|
||||
def test_every_role_derives_the_same_target_identity(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role):
|
||||
with self._bind(role, self.target_root):
|
||||
self.assertEqual(
|
||||
srv._workspace_repository_slug("origin"), TARGET_SLUG
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. _resolve — omitted and explicit coordinates
|
||||
# ===========================================================================
|
||||
class TestResolveTargetCoordinates(_CrossRepoHarness):
|
||||
"""Omitted coordinates follow the binding; explicit ones may only confirm."""
|
||||
|
||||
def test_omitted_coordinates_resolve_to_target_repository(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role):
|
||||
with self._bind(role, self.target_root):
|
||||
_host, org, repo = srv._resolve("prgs", None, None, None)
|
||||
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
|
||||
|
||||
def test_omitted_coordinates_unconfigured_resolve_to_installation(self):
|
||||
with self._bind("author", None):
|
||||
_host, org, repo = srv._resolve("prgs", None, None, None)
|
||||
self.assertEqual((org, repo), (INSTALL_ORG, INSTALL_REPO))
|
||||
|
||||
def test_explicit_matching_coordinates_confirm_the_binding(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role):
|
||||
with self._bind(role, self.target_root):
|
||||
_host, org, repo = srv._resolve(
|
||||
"prgs", None, TARGET_ORG, TARGET_REPO
|
||||
)
|
||||
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
|
||||
|
||||
def test_explicit_mismatched_repository_cannot_override_binding(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role):
|
||||
with self._bind(role, self.target_root):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv._resolve("prgs", None, TARGET_ORG, INSTALL_REPO)
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("#741", msg)
|
||||
self.assertIn("fail closed", msg)
|
||||
|
||||
def test_explicit_mismatched_organization_cannot_override_binding(self):
|
||||
with self._bind("author", self.target_root):
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv._resolve("prgs", None, "SomeoneElse", TARGET_REPO)
|
||||
|
||||
def test_gitea_tools_rooted_namespace_cannot_target_control_plane(self):
|
||||
"""Direction 1: an install-rooted namespace must not reach the target."""
|
||||
with self._bind("author", self.install_root):
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv._resolve("prgs", None, TARGET_ORG, TARGET_REPO)
|
||||
|
||||
def test_control_plane_rooted_namespace_cannot_target_gitea_tools(self):
|
||||
"""Direction 2: a target-rooted namespace must not reach Gitea-Tools."""
|
||||
with self._bind("author", self.target_root):
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
|
||||
|
||||
def test_unconfigured_namespace_keeps_existing_explicit_behaviour(self):
|
||||
"""Same-repository behaviour is unchanged (no new fail-closed path)."""
|
||||
with self._bind("author", None):
|
||||
_host, org, repo = srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
|
||||
self.assertEqual((org, repo), (INSTALL_ORG, INSTALL_REPO))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. #274 workspace guard — the two guard paths must agree
|
||||
# ===========================================================================
|
||||
class TestMutationWorkspaceGuardBinding(_CrossRepoHarness):
|
||||
"""Both guard paths must agree on which repository they protect."""
|
||||
|
||||
def _contexts(self, worktree: str | None = None):
|
||||
return srv._resolve_namespace_mutation_context(worktree)
|
||||
|
||||
def test_mutation_context_uses_target_root(self):
|
||||
with self._bind("author", self.target_root):
|
||||
self.assertEqual(self._contexts()["canonical_repo_root"], self.target_root)
|
||||
|
||||
def test_mutation_context_unconfigured_uses_installation_root(self):
|
||||
with self._bind("author", None):
|
||||
self.assertEqual(self._contexts()["canonical_repo_root"], self.install_root)
|
||||
|
||||
def test_role_mutation_workspace_guard_agrees_with_mutation_context(self):
|
||||
"""The omitted configured_canonical_root made these two disagree."""
|
||||
import namespace_workspace_binding as nwb
|
||||
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role):
|
||||
with self._bind(role, self.target_root):
|
||||
configured, _src = srv._configured_canonical_root()
|
||||
assessment = nwb.assess_namespace_mutation_workspace(
|
||||
role_kind=role,
|
||||
worktree_path=None,
|
||||
worktree=None,
|
||||
process_project_root=srv.PROJECT_ROOT,
|
||||
profile_name=f"prgs-{role}",
|
||||
configured_canonical_root=configured,
|
||||
)
|
||||
self.assertEqual(
|
||||
assessment["canonical_repo_root"],
|
||||
self._contexts()["canonical_repo_root"],
|
||||
)
|
||||
self.assertEqual(
|
||||
assessment["canonical_repo_root"], self.target_root
|
||||
)
|
||||
|
||||
def test_wrong_worktree_is_rejected_against_target_root(self):
|
||||
"""A worktree belonging to the install repo is not in the target repo."""
|
||||
import author_mutation_worktree as amw
|
||||
|
||||
with self._bind("author", self.target_root):
|
||||
foreign = os.path.join(self.install_root, "branches", "wt")
|
||||
os.makedirs(foreign, exist_ok=True)
|
||||
membership = amw.assess_workspace_repo_membership(
|
||||
workspace_path=foreign,
|
||||
canonical_repo_root=self.target_root,
|
||||
)
|
||||
self.assertTrue(membership["block"])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. Canonical-root validation — missing / invalid / mismatched
|
||||
# ===========================================================================
|
||||
class TestCanonicalRootFailClosed(_CrossRepoHarness):
|
||||
"""Missing, invalid, ambiguous and mismatched roots fail closed."""
|
||||
|
||||
def _assess(self, value, *, expected=TARGET_SLUG, require=True):
|
||||
return crr.assess_canonical_repository_root(
|
||||
configured_value=value,
|
||||
source="test",
|
||||
expected_slug=expected,
|
||||
process_project_root=self.install_root,
|
||||
remote="origin",
|
||||
require_binding=require,
|
||||
)
|
||||
|
||||
def test_missing_root_fails_closed_when_binding_required(self):
|
||||
result = self._assess(None)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertFalse(result["configured"])
|
||||
|
||||
def test_missing_root_is_the_single_repo_default_when_not_required(self):
|
||||
result = self._assess(None, expected=None, require=False)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["canonical_repo_root"], self.install_root)
|
||||
|
||||
def test_nonexistent_root_fails_closed(self):
|
||||
result = self._assess(os.path.join(self.tmp, "nope"))
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("does not exist", " ".join(result["reasons"]))
|
||||
|
||||
def test_non_git_directory_fails_closed(self):
|
||||
result = self._assess(self.not_a_repo)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("not a git repository", " ".join(result["reasons"]))
|
||||
|
||||
def test_mismatched_repository_identity_fails_closed(self):
|
||||
result = self._assess(self.third_root)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("mismatch", " ".join(result["reasons"]))
|
||||
|
||||
def test_matching_repository_identity_is_proven(self):
|
||||
result = self._assess(self.target_root)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["resolved_slug"], TARGET_SLUG)
|
||||
self.assertEqual(result["canonical_repo_root"], self.target_root)
|
||||
|
||||
def test_unresolvable_root_yields_fail_closed_reasons_not_fallback(self):
|
||||
with self._bind("author", self.not_a_repo):
|
||||
slug, reasons = srv._canonical_repository_slug(srv.get_profile(), "origin")
|
||||
self.assertIsNone(slug)
|
||||
self.assertTrue(reasons)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 6. Immutability — first bind wins, requests cannot replace the root
|
||||
# ===========================================================================
|
||||
class TestCanonicalRootImmutability(_CrossRepoHarness):
|
||||
"""No request-supplied value can establish or swap the pinned root."""
|
||||
|
||||
def test_first_bind_pins_target_root(self):
|
||||
with self._bind("author", self.target_root):
|
||||
with patch(
|
||||
"gitea_mcp_server.api_request",
|
||||
side_effect=lambda *a, **k: {
|
||||
"login": "jcwalker3",
|
||||
"full_name": "T",
|
||||
"id": 1,
|
||||
"email": "[email protected]",
|
||||
},
|
||||
):
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
bound = session_ctx.get_session_context()
|
||||
self.assertEqual(bound["canonical_repository_root"], self.target_root)
|
||||
|
||||
def test_binding_is_first_write_wins(self):
|
||||
with self._bind("author", self.target_root):
|
||||
session_ctx.seed_session_context_if_unbound(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
org=TARGET_ORG,
|
||||
repository=TARGET_REPO,
|
||||
role_kind="author",
|
||||
source="test",
|
||||
canonical_repository_root=self.target_root,
|
||||
)
|
||||
# A second, contradictory seed must not replace the pin.
|
||||
session_ctx.seed_session_context_if_unbound(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
org=INSTALL_ORG,
|
||||
repository=INSTALL_REPO,
|
||||
role_kind="author",
|
||||
source="test-2",
|
||||
canonical_repository_root=self.install_root,
|
||||
)
|
||||
bound = session_ctx.get_session_context()
|
||||
self.assertEqual(bound["canonical_repository_root"], self.target_root)
|
||||
self.assertEqual(bound["repository"], TARGET_REPO)
|
||||
|
||||
def test_request_supplied_worktree_cannot_replace_the_root(self):
|
||||
with self._bind("author", self.target_root):
|
||||
ctx = srv._resolve_namespace_mutation_context(self.install_root)
|
||||
# The workspace argument may be demoted/inspected, but the canonical
|
||||
# repository root stays the configured target.
|
||||
self.assertEqual(ctx["canonical_repo_root"], self.target_root)
|
||||
|
||||
def test_request_supplied_coordinates_cannot_replace_the_root(self):
|
||||
with self._bind("author", self.target_root):
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
|
||||
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7. Parity dimensions stay separately labelled (#739 F3 preserved)
|
||||
# ===========================================================================
|
||||
class TestParityDimensionSeparation(_CrossRepoHarness):
|
||||
"""Server-implementation parity stays anchored to the install checkout."""
|
||||
|
||||
def test_server_dimension_is_installation_not_target(self):
|
||||
import master_parity_gate
|
||||
|
||||
with self._bind("author", self.target_root):
|
||||
head = master_parity_gate.read_git_head(srv.PROJECT_ROOT)
|
||||
self.assertEqual(head, _git(self.install_root, "rev-parse", "HEAD"))
|
||||
self.assertNotEqual(head, _git(self.target_root, "rev-parse", "HEAD"))
|
||||
|
||||
def test_target_dimension_reads_the_target_checkout(self):
|
||||
with self._bind("author", self.target_root):
|
||||
self.assertEqual(
|
||||
_git(srv._canonical_local_git_root(), "rev-parse", "HEAD"),
|
||||
_git(self.target_root, "rev-parse", "HEAD"),
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 8. Configuration loaders validate the binding consistently (AC13)
|
||||
# ===========================================================================
|
||||
class TestConfigurationLoaderValidation(unittest.TestCase):
|
||||
"""Every supported loader treats canonical_repository_root identically."""
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.tmp = self._dir.name
|
||||
|
||||
def tearDown(self):
|
||||
self._dir.cleanup()
|
||||
|
||||
def _write(self, data: dict) -> str:
|
||||
path = os.path.join(self.tmp, "profiles.json")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(data))
|
||||
return path
|
||||
|
||||
# --- v1 ---------------------------------------------------------------
|
||||
def _v1(self, root):
|
||||
return {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN"},
|
||||
"canonical_repository_root": root,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def test_v1_rejects_relative_canonical_root(self):
|
||||
path = self._write(self._v1("relative/path"))
|
||||
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||
gitea_config.load_config(path)
|
||||
self.assertIn("absolute", str(ctx.exception))
|
||||
|
||||
def test_v1_rejects_blank_canonical_root(self):
|
||||
path = self._write(self._v1(" "))
|
||||
with self.assertRaises(gitea_config.ConfigError):
|
||||
gitea_config.load_config(path)
|
||||
|
||||
def test_v1_accepts_absolute_canonical_root(self):
|
||||
path = self._write(self._v1("/abs/target"))
|
||||
loaded = gitea_config.load_config(path)
|
||||
self.assertEqual(
|
||||
loaded["profiles"]["prgs-author"]["canonical_repository_root"],
|
||||
"/abs/target",
|
||||
)
|
||||
|
||||
def test_v1_without_the_field_is_unchanged(self):
|
||||
data = self._v1("/abs/target")
|
||||
del data["profiles"]["prgs-author"]["canonical_repository_root"]
|
||||
loaded = gitea_config.load_config(self._write(data))
|
||||
self.assertNotIn("canonical_repository_root", loaded["profiles"]["prgs-author"])
|
||||
|
||||
# --- v2 environments --------------------------------------------------
|
||||
def _v2_env(self, root):
|
||||
ident = {
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN"},
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"username": "jcwalker3",
|
||||
"allowed_operations": ["gitea.read"],
|
||||
}
|
||||
if root is not None:
|
||||
ident["canonical_repository_root"] = root
|
||||
return {
|
||||
"version": 2,
|
||||
"environments": {
|
||||
"prgs": {"services": {"gitea": {"identities": {"author": ident}}}}
|
||||
},
|
||||
}
|
||||
|
||||
def test_v2_environments_propagates_canonical_root(self):
|
||||
"""Regression: the field was silently dropped during flattening."""
|
||||
loaded = gitea_config.load_config(self._write(self._v2_env("/abs/target")))
|
||||
profile = loaded["profiles"]["prgs.gitea.author"]
|
||||
self.assertEqual(profile["canonical_repository_root"], "/abs/target")
|
||||
|
||||
def test_v2_environments_rejects_relative_canonical_root(self):
|
||||
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||
gitea_config.load_config(self._write(self._v2_env("relative/path")))
|
||||
self.assertIn("absolute", str(ctx.exception))
|
||||
|
||||
def test_v2_environments_without_the_field_is_unchanged(self):
|
||||
loaded = gitea_config.load_config(self._write(self._v2_env(None)))
|
||||
self.assertNotIn(
|
||||
"canonical_repository_root", loaded["profiles"]["prgs.gitea.author"]
|
||||
)
|
||||
|
||||
# --- v2 contexts (already validated; guard against regression) ---------
|
||||
def test_v2_contexts_still_rejects_relative_canonical_root(self):
|
||||
data = {
|
||||
"version": 2,
|
||||
"contexts": {
|
||||
"prgs": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"enabled": True,
|
||||
"context": "prgs",
|
||||
"username": "jcwalker3",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN"},
|
||||
"allowed_operations": ["gitea.read"],
|
||||
"canonical_repository_root": "relative/path",
|
||||
}
|
||||
},
|
||||
}
|
||||
with self.assertRaises(gitea_config.ConfigError):
|
||||
gitea_config.load_config(self._write(data))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 9. Role-by-operation matrix
|
||||
# ===========================================================================
|
||||
class TestRoleOperationMatrix(_CrossRepoHarness):
|
||||
"""Each role, each cross-repository condition, one assertion per cell."""
|
||||
|
||||
def test_correct_target_resolves_for_every_role(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role, case="correct-target"):
|
||||
with self._bind(role, self.target_root):
|
||||
_h, org, repo = srv._resolve("prgs", None, None, None)
|
||||
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
|
||||
|
||||
def test_wrong_repository_is_rejected_for_every_role(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role, case="wrong-repo"):
|
||||
with self._bind(role, self.target_root):
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
|
||||
|
||||
def test_explicit_matching_coordinates_pass_for_every_role(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role, case="explicit-match"):
|
||||
with self._bind(role, self.target_root):
|
||||
_h, org, repo = srv._resolve("prgs", None, TARGET_ORG, TARGET_REPO)
|
||||
self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))
|
||||
|
||||
def test_explicit_mismatch_fails_closed_for_every_role(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role, case="explicit-mismatch"):
|
||||
with self._bind(role, self.target_root):
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv._resolve("prgs", None, INSTALL_ORG, THIRD_REPO)
|
||||
|
||||
def test_missing_canonical_root_preserves_single_repo_default(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role, case="missing-root"):
|
||||
with self._bind(role, None):
|
||||
self.assertEqual(srv._canonical_local_git_root(), self.install_root)
|
||||
|
||||
def test_invalid_canonical_root_never_becomes_install_root(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role, case="invalid-root"):
|
||||
with self._bind(role, self.not_a_repo):
|
||||
self.assertNotEqual(
|
||||
srv._canonical_local_git_root(), self.install_root
|
||||
)
|
||||
|
||||
def test_wrong_worktree_rejected_for_every_role(self):
|
||||
import author_mutation_worktree as amw
|
||||
|
||||
foreign = os.path.join(self.install_root, "branches", "foreign")
|
||||
os.makedirs(foreign, exist_ok=True)
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role, case="wrong-worktree"):
|
||||
with self._bind(role, self.target_root):
|
||||
membership = amw.assess_workspace_repo_membership(
|
||||
workspace_path=foreign,
|
||||
canonical_repo_root=srv._canonical_local_git_root(),
|
||||
)
|
||||
self.assertTrue(membership["block"])
|
||||
|
||||
def test_request_override_attempt_rejected_for_every_role(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role, case="request-override"):
|
||||
with self._bind(role, self.target_root):
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv._resolve("prgs", None, "Attacker", "evil-repo")
|
||||
self.assertEqual(srv._canonical_local_git_root(), self.target_root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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()
|
||||
@@ -79,22 +79,26 @@ class TestReviewerLeaseAcquire(unittest.TestCase):
|
||||
|
||||
|
||||
class TestReviewerLeaseFreshness(unittest.TestCase):
|
||||
def test_stale_warning_after_30_minutes(self):
|
||||
def test_stale_warning_at_half_the_sliding_window(self):
|
||||
# #747 warns at half the 10-minute window, while the owner can still
|
||||
# heartbeat and keep the lease.
|
||||
lease = leases.parse_lease_comment(
|
||||
_lease_comment(382, "session-a", minutes_ago=35)["body"]
|
||||
_lease_comment(382, "session-a", minutes_ago=6)["body"]
|
||||
)
|
||||
self.assertEqual(
|
||||
leases.classify_lease_freshness(lease),
|
||||
"stale_warning",
|
||||
)
|
||||
|
||||
def test_reclaimable_after_60_minutes(self):
|
||||
def test_expired_once_the_sliding_window_lapses(self):
|
||||
# Pre-#747 a 65-minute-idle lease was "reclaimable" and had to wait out
|
||||
# a second timer. It is now simply expired and immediately takeable.
|
||||
lease = leases.parse_lease_comment(
|
||||
_lease_comment(382, "session-a", minutes_ago=65)["body"]
|
||||
)
|
||||
self.assertEqual(
|
||||
leases.classify_lease_freshness(lease),
|
||||
"reclaimable",
|
||||
"expired",
|
||||
)
|
||||
|
||||
|
||||
@@ -281,7 +285,10 @@ class TestReviewerLeaseHandoffDiagnose(unittest.TestCase):
|
||||
self.assertEqual(result["active_lease"]["comment_id"], 8647)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
|
||||
def test_foreign_reclaimable_release_expired(self):
|
||||
def test_foreign_expired_release_expired(self):
|
||||
# Pre-#747 this classified as "foreign_reclaimable" after the 60-minute
|
||||
# activity band. Under the sliding TTL the lease is simply expired, and
|
||||
# the sanctioned next action is unchanged.
|
||||
reclaim = _lease_comment(
|
||||
592, "foreign-old", phase="claimed", minutes_ago=65
|
||||
)
|
||||
@@ -293,7 +300,7 @@ class TestReviewerLeaseHandoffDiagnose(unittest.TestCase):
|
||||
current_reviewer_identity="sysadmin",
|
||||
proposed_worktree="branches/review-pr-592",
|
||||
)
|
||||
self.assertEqual(result["classification"], "foreign_reclaimable")
|
||||
self.assertEqual(result["classification"], "foreign_expired")
|
||||
self.assertEqual(
|
||||
result["next_action"], leases.NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user