Compare commits
200
Commits
64b83cd1fb
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c2f45abb7 | ||
|
|
5ed2ab8a38 | ||
|
|
0568f44cb2 | ||
|
|
ab34280f90 | ||
|
|
9bf3acfef6 | ||
|
|
059ee77c1f | ||
|
|
bc968dd2e0 | ||
|
|
716fc21a0d | ||
|
|
edaeede250 | ||
|
|
5547399037 | ||
|
|
cb6ae0ca50 | ||
|
|
d12adabeb1 | ||
|
|
4b8a9219d8 | ||
|
|
e168978579 | ||
|
|
fcf6981b1b | ||
|
|
d181d499d3 | ||
|
|
b7a5284b98 | ||
|
|
6b568d8805 | ||
|
|
7f2b9f36de | ||
|
|
8a851eb87e | ||
|
|
ad59053cd7 | ||
|
|
854818e65a | ||
|
|
9d2c652ae8 | ||
|
|
adc61255b2 | ||
|
|
bde5c5fb20 | ||
|
|
a517655aad | ||
|
|
447595b72b | ||
|
|
5e0e474350 | ||
|
|
043df0f7df | ||
|
|
f858c1d1b2 | ||
|
|
08f67007c5 | ||
|
|
3edeba4d7f | ||
|
|
0425bf9a43 | ||
|
|
03c64a3219 | ||
|
|
eac7afe5cb | ||
|
|
e349839fd7 | ||
|
|
fdab6b6c69 | ||
|
|
277ec5269d | ||
|
|
b00e09a781 | ||
|
|
b05075fd25 | ||
|
|
61c3a57df5 | ||
|
|
c908ed6050 | ||
|
|
0a38d92382 | ||
|
|
fde95b9266 | ||
|
|
7ae5f3a541 | ||
|
|
22d0fdd251 | ||
|
|
15a8a76e99 | ||
|
|
a8d2087b4a | ||
|
|
61c0d73cd1 | ||
|
|
6a0d7bbef4 | ||
|
|
29d96c8946 | ||
|
|
8d8d2d8f81 | ||
|
|
0d8a2c2b1d | ||
|
|
11d1d2e99f | ||
|
|
3990fc684f | ||
|
|
daf60266d0 | ||
|
|
7e5eca08b3 | ||
|
|
39e27dc63f | ||
|
|
7eb4884658 | ||
|
|
3e761206e5 | ||
|
|
0801e2455b | ||
|
|
ec2433f13b | ||
|
|
899ef8ec7f | ||
|
|
58d6b5844f | ||
|
|
c1c61e9b15 | ||
|
|
fca3296883 | ||
|
|
d7b0b0e772 | ||
|
|
83bc7246ff | ||
|
|
29c2cf2ef6 | ||
|
|
4a160e0e0c | ||
|
|
14d885b2d2 | ||
|
|
a3dbf668fd | ||
|
|
fb9191e559 | ||
|
|
07083eae09 | ||
|
|
d867acd9db | ||
|
|
a19af4de22 | ||
|
|
751403d12b | ||
|
|
3e78db5d28 | ||
|
|
e33d8874a5 | ||
|
|
98fbfb3de7 | ||
|
|
8814c04e3a | ||
|
|
7c77df0c52 | ||
|
|
c54f41c38d | ||
|
|
f34ec86b90 | ||
|
|
7cb65028fd | ||
|
|
f65069d22d | ||
|
|
9617b64a77 | ||
|
|
4f894009f6 | ||
|
|
acf2eaec01 | ||
|
|
e8ca5202a0 | ||
|
|
99640f90d0 | ||
|
|
1421fa8568 | ||
|
|
9e144db75c | ||
|
|
c780ded653 | ||
|
|
67e4a2b5e9 | ||
|
|
ba7915452e | ||
|
|
293808b42d | ||
|
|
970e68bddb | ||
|
|
80f59b334e | ||
|
|
77746b08fc | ||
|
|
1c37e62014 | ||
|
|
137426f7ad | ||
|
|
5b0d7aed0a | ||
|
|
1ab384adc9 | ||
|
|
dc899d23c8 | ||
|
|
943d40270e | ||
|
|
d936da5c87 | ||
|
|
573e721437 | ||
|
|
29ffe1407f | ||
|
|
632c568865 | ||
|
|
2b359e0c26 | ||
|
|
9cb12ee0f4 | ||
|
|
ec5cf67771 | ||
|
|
1eafb757a9 | ||
|
|
576349d545 | ||
|
|
ae6f0b74db | ||
|
|
553f745f23 | ||
|
|
cc3ad0870a | ||
|
|
ee90a5e7a2 | ||
|
|
c2fc2683b9 | ||
|
|
889931d553 | ||
|
|
6b675f5c83 | ||
|
|
b4d0cb22e4 | ||
|
|
1844e29880 | ||
|
|
237656702f | ||
|
|
6d86db793b | ||
|
|
4a63578003 | ||
|
|
c7a444eb4b | ||
|
|
8cac50b2e7 | ||
|
|
56f1230a10 | ||
|
|
d302602567 | ||
|
|
3fd02a3c63 | ||
|
|
ea8e186549 | ||
|
|
78cc37a977 | ||
|
|
22698c1b5f | ||
|
|
de27053f74 | ||
|
|
5933d87647 | ||
|
|
72b18143f8 | ||
|
|
8886ce201f | ||
|
|
bee24e2b10 | ||
|
|
a7aac0df1d | ||
|
|
ec903b0d61 | ||
|
|
f34319852e | ||
|
|
2fa97c26fb | ||
|
|
5ab5fe8583 | ||
|
|
f32aaaa3b5 | ||
|
|
5347b313ea | ||
|
|
1be4600261 | ||
|
|
780ef0b1be | ||
|
|
a654cda058 | ||
|
|
ab1c2d7973 | ||
|
|
6a179aeb85 | ||
|
|
f20c8436aa | ||
|
|
db5d14184f | ||
|
|
10228e1c06 | ||
|
|
d8b2b8f1a7 | ||
|
|
9e1d5c5649 | ||
|
|
2429d9d2e8 | ||
|
|
16cd871fbd | ||
|
|
783ea88a01 | ||
|
|
036b78e31e | ||
|
|
08c3488d7c | ||
|
|
4f2fd9a8b1 | ||
|
|
4185ee1417 | ||
|
|
ae6a3ae00c | ||
|
|
2fde92ad25 | ||
|
|
23e366d9d6 | ||
|
|
ebd06b73c9 | ||
|
|
af9f1c5944 | ||
|
|
f83d2c2027 | ||
|
|
93781af7e9 | ||
|
|
314615ec2c | ||
|
|
52013791d4 | ||
|
|
2ac7519792 | ||
|
|
a32c68e24b | ||
|
|
7186718cfd | ||
|
|
964505654f | ||
|
|
008cd3fd74 | ||
|
|
11ffe0f9dd | ||
|
|
683649424b | ||
|
|
ac531dda05 | ||
|
|
36781ebef7 | ||
|
|
2941ec529c | ||
|
|
eddb68818e | ||
|
|
b98e8dfa1a | ||
|
|
86651a3d05 | ||
|
|
3f3f880147 | ||
|
|
6094069ef6 | ||
|
|
6913ac98f1 | ||
|
|
faf36b5bdf | ||
|
|
05b4f13e96 | ||
|
|
57b2023611 | ||
|
|
5debfcf178 | ||
|
|
27d75facd4 | ||
|
|
2b4c291f12 | ||
|
|
b0ae1605c3 | ||
|
|
c738e5b10d | ||
|
|
fe9faec741 | ||
|
|
7f2a5a3d3c | ||
|
|
946d6c2f81 |
@@ -39,6 +39,27 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log
|
|||||||
# only — never the token value. Surfaced by gitea_get_profile.
|
# only — never the token value. Surfaced by gitea_get_profile.
|
||||||
GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
||||||
|
|
||||||
|
# ── Optional self-hosted Sentry observability (#606) ────────────────────────
|
||||||
|
# Emits runtime errors, fail-closed workflow blockers, lease/terminal-lock/
|
||||||
|
# stale-runtime collisions, and watchdog cron check-ins to a SELF-HOSTED Sentry
|
||||||
|
# (https://sentry.prgs.cc/) — never Sentry Cloud. Gitea stays the source of
|
||||||
|
# truth; Sentry is observe-only. OFF by default: with MCP_SENTRY_ENABLED unset
|
||||||
|
# or SENTRY_DSN empty, nothing is initialised and no events are sent.
|
||||||
|
#
|
||||||
|
# Master gate. Truthy = 1/true/yes/on. Both this AND SENTRY_DSN are required.
|
||||||
|
MCP_SENTRY_ENABLED=0
|
||||||
|
# DSN for the self-hosted project (create a `gitea-tools-mcp` project in
|
||||||
|
# https://sentry.prgs.cc/ and copy its DSN). Never commit a real DSN.
|
||||||
|
SENTRY_DSN=
|
||||||
|
# Deployment environment tag (local/dev/prod). Defaults to "development".
|
||||||
|
SENTRY_ENVIRONMENT=development
|
||||||
|
# Optional release identifier (e.g. a git SHA or version string).
|
||||||
|
SENTRY_RELEASE=
|
||||||
|
# Performance-trace sample rate, 0.0–1.0 (clamped). Default 0.0 (traces off).
|
||||||
|
MCP_SENTRY_TRACES_SAMPLE_RATE=0.0
|
||||||
|
# Set to 1 to forward Python logs to Sentry as structured logs. Default off.
|
||||||
|
MCP_SENTRY_ENABLE_LOGS=0
|
||||||
|
|
||||||
# Optional canonical runtime-profile config (#19). Instead of the fields above,
|
# Optional canonical runtime-profile config (#19). Instead of the fields above,
|
||||||
# point every LLM launcher at ONE JSON file of named profiles and select one.
|
# point every LLM launcher at ONE JSON file of named profiles and select one.
|
||||||
# Secrets are referenced (keychain id / env var name), never inlined. See
|
# Secrets are referenced (keychain id / env var name), never inlined. See
|
||||||
@@ -55,3 +76,12 @@ GITEA_MCP_PROFILE=prgs
|
|||||||
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
||||||
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
||||||
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
||||||
|
|
||||||
|
# Durable HMAC key for irrecoverable decision-lock provenance artifacts (#709 F4).
|
||||||
|
# REQUIRED in production native MCP processes that mint or verify recovery
|
||||||
|
# authorization (reconciler + merger must share the same key). Hex (preferred),
|
||||||
|
# base64, or utf-8 literal (>=16 bytes). Never generate an ephemeral per-process
|
||||||
|
# key — cross-process / post-restart verification would fail closed.
|
||||||
|
# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||||
|
# Optional key version string bound into the signature (for rotation).
|
||||||
|
# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION=v1
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Canonical dependency parsing and live-state resolution for the allocator (#758).
|
||||||
|
|
||||||
|
The work allocator previously inferred dependency state from two lowercase
|
||||||
|
substrings (``"blocked on #"`` / ``"downstream of #"``). The repository's
|
||||||
|
canonical declaration form is a ``Depends:`` field inside the issue body's
|
||||||
|
linkage line, for example::
|
||||||
|
|
||||||
|
* Parent: #631 · Depends: #633, #634 · Related: #630, #434
|
||||||
|
|
||||||
|
That form matched neither substring, so dependency-blocked issues were emitted
|
||||||
|
as eligible candidates. This module replaces substring inference with:
|
||||||
|
|
||||||
|
1. structured parsing of ``Depends:`` declarations into issue references, and
|
||||||
|
2. resolution of each reference against **live issue state**, never body text.
|
||||||
|
|
||||||
|
Both halves fail closed: a reference whose state cannot be established makes
|
||||||
|
the owning candidate ineligible rather than assignable.
|
||||||
|
|
||||||
|
No issue number is special-cased here (#758 AC4/AC14); the parser is driven
|
||||||
|
entirely by the declaration syntax.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
# Live issue states, as reported by Gitea.
|
||||||
|
DEP_STATE_OPEN = "open"
|
||||||
|
DEP_STATE_CLOSED = "closed"
|
||||||
|
|
||||||
|
# "Depends:" / "Depends on:" introduces the declaration. "Dependencies" does
|
||||||
|
# not match: after "depend" it continues with "e", not "s".
|
||||||
|
_DEPENDS_KEYWORD = re.compile(r"depends(?:\s+on)?\s*:?\s*", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Immediately after the keyword, consume only the contiguous run of issue
|
||||||
|
# references. Anchoring the run this way means the declaration ends naturally
|
||||||
|
# at the next separator ("·", newline) or sibling field ("Related:"), without
|
||||||
|
# needing to enumerate separators.
|
||||||
|
_DEP_RUN = re.compile(
|
||||||
|
r"\s*(#\d+(?:\s*(?:,|and|&)\s*#\d+)*)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy marker retained so previously-recognized bodies keep working.
|
||||||
|
_LEGACY_BLOCKED = re.compile(r"blocked\s+on\s+#(\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
_ISSUE_REF = re.compile(r"#(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dependency_refs(body: str | None) -> tuple[int, ...]:
|
||||||
|
"""Extract declared dependency issue numbers from an issue *body*.
|
||||||
|
|
||||||
|
Recognizes the canonical ``Depends: #N, #N`` field (including the
|
||||||
|
``Depends on`` spelling) plus the legacy ``blocked on #N`` marker.
|
||||||
|
Returns references in first-seen order with duplicates removed. Malformed
|
||||||
|
or absent declarations yield an empty tuple rather than raising.
|
||||||
|
"""
|
||||||
|
if not body:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
refs: list[int] = []
|
||||||
|
|
||||||
|
def _add(value: str) -> None:
|
||||||
|
number = int(value)
|
||||||
|
if number > 0 and number not in refs:
|
||||||
|
refs.append(number)
|
||||||
|
|
||||||
|
for match in _DEPENDS_KEYWORD.finditer(body):
|
||||||
|
run = _DEP_RUN.match(body, match.end())
|
||||||
|
if not run:
|
||||||
|
continue
|
||||||
|
for ref in _ISSUE_REF.findall(run.group(1)):
|
||||||
|
_add(ref)
|
||||||
|
|
||||||
|
for ref in _LEGACY_BLOCKED.findall(body):
|
||||||
|
_add(ref)
|
||||||
|
|
||||||
|
return tuple(refs)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dependency_state(
|
||||||
|
refs: Iterable[int],
|
||||||
|
state_lookup: Callable[[int], str | None],
|
||||||
|
*,
|
||||||
|
subject: str = "candidate",
|
||||||
|
) -> dict:
|
||||||
|
"""Resolve declared *refs* against live issue state.
|
||||||
|
|
||||||
|
*state_lookup* maps an issue number to its live state string, or to
|
||||||
|
``None`` when that evidence could not be obtained. A reference is:
|
||||||
|
|
||||||
|
* **met** when live state is ``closed``;
|
||||||
|
* **unmet** when live state is any other live value (``open``, etc.);
|
||||||
|
* **unavailable** when state is ``None`` or the lookup raises.
|
||||||
|
|
||||||
|
Unmet *and* unavailable both mark the candidate ineligible (#758 AC6/AC7):
|
||||||
|
allocation must never assume a dependency is satisfied.
|
||||||
|
"""
|
||||||
|
unmet: list[int] = []
|
||||||
|
unavailable: list[int] = []
|
||||||
|
met: list[int] = []
|
||||||
|
|
||||||
|
for ref in refs:
|
||||||
|
try:
|
||||||
|
number = int(ref)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
state = state_lookup(number)
|
||||||
|
except Exception: # noqa: BLE001 — unavailable evidence fails closed
|
||||||
|
state = None
|
||||||
|
normalized = (str(state).strip().lower() if state is not None else "") or None
|
||||||
|
if normalized is None:
|
||||||
|
unavailable.append(number)
|
||||||
|
elif normalized == DEP_STATE_CLOSED:
|
||||||
|
met.append(number)
|
||||||
|
else:
|
||||||
|
unmet.append(number)
|
||||||
|
|
||||||
|
reason: str | None = None
|
||||||
|
if unmet and unavailable:
|
||||||
|
reason = (
|
||||||
|
f"{subject} has unresolved dependencies "
|
||||||
|
f"{_fmt(unmet)} and unverifiable dependencies {_fmt(unavailable)} "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
elif unmet:
|
||||||
|
reason = (
|
||||||
|
f"{subject} depends on unresolved issue(s) {_fmt(unmet)}; "
|
||||||
|
"they are not closed"
|
||||||
|
)
|
||||||
|
elif unavailable:
|
||||||
|
reason = (
|
||||||
|
f"{subject} dependency evidence unavailable for {_fmt(unavailable)} "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"refs": tuple(int(x) for x in refs),
|
||||||
|
"met": tuple(met),
|
||||||
|
"unmet": tuple(unmet),
|
||||||
|
"unavailable": tuple(unavailable),
|
||||||
|
"dependency_unmet": bool(unmet or unavailable),
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(numbers: Iterable[int]) -> str:
|
||||||
|
return ", ".join(f"#{n}" for n in numbers)
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
"""Controller-owned work allocator policy (#600).
|
||||||
|
|
||||||
|
Builds on the #613 control-plane DB substrate (``ControlPlaneDB.assign_and_lease``).
|
||||||
|
|
||||||
|
Workers must not self-select exclusive work under the standard multi-LLM
|
||||||
|
workflow. They call ``gitea_allocate_next_work`` which:
|
||||||
|
|
||||||
|
1. Inspects candidate Gitea issues/PRs (never raw monitoring incidents).
|
||||||
|
2. Applies ADR routing policy (role, terminal path, leases, blocked, deps).
|
||||||
|
3. Atomically assigns + leases the selected item in one DB transaction.
|
||||||
|
|
||||||
|
This module is pure selection + substrate orchestration. Gitea I/O for live
|
||||||
|
inventory lives in the MCP tool wrapper so tests can inject candidates.
|
||||||
|
|
||||||
|
#612 remains downstream: bridge-created Gitea issues become candidates only
|
||||||
|
after they exist as normal issues; this module never assigns incidents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
from control_plane_db import (
|
||||||
|
ControlPlaneDB,
|
||||||
|
ControlPlaneError,
|
||||||
|
InvalidWorkKindError,
|
||||||
|
LeaseRequiredError,
|
||||||
|
WORK_KINDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Outcomes required by #600 / ADR §5.
|
||||||
|
OUTCOME_ASSIGNED = "assigned_work"
|
||||||
|
OUTCOME_WAIT = "wait"
|
||||||
|
OUTCOME_BLOCKED_TERMINAL = "blocked_by_terminal_path"
|
||||||
|
OUTCOME_BLOCKED_LEASE = "blocked_by_active_lease"
|
||||||
|
OUTCOME_NEEDS_CONTROLLER = "needs_controller"
|
||||||
|
OUTCOME_NO_SAFE = "no_safe_work"
|
||||||
|
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
||||||
|
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
||||||
|
|
||||||
|
# Human-readable statement of how a winner is chosen (#758 AC10). Reported
|
||||||
|
# alongside allocator results so the flat status:ready tier and its
|
||||||
|
# oldest-number tie-break are explicit rather than incidental.
|
||||||
|
SELECTION_POLICY = (
|
||||||
|
"rank complete inventory by (priority desc, PRs before issues, "
|
||||||
|
"number asc); status:ready issues share priority 20, so the oldest "
|
||||||
|
"eligible number wins ties; result limits never affect selection"
|
||||||
|
)
|
||||||
|
|
||||||
|
ROLE_AUTHOR = "author"
|
||||||
|
ROLE_REVIEWER = "reviewer"
|
||||||
|
ROLE_MERGER = "merger"
|
||||||
|
ROLE_RECONCILER = "reconciler"
|
||||||
|
ROLE_CONTROLLER = "controller"
|
||||||
|
|
||||||
|
VALID_ROLES = frozenset(
|
||||||
|
{ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default action matrices by role (mutation gate will re-check).
|
||||||
|
ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
|
||||||
|
ROLE_AUTHOR: (
|
||||||
|
("implement", "comment", "push", "create_pr"),
|
||||||
|
("approve", "merge", "request_changes", "self_select_without_assignment"),
|
||||||
|
),
|
||||||
|
ROLE_REVIEWER: (
|
||||||
|
("review", "comment", "approve", "request_changes"),
|
||||||
|
("merge", "push", "create_pr", "self_select_without_assignment"),
|
||||||
|
),
|
||||||
|
ROLE_MERGER: (
|
||||||
|
("merge", "comment"),
|
||||||
|
("approve", "request_changes", "push", "create_pr", "self_select_without_assignment"),
|
||||||
|
),
|
||||||
|
ROLE_RECONCILER: (
|
||||||
|
("comment", "diagnose", "cleanup"),
|
||||||
|
("approve", "merge", "push", "create_pr", "self_select_without_assignment"),
|
||||||
|
),
|
||||||
|
ROLE_CONTROLLER: (
|
||||||
|
("comment", "diagnose", "allocate"),
|
||||||
|
("approve", "merge", "push", "create_pr"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WorkCandidate:
|
||||||
|
"""One assignable Gitea issue or PR presented to the allocator."""
|
||||||
|
|
||||||
|
kind: str # issue | pr
|
||||||
|
number: int
|
||||||
|
state: str = "open"
|
||||||
|
labels: tuple[str, ...] = ()
|
||||||
|
title: str = ""
|
||||||
|
priority: int = 0
|
||||||
|
head_sha: str | None = None
|
||||||
|
# Routing signals (callers derive from Gitea / review feedback).
|
||||||
|
request_changes_current_head: bool = False
|
||||||
|
approval_on_current_head: bool = False
|
||||||
|
approval_stale: bool = False
|
||||||
|
approval_contaminated: bool = False
|
||||||
|
mergeable: bool = False
|
||||||
|
blocked: bool = False
|
||||||
|
dependency_unmet: bool = False
|
||||||
|
dependency_reason: str | None = None
|
||||||
|
already_claimed_elsewhere: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.kind = (self.kind or "").strip().lower()
|
||||||
|
self.state = (self.state or "open").strip().lower()
|
||||||
|
self.labels = tuple(
|
||||||
|
str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
|
||||||
|
)
|
||||||
|
if self.kind not in WORK_KINDS:
|
||||||
|
raise InvalidWorkKindError(
|
||||||
|
f"candidate kind '{self.kind}' is not assignable; only "
|
||||||
|
f"{sorted(WORK_KINDS)} (never raw incidents)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": self.kind,
|
||||||
|
"number": self.number,
|
||||||
|
"state": self.state,
|
||||||
|
"labels": list(self.labels),
|
||||||
|
"title": self.title,
|
||||||
|
"priority": self.priority,
|
||||||
|
"head_sha": self.head_sha,
|
||||||
|
"request_changes_current_head": self.request_changes_current_head,
|
||||||
|
"approval_on_current_head": self.approval_on_current_head,
|
||||||
|
"approval_stale": self.approval_stale,
|
||||||
|
"approval_contaminated": self.approval_contaminated,
|
||||||
|
"mergeable": self.mergeable,
|
||||||
|
"blocked": self.blocked,
|
||||||
|
"dependency_unmet": self.dependency_unmet,
|
||||||
|
"dependency_reason": self.dependency_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SkipRecord:
|
||||||
|
kind: str
|
||||||
|
number: int
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {"kind": self.kind, "number": self.number, "reason": self.reason}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
|
||||||
|
"""Map profile/role strings to a canonical allocator role."""
|
||||||
|
raw = (role or "").strip().lower()
|
||||||
|
if raw in VALID_ROLES:
|
||||||
|
return raw
|
||||||
|
prof = (profile_name or "").strip().lower()
|
||||||
|
for token in VALID_ROLES:
|
||||||
|
if token in prof or prof.endswith(f"-{token}"):
|
||||||
|
return token
|
||||||
|
if "author" in raw:
|
||||||
|
return ROLE_AUTHOR
|
||||||
|
if "review" in raw:
|
||||||
|
return ROLE_REVIEWER
|
||||||
|
if "merg" in raw:
|
||||||
|
return ROLE_MERGER
|
||||||
|
if "reconcil" in raw:
|
||||||
|
return ROLE_RECONCILER
|
||||||
|
if "control" in raw:
|
||||||
|
return ROLE_CONTROLLER
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"unknown allocator role '{role}' (profile={profile_name!r}); "
|
||||||
|
f"expected one of {sorted(VALID_ROLES)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def expected_role_for_candidate(c: WorkCandidate) -> str:
|
||||||
|
"""ADR §5.3 routing: which role should take this work next."""
|
||||||
|
if c.kind == "pr":
|
||||||
|
if c.approval_contaminated:
|
||||||
|
return ROLE_RECONCILER
|
||||||
|
if c.request_changes_current_head:
|
||||||
|
return ROLE_AUTHOR
|
||||||
|
if c.approval_stale:
|
||||||
|
return ROLE_REVIEWER
|
||||||
|
if c.approval_on_current_head and c.mergeable:
|
||||||
|
return ROLE_MERGER
|
||||||
|
# Open PR without terminal verdict → reviewer
|
||||||
|
return ROLE_REVIEWER
|
||||||
|
# Issues: ready work → author by default; blocked stays controller/none
|
||||||
|
labels = set(c.labels)
|
||||||
|
if "status:blocked" in labels or c.blocked:
|
||||||
|
return ROLE_CONTROLLER
|
||||||
|
return ROLE_AUTHOR
|
||||||
|
|
||||||
|
|
||||||
|
def role_actions(role: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||||
|
return ROLE_ACTIONS.get(role, ROLE_ACTIONS[ROLE_AUTHOR])
|
||||||
|
|
||||||
|
|
||||||
|
def classify_skip(
|
||||||
|
c: WorkCandidate,
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
terminal_pr: int | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Return skip reason, or None if candidate is selectable for *role*."""
|
||||||
|
if c.state in ("merged", "closed"):
|
||||||
|
return f"{c.kind}#{c.number} is {c.state}; never assign"
|
||||||
|
if c.blocked or "status:blocked" in c.labels:
|
||||||
|
return f"{c.kind}#{c.number} is blocked"
|
||||||
|
if c.dependency_unmet:
|
||||||
|
return (
|
||||||
|
c.dependency_reason
|
||||||
|
or f"{c.kind}#{c.number} has unmet dependencies"
|
||||||
|
)
|
||||||
|
if c.already_claimed_elsewhere:
|
||||||
|
return f"{c.kind}#{c.number} already claimed elsewhere"
|
||||||
|
if c.kind == "pr" and not (c.head_sha or "").strip():
|
||||||
|
return f"pr#{c.number} missing head_sha pin"
|
||||||
|
|
||||||
|
# Terminal path first: when an active terminal PR exists, only that PR
|
||||||
|
# (or controller diagnosis) is assignable for review-path roles.
|
||||||
|
if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr:
|
||||||
|
if role in (ROLE_REVIEWER, ROLE_MERGER):
|
||||||
|
return (
|
||||||
|
f"pr#{c.number} skipped: active terminal-review lock on "
|
||||||
|
f"PR #{terminal_pr} must be resolved first"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = expected_role_for_candidate(c)
|
||||||
|
if role == ROLE_CONTROLLER:
|
||||||
|
# Controller may inspect anything but only assigns diagnosis targets
|
||||||
|
# when contaminated / blocked.
|
||||||
|
if expected == ROLE_RECONCILER or c.blocked:
|
||||||
|
return None
|
||||||
|
return f"{c.kind}#{c.number} does not require controller (expected {expected})"
|
||||||
|
|
||||||
|
if role != expected:
|
||||||
|
return (
|
||||||
|
f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ready-gate for issues: prefer status:ready when labels present.
|
||||||
|
if c.kind == "issue" and c.labels:
|
||||||
|
if "status:ready" not in c.labels and "status:in-progress" not in c.labels:
|
||||||
|
# Allow unlabeled open issues; only skip explicit non-ready states.
|
||||||
|
if any(l.startswith("status:") for l in c.labels):
|
||||||
|
return f"issue#{c.number} not status:ready ({','.join(c.labels)})"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
|
||||||
|
"""Rank candidates deterministically (#758 AC10).
|
||||||
|
|
||||||
|
Ordering key, in precedence order:
|
||||||
|
|
||||||
|
1. ``priority`` descending — the loader scores ``status:ready`` issues at
|
||||||
|
20 and everything else at 1, so the ready queue ties at a single value
|
||||||
|
by design;
|
||||||
|
2. PRs before issues — in-flight review work drains before new authoring;
|
||||||
|
3. ``number`` ascending — oldest first, which is what actually breaks the
|
||||||
|
flat ``status:ready`` tie.
|
||||||
|
|
||||||
|
Because the ready tier is intentionally flat, rule 3 decides most real
|
||||||
|
selections. That is only safe when ranking sees the *complete* candidate
|
||||||
|
inventory: truncating before this call silently redefines "oldest" as
|
||||||
|
"oldest among whatever survived the slice", which is the defect #758
|
||||||
|
fixed. Callers must rank everything and bound reporting afterwards.
|
||||||
|
"""
|
||||||
|
return sorted(
|
||||||
|
candidates,
|
||||||
|
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def allocate_next_work(
|
||||||
|
db: ControlPlaneDB,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
role: str,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
candidates: Sequence[WorkCandidate],
|
||||||
|
apply: bool = False,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
username: str | None = None,
|
||||||
|
lease_ttl_seconds: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Select and optionally reserve the next work unit via control-plane DB.
|
||||||
|
|
||||||
|
*apply=False* (default): dry-run selection only — no lease/assignment.
|
||||||
|
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
||||||
|
|
||||||
|
Never uses file locks or comment-only leases as the assignment source.
|
||||||
|
"""
|
||||||
|
if db is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_NO_SAFE,
|
||||||
|
"reasons": [
|
||||||
|
"control-plane DB substrate unavailable (fail closed, #600/#613)"
|
||||||
|
],
|
||||||
|
"skipped": [],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
role_norm = normalize_role(role, profile_name=profile_name)
|
||||||
|
except ControlPlaneError as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_ROLE_INELIGIBLE,
|
||||||
|
"reasons": [str(exc)],
|
||||||
|
"skipped": [],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
}
|
||||||
|
|
||||||
|
session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
|
||||||
|
try:
|
||||||
|
db.upsert_session(
|
||||||
|
session_id=session_id,
|
||||||
|
role=role_norm,
|
||||||
|
profile=profile_name,
|
||||||
|
pid=os.getpid(),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface structured
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_NO_SAFE,
|
||||||
|
"reasons": [
|
||||||
|
f"failed to register session in control-plane DB: {exc} "
|
||||||
|
"(fail closed, #613)"
|
||||||
|
],
|
||||||
|
"skipped": [],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expire stale leases globally before selection.
|
||||||
|
try:
|
||||||
|
db.expire_stale_leases()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_NO_SAFE,
|
||||||
|
"reasons": [f"lease expiry failed: {exc} (fail closed)"],
|
||||||
|
"skipped": [],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal = None
|
||||||
|
try:
|
||||||
|
terminal = db.get_active_terminal_lock(remote=remote, org=org, repo=repo)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_NO_SAFE,
|
||||||
|
"reasons": [f"terminal lock lookup failed: {exc} (fail closed)"],
|
||||||
|
"skipped": [],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
}
|
||||||
|
terminal_pr = int(terminal["terminal_pr"]) if terminal else None
|
||||||
|
|
||||||
|
skipped: list[SkipRecord] = []
|
||||||
|
ordered = sort_candidates(list(candidates))
|
||||||
|
selected: WorkCandidate | None = None
|
||||||
|
for c in ordered:
|
||||||
|
reason = classify_skip(c, role=role_norm, terminal_pr=terminal_pr)
|
||||||
|
if reason:
|
||||||
|
skipped.append(SkipRecord(c.kind, c.number, reason))
|
||||||
|
continue
|
||||||
|
selected = c
|
||||||
|
break
|
||||||
|
|
||||||
|
if selected is None:
|
||||||
|
# If terminal lock blocks all review work, surface that explicitly.
|
||||||
|
if terminal_pr is not None and role_norm in (ROLE_REVIEWER, ROLE_MERGER):
|
||||||
|
outcome = OUTCOME_BLOCKED_TERMINAL
|
||||||
|
reasons = [
|
||||||
|
f"no safe work for role '{role_norm}': active terminal-review "
|
||||||
|
f"lock on PR #{terminal_pr} (resolve terminal path first, #332/#600)"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
outcome = OUTCOME_NO_SAFE
|
||||||
|
reasons = [
|
||||||
|
f"no safe assignable work for role '{role_norm}' "
|
||||||
|
f"among {len(ordered)} candidates"
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": outcome,
|
||||||
|
"apply": bool(apply),
|
||||||
|
"role": role_norm,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"username": username,
|
||||||
|
"session_id": session_id,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"selected": None,
|
||||||
|
"expected_role_next": None,
|
||||||
|
"reasons": reasons,
|
||||||
|
"skipped": [s.as_dict() for s in skipped],
|
||||||
|
"terminal_pr": terminal_pr,
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
"downstream_note": (
|
||||||
|
"#612 incident bridge remains downstream of #600; "
|
||||||
|
"allocator never assigns raw monitoring incidents"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_role = expected_role_for_candidate(selected)
|
||||||
|
allowed, forbidden = role_actions(role_norm)
|
||||||
|
selection = {
|
||||||
|
"kind": selected.kind,
|
||||||
|
"number": selected.number,
|
||||||
|
"title": selected.title,
|
||||||
|
"labels": list(selected.labels),
|
||||||
|
"head_sha": selected.head_sha,
|
||||||
|
"priority": selected.priority,
|
||||||
|
"expected_role_next": expected_role,
|
||||||
|
"reason_selected": (
|
||||||
|
f"highest-priority candidate for role '{role_norm}' "
|
||||||
|
f"(expected_role={expected_role})"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": OUTCOME_PREVIEW,
|
||||||
|
"apply": False,
|
||||||
|
"role": role_norm,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"username": username,
|
||||||
|
"session_id": session_id,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"selected": selection,
|
||||||
|
"expected_role_next": expected_role,
|
||||||
|
"reasons": [
|
||||||
|
"dry-run only (apply=false); no assignment/lease created — "
|
||||||
|
"call again with apply=true to reserve via control-plane DB"
|
||||||
|
],
|
||||||
|
"skipped": [s.as_dict() for s in skipped],
|
||||||
|
"terminal_pr": terminal_pr,
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
"downstream_note": (
|
||||||
|
"#612 incident bridge remains downstream of #600; "
|
||||||
|
"allocator never assigns raw monitoring incidents"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Atomic reserve via #613 substrate.
|
||||||
|
ttl = lease_ttl_seconds if lease_ttl_seconds is not None else None
|
||||||
|
try:
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"role": role_norm,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"kind": selected.kind,
|
||||||
|
"number": selected.number,
|
||||||
|
"expected_head_sha": selected.head_sha,
|
||||||
|
"allowed_actions": allowed,
|
||||||
|
"forbidden_actions": forbidden,
|
||||||
|
"phase": "allocated",
|
||||||
|
}
|
||||||
|
if ttl is not None:
|
||||||
|
kwargs["lease_ttl_seconds"] = int(ttl)
|
||||||
|
result = db.assign_and_lease(**kwargs)
|
||||||
|
except (InvalidWorkKindError, LeaseRequiredError, ControlPlaneError) as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_NO_SAFE,
|
||||||
|
"apply": True,
|
||||||
|
"role": role_norm,
|
||||||
|
"session_id": session_id,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"selected": selection,
|
||||||
|
"expected_role_next": expected_role,
|
||||||
|
"reasons": [f"atomic assign+lease failed: {exc} (fail closed, #613)"],
|
||||||
|
"skipped": [s.as_dict() for s in skipped],
|
||||||
|
"terminal_pr": terminal_pr,
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.outcome == "wait":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": OUTCOME_WAIT,
|
||||||
|
"apply": True,
|
||||||
|
"role": role_norm,
|
||||||
|
"session_id": session_id,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"selected": selection,
|
||||||
|
"expected_role_next": expected_role,
|
||||||
|
"reasons": [result.reason or "foreign active lease"],
|
||||||
|
"skipped": [s.as_dict() for s in skipped],
|
||||||
|
"terminal_pr": terminal_pr,
|
||||||
|
"assignment": result.as_dict(),
|
||||||
|
"owner_session_id": result.owner_session_id,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.outcome == "no_safe_work":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": OUTCOME_NO_SAFE,
|
||||||
|
"apply": True,
|
||||||
|
"role": role_norm,
|
||||||
|
"session_id": session_id,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"selected": selection,
|
||||||
|
"expected_role_next": expected_role,
|
||||||
|
"reasons": [result.reason or "no_safe_work"],
|
||||||
|
"skipped": [s.as_dict() for s in skipped],
|
||||||
|
"terminal_pr": terminal_pr,
|
||||||
|
"assignment": result.as_dict(),
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# assigned
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": OUTCOME_ASSIGNED,
|
||||||
|
"apply": True,
|
||||||
|
"role": role_norm,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"username": username,
|
||||||
|
"session_id": session_id,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"selected": selection,
|
||||||
|
"expected_role_next": expected_role,
|
||||||
|
"reasons": [
|
||||||
|
selection["reason_selected"],
|
||||||
|
result.reason or "atomic assign+lease created",
|
||||||
|
],
|
||||||
|
"skipped": [s.as_dict() for s in skipped],
|
||||||
|
"terminal_pr": terminal_pr,
|
||||||
|
"assignment": result.as_dict(),
|
||||||
|
"lease_proof": {
|
||||||
|
"assignment_id": result.assignment_id,
|
||||||
|
"lease_id": result.lease_id,
|
||||||
|
"expires_at": result.expires_at,
|
||||||
|
"expected_head_sha": result.expected_head_sha,
|
||||||
|
"allowed_actions": list(result.allowed_actions),
|
||||||
|
"forbidden_actions": list(result.forbidden_actions),
|
||||||
|
"source": "control_plane_db.assign_and_lease",
|
||||||
|
},
|
||||||
|
"next_valid_command": _next_command(role_norm, selected),
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
"downstream_note": (
|
||||||
|
"#612 incident bridge remains downstream of #600; "
|
||||||
|
"allocator never assigns raw monitoring incidents"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _next_command(role: str, c: WorkCandidate) -> str:
|
||||||
|
if role == ROLE_AUTHOR and c.kind == "issue":
|
||||||
|
return f"implement issue #{c.number} under a branches/ worktree; open PR when ready"
|
||||||
|
if role == ROLE_AUTHOR and c.kind == "pr":
|
||||||
|
return (
|
||||||
|
f"address REQUEST_CHANGES on PR #{c.number} at head "
|
||||||
|
f"{(c.head_sha or '')[:12]} and push fixes"
|
||||||
|
)
|
||||||
|
if role == ROLE_REVIEWER:
|
||||||
|
return (
|
||||||
|
f"review PR #{c.number} pinned at head {(c.head_sha or '')[:12]} "
|
||||||
|
"via full reviewer workflow"
|
||||||
|
)
|
||||||
|
if role == ROLE_MERGER:
|
||||||
|
return (
|
||||||
|
f"merge PR #{c.number} only with explicit operator MERGE "
|
||||||
|
f"authorization at head {(c.head_sha or '')[:12]}"
|
||||||
|
)
|
||||||
|
if role == ROLE_RECONCILER:
|
||||||
|
return f"diagnose contested state for {c.kind}#{c.number}"
|
||||||
|
return f"proceed on {c.kind}#{c.number} under role {role}"
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
||||||
|
"""Build a WorkCandidate from a plain dict (tests / MCP inventory)."""
|
||||||
|
return WorkCandidate(
|
||||||
|
kind=str(data.get("kind") or "issue"),
|
||||||
|
number=int(data["number"]),
|
||||||
|
state=str(data.get("state") or "open"),
|
||||||
|
labels=tuple(data.get("labels") or ()),
|
||||||
|
title=str(data.get("title") or ""),
|
||||||
|
priority=int(data.get("priority") or 0),
|
||||||
|
head_sha=data.get("head_sha"),
|
||||||
|
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||||
|
approval_on_current_head=bool(data.get("approval_on_current_head")),
|
||||||
|
approval_stale=bool(data.get("approval_stale")),
|
||||||
|
approval_contaminated=bool(data.get("approval_contaminated")),
|
||||||
|
mergeable=bool(data.get("mergeable")),
|
||||||
|
blocked=bool(data.get("blocked")),
|
||||||
|
dependency_unmet=bool(data.get("dependency_unmet")),
|
||||||
|
dependency_reason=data.get("dependency_reason"),
|
||||||
|
already_claimed_elsewhere=bool(data.get("already_claimed_elsewhere")),
|
||||||
|
)
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
"""Common anti-stomp preflight for every MCP mutation tool (#604).
|
||||||
|
|
||||||
|
Even with leases, mutation tools need a **shared** preflight that prevents
|
||||||
|
stale sessions, wrong worktrees, wrong repos, old prompts, terminal locks,
|
||||||
|
foreign leases, contaminated approvals, and root-checkout mutations from
|
||||||
|
slipping through.
|
||||||
|
|
||||||
|
This module is the pure assessment core. Callers (MCP mutation entrypoints)
|
||||||
|
gather live facts and pass them in — nothing here performs git, network, or
|
||||||
|
durable-state I/O. Existing happy-path guards remain authoritative; this
|
||||||
|
module composes them into one typed, fail-closed result.
|
||||||
|
|
||||||
|
Required checks (issue #604):
|
||||||
|
|
||||||
|
* repo/org verification
|
||||||
|
* profile/role verification
|
||||||
|
* root checkout clean and not used for mutation (when role requires branches/)
|
||||||
|
* worktree under branches when required
|
||||||
|
* active lease ownership (when lease is required for the mutation)
|
||||||
|
* terminal lock status (when applicable)
|
||||||
|
* expected head SHA (when pinned)
|
||||||
|
* stale runtime status
|
||||||
|
* workflow hash (when a workflow load is required)
|
||||||
|
* source contamination status
|
||||||
|
* no manual state/mtime/source-file bypass
|
||||||
|
|
||||||
|
Failure returns a typed blocker and an exact next action. There is **no**
|
||||||
|
agent-facing bypass flag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import author_mutation_worktree
|
||||||
|
import create_issue_bootstrap
|
||||||
|
import master_parity_gate
|
||||||
|
import remote_repo_guard
|
||||||
|
import root_checkout_guard
|
||||||
|
import stable_branch_push_guard
|
||||||
|
|
||||||
|
# ── public constants ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Typed blocker kinds returned in the structured result.
|
||||||
|
BLOCKER_WRONG_REPO = "wrong_repo"
|
||||||
|
BLOCKER_WRONG_ROLE = "wrong_role"
|
||||||
|
BLOCKER_ROOT_CHECKOUT = "root_checkout_mutation"
|
||||||
|
BLOCKER_WRONG_WORKTREE = "wrong_worktree"
|
||||||
|
BLOCKER_FOREIGN_LEASE = "foreign_lease"
|
||||||
|
BLOCKER_TERMINAL_LOCK = "terminal_lock"
|
||||||
|
BLOCKER_HEAD_SHA = "head_sha_mismatch"
|
||||||
|
BLOCKER_STALE_RUNTIME = "stale_runtime"
|
||||||
|
BLOCKER_WORKFLOW_HASH = "workflow_hash"
|
||||||
|
BLOCKER_SOURCE_CONTAMINATION = "source_contamination"
|
||||||
|
BLOCKER_MANUAL_BYPASS = "manual_bypass"
|
||||||
|
|
||||||
|
BLOCKER_KINDS = frozenset({
|
||||||
|
BLOCKER_WRONG_REPO,
|
||||||
|
BLOCKER_WRONG_ROLE,
|
||||||
|
BLOCKER_ROOT_CHECKOUT,
|
||||||
|
BLOCKER_WRONG_WORKTREE,
|
||||||
|
BLOCKER_FOREIGN_LEASE,
|
||||||
|
BLOCKER_TERMINAL_LOCK,
|
||||||
|
BLOCKER_HEAD_SHA,
|
||||||
|
BLOCKER_STALE_RUNTIME,
|
||||||
|
BLOCKER_WORKFLOW_HASH,
|
||||||
|
BLOCKER_SOURCE_CONTAMINATION,
|
||||||
|
BLOCKER_MANUAL_BYPASS,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mutation tasks that must invoke the shared anti-stomp preflight before
|
||||||
|
# acting (issue #604 AC1). Every member must appear as a live task= kwarg
|
||||||
|
# to verify_preflight_purity / _run_anti_stomp_preflight (or the review
|
||||||
|
# anti_task map) in gitea_mcp_server.py. Inventory↔wiring tests fail if
|
||||||
|
# a declared task is not wired.
|
||||||
|
MUTATION_TASKS = frozenset({
|
||||||
|
"create_issue",
|
||||||
|
"comment_issue",
|
||||||
|
"close_issue",
|
||||||
|
"mark_issue",
|
||||||
|
"lock_issue",
|
||||||
|
"set_issue_labels",
|
||||||
|
"create_label",
|
||||||
|
"create_pr",
|
||||||
|
"close_pr",
|
||||||
|
"edit_pr",
|
||||||
|
"commit_files",
|
||||||
|
"gitea_commit_files",
|
||||||
|
"delete_branch",
|
||||||
|
"cleanup_merged_pr_branch",
|
||||||
|
"cleanup_stale_claims",
|
||||||
|
"reconcile_merged_cleanups",
|
||||||
|
"reconcile_already_landed_pr",
|
||||||
|
"reconcile_close_superseded_pr",
|
||||||
|
"post_heartbeat",
|
||||||
|
"acquire_reviewer_pr_lease",
|
||||||
|
"gitea_acquire_reviewer_pr_lease",
|
||||||
|
"adopt_merger_pr_lease",
|
||||||
|
"review_pr",
|
||||||
|
"submit_pr_review",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"merge_pr",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Intentionally excluded from MUTATION_TASKS. Each has a dedicated
|
||||||
|
# fail-closed gate that preserves decision-lock ownership, workflow-hash,
|
||||||
|
# head-SHA, and repository consistency. Do not re-add without either
|
||||||
|
# wiring shared preflight or updating this rationale (#604 Blocker B).
|
||||||
|
DEDICATED_GATE_MUTATIONS: dict[str, str] = {
|
||||||
|
"mark_final_review_decision": (
|
||||||
|
"Local review-decision lock only. Enforced by session lock ownership, "
|
||||||
|
"workflow-hash, expected head SHA, PR work lease, eligibility, and "
|
||||||
|
"terminal-lock gates. No Gitea review POST; shared anti-stomp would "
|
||||||
|
"duplicate without side-effect-ordering value."
|
||||||
|
),
|
||||||
|
"save_review_draft": (
|
||||||
|
"Local session-state draft save with live PR head/base consistency "
|
||||||
|
"checks. Not a Gitea review/merge mutation; dedicated head-SHA and "
|
||||||
|
"worktree resolution gates apply."
|
||||||
|
),
|
||||||
|
"resume_review_draft": (
|
||||||
|
"Live-state resume with terminal lock, lease ownership, head/base SHA, "
|
||||||
|
"and parity checks. When submit=True, delegates to gitea_submit_pr_review "
|
||||||
|
"which runs shared anti-stomp before the Gitea review POST."
|
||||||
|
),
|
||||||
|
"cleanup_stale_review_decision_lock": (
|
||||||
|
"Moot-lock cleanup with identity match, live PR merged/closed proof, "
|
||||||
|
"and reviewer capability gate (#594). Distinct from shared anti-stomp."
|
||||||
|
),
|
||||||
|
"gitea_cleanup_stale_review_decision_lock": (
|
||||||
|
"Alias of cleanup_stale_review_decision_lock; dedicated #594 path."
|
||||||
|
),
|
||||||
|
"heartbeat_reviewer_pr_lease": (
|
||||||
|
"Lease heartbeat posts via verify_preflight_purity(task='review_pr') "
|
||||||
|
"plus in-session lease ownership checks; not a separate mutation class."
|
||||||
|
),
|
||||||
|
"release_reviewer_pr_lease": (
|
||||||
|
"Lease release uses workspace binding + ownership checks; posts only "
|
||||||
|
"when the session owns the active lease."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Roles that must mutate from a branches/ worktree (not the control checkout).
|
||||||
|
_BRANCHES_REQUIRED_ROLES = frozenset({"author"})
|
||||||
|
|
||||||
|
# Reviewer/merger mutations that require an owned lease + head pinning when
|
||||||
|
# the caller supplies those facts.
|
||||||
|
_LEASE_AWARE_TASKS = frozenset({
|
||||||
|
"review_pr",
|
||||||
|
"submit_pr_review",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"merge_pr",
|
||||||
|
"acquire_reviewer_pr_lease",
|
||||||
|
"gitea_acquire_reviewer_pr_lease",
|
||||||
|
"adopt_merger_pr_lease",
|
||||||
|
})
|
||||||
|
|
||||||
|
_NEXT_ACTIONS: dict[str, str] = {
|
||||||
|
BLOCKER_WRONG_REPO: (
|
||||||
|
"Pass explicit org= and repo= matching the local git remote "
|
||||||
|
"(e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools) and retry."
|
||||||
|
),
|
||||||
|
BLOCKER_WRONG_ROLE: (
|
||||||
|
"Call gitea_resolve_task_capability, switch to the required "
|
||||||
|
"namespace/profile, re-verify with gitea_whoami, then retry."
|
||||||
|
),
|
||||||
|
BLOCKER_ROOT_CHECKOUT: (
|
||||||
|
"Leave the root control checkout on clean master; create or switch "
|
||||||
|
"to a session-owned worktree under branches/ and retry the mutation "
|
||||||
|
"with worktree_path set."
|
||||||
|
),
|
||||||
|
BLOCKER_WRONG_WORKTREE: (
|
||||||
|
"Create or bind a session-owned worktree under branches/, set "
|
||||||
|
"worktree_path (or GITEA_ACTIVE_WORKTREE), and retry."
|
||||||
|
),
|
||||||
|
BLOCKER_FOREIGN_LEASE: (
|
||||||
|
"Stop. Do not stomp a foreign lease. Wait for expiry, request "
|
||||||
|
"takeover through the sanctioned path, or hand off to the owner."
|
||||||
|
),
|
||||||
|
BLOCKER_TERMINAL_LOCK: (
|
||||||
|
"Stop. A terminal review-decision lock is active for this head. "
|
||||||
|
"Do not re-approve/merge; follow the #332/#620 recovery path."
|
||||||
|
),
|
||||||
|
BLOCKER_HEAD_SHA: (
|
||||||
|
"Re-fetch the live PR head with gitea_view_pr, re-pin "
|
||||||
|
"expected_head_sha to the current head, re-validate, then retry."
|
||||||
|
),
|
||||||
|
BLOCKER_STALE_RUNTIME: (
|
||||||
|
"Restart the Gitea MCP server so it reloads master's capability "
|
||||||
|
"gates, re-run gitea_whoami + gitea_resolve_task_capability, then retry."
|
||||||
|
),
|
||||||
|
BLOCKER_WORKFLOW_HASH: (
|
||||||
|
"Reload the canonical workflow via gitea_load_review_workflow, then "
|
||||||
|
"rerun the full review-merge workflow from inventory (no approve/merge replay)."
|
||||||
|
),
|
||||||
|
BLOCKER_SOURCE_CONTAMINATION: (
|
||||||
|
"Stop. Session is source-contaminated. Hand off to a reconciler for "
|
||||||
|
"audit/clear; do not clear markers by deleting session-state files."
|
||||||
|
),
|
||||||
|
BLOCKER_MANUAL_BYPASS: (
|
||||||
|
"Stop. Manual state/mtime/source-file bypass is forbidden. Use "
|
||||||
|
"sanctioned MCP tools only; never delete or rewrite session-state "
|
||||||
|
"or lock files by hand."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_mutation_task(task: str | None) -> bool:
|
||||||
|
"""True when *task* is in the #604 mutation set (normalized)."""
|
||||||
|
name = (task or "").strip().lower().removeprefix("gitea_")
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
if name in MUTATION_TASKS:
|
||||||
|
return True
|
||||||
|
# Accept gitea_ prefixed membership for aliases already in the set.
|
||||||
|
return f"gitea_{name}" in MUTATION_TASKS
|
||||||
|
|
||||||
|
|
||||||
|
def roles_compatible(active_role: str | None, required_role: str | None) -> bool:
|
||||||
|
"""Whether *active_role* may perform a task that maps to *required_role*.
|
||||||
|
|
||||||
|
Exact match always passes. Reconcilers may run author-class mutations
|
||||||
|
(comment/close/cleanup) that the capability map stamps as ``author`` —
|
||||||
|
matching the long-standing reconciler exemption for control-checkout
|
||||||
|
work. No other cross-role substitutions are allowed.
|
||||||
|
|
||||||
|
Capability-authorized multi-role tasks (e.g. reviewer holding
|
||||||
|
``gitea.issue.comment`` for ``comment_issue``) are handled by
|
||||||
|
:func:`authorization_compatible`, not by expanding this role matrix.
|
||||||
|
"""
|
||||||
|
active = (active_role or "").strip().lower()
|
||||||
|
required = (required_role or "").strip().lower()
|
||||||
|
if not active or not required:
|
||||||
|
return True
|
||||||
|
if active == required:
|
||||||
|
return True
|
||||||
|
if active == "reconciler" and required in {"author", "reconciler"}:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def authorization_compatible(
|
||||||
|
active_role: str | None,
|
||||||
|
required_role: str | None,
|
||||||
|
*,
|
||||||
|
required_permission: str | None = None,
|
||||||
|
allowed_operations: Any = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Whether the active session may run a task given role *and* capability.
|
||||||
|
|
||||||
|
Order:
|
||||||
|
|
||||||
|
1. :func:`roles_compatible` (exact role or narrow reconciler→author).
|
||||||
|
2. Capability possession: when *required_permission* is non-empty and
|
||||||
|
present in *allowed_operations*, allow even if the nominal task role
|
||||||
|
differs (e.g. reviewer/merger with ``gitea.issue.comment`` on
|
||||||
|
``comment_issue`` / ``mark_issue`` / ``set_issue_labels`` /
|
||||||
|
``lock_issue``).
|
||||||
|
|
||||||
|
Fail closed otherwise. This is **not** a broad reviewer→author or
|
||||||
|
merger→author role rewrite: without the specific permission the check
|
||||||
|
still denies. Missing *allowed_operations* when a permission is required
|
||||||
|
also fails closed (callers must supply the live profile op list).
|
||||||
|
"""
|
||||||
|
if roles_compatible(active_role, required_role):
|
||||||
|
return True
|
||||||
|
perm = (required_permission or "").strip()
|
||||||
|
if not perm:
|
||||||
|
return False
|
||||||
|
if allowed_operations is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ops = {str(o).strip() for o in allowed_operations if o is not None}
|
||||||
|
except TypeError:
|
||||||
|
return False
|
||||||
|
return perm in ops
|
||||||
|
|
||||||
|
|
||||||
|
def _blocker(
|
||||||
|
kind: str,
|
||||||
|
reasons: list[str],
|
||||||
|
*,
|
||||||
|
exact_next_action: str | None = None,
|
||||||
|
detail: dict | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if kind not in BLOCKER_KINDS:
|
||||||
|
raise ValueError(f"unknown anti-stomp blocker kind: {kind!r}")
|
||||||
|
return {
|
||||||
|
"kind": kind,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"exact_next_action": exact_next_action or _NEXT_ACTIONS[kind],
|
||||||
|
"detail": dict(detail or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_result(checks: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"block": False,
|
||||||
|
"blockers": [],
|
||||||
|
"reasons": [],
|
||||||
|
"exact_next_action": "proceed",
|
||||||
|
"blocker_kind": None,
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_result(
|
||||||
|
blockers: list[dict[str, Any]],
|
||||||
|
checks: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
primary = blockers[0]
|
||||||
|
all_reasons: list[str] = []
|
||||||
|
for b in blockers:
|
||||||
|
for r in b.get("reasons") or []:
|
||||||
|
if r not in all_reasons:
|
||||||
|
all_reasons.append(r)
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"block": True,
|
||||||
|
"blockers": blockers,
|
||||||
|
"reasons": all_reasons,
|
||||||
|
"exact_next_action": primary["exact_next_action"],
|
||||||
|
"blocker_kind": primary["kind"],
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_anti_stomp_preflight(
|
||||||
|
*,
|
||||||
|
task: str | None = None,
|
||||||
|
# repo/org
|
||||||
|
remote: str | None = None,
|
||||||
|
resolved_org: str | None = None,
|
||||||
|
resolved_repo: str | None = None,
|
||||||
|
local_remote_url: str | None = None,
|
||||||
|
org_explicit: bool = False,
|
||||||
|
repo_explicit: bool = False,
|
||||||
|
check_repo: bool = True,
|
||||||
|
# profile/role + capability
|
||||||
|
profile_name: str | None = None,
|
||||||
|
profile_role: str | None = None,
|
||||||
|
required_role: str | None = None,
|
||||||
|
required_permission: str | None = None,
|
||||||
|
allowed_operations: Any = None,
|
||||||
|
check_role: bool = True,
|
||||||
|
# root checkout + worktree
|
||||||
|
workspace_path: str | None = None,
|
||||||
|
project_root: str | None = None,
|
||||||
|
current_branch: str | None = None,
|
||||||
|
root_head_sha: str | None = None,
|
||||||
|
root_porcelain: str | None = None,
|
||||||
|
remote_master_sha: str | None = None,
|
||||||
|
check_root_checkout: bool = True,
|
||||||
|
check_worktree: bool = True,
|
||||||
|
create_issue_bootstrap_assessment: dict[str, Any] | None = None,
|
||||||
|
# stale runtime (master parity)
|
||||||
|
startup_head: str | None = None,
|
||||||
|
current_code_head: str | None = None,
|
||||||
|
check_stale_runtime: bool = True,
|
||||||
|
# lease ownership
|
||||||
|
lease_required: bool = False,
|
||||||
|
foreign_lease: bool | None = None,
|
||||||
|
lease_owner_session: str | None = None,
|
||||||
|
active_session_id: str | None = None,
|
||||||
|
lease_owner_identity: str | None = None,
|
||||||
|
active_identity: str | None = None,
|
||||||
|
lease_reasons: list[str] | None = None,
|
||||||
|
# terminal lock
|
||||||
|
terminal_lock_blocks: bool | None = None,
|
||||||
|
terminal_lock_reasons: list[str] | None = None,
|
||||||
|
# expected head SHA
|
||||||
|
require_head_sha: bool = False,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
live_head_sha: str | None = None,
|
||||||
|
# workflow hash
|
||||||
|
workflow_hash_valid: bool | None = None,
|
||||||
|
workflow_hash_reasons: list[str] | None = None,
|
||||||
|
# source contamination (stable-branch push / approval contamination)
|
||||||
|
source_contaminated: bool | None = None,
|
||||||
|
contamination_reasons: list[str] | None = None,
|
||||||
|
# manual bypass
|
||||||
|
manual_bypass_attempted: bool = False,
|
||||||
|
manual_bypass_reasons: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail-closed common anti-stomp assessment (pure).
|
||||||
|
|
||||||
|
Only checks for which facts are supplied (or which are required by the
|
||||||
|
mutation category) are evaluated. Unsupplied optional facts are recorded
|
||||||
|
as ``skipped`` so callers can see coverage without inventing defaults.
|
||||||
|
|
||||||
|
Returns a structured dict with ``allowed``/``block``, typed ``blockers``,
|
||||||
|
aggregated ``reasons``, ``exact_next_action``, and per-check ``checks``.
|
||||||
|
"""
|
||||||
|
task_name = (task or "").strip()
|
||||||
|
role = (profile_role or "").strip().lower() or None
|
||||||
|
req_role = (required_role or "").strip().lower() or None
|
||||||
|
req_perm = (required_permission or "").strip() or None
|
||||||
|
checks: dict[str, Any] = {
|
||||||
|
"task": task_name or None,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"profile_role": role,
|
||||||
|
"required_role": req_role,
|
||||||
|
"required_permission": req_perm,
|
||||||
|
}
|
||||||
|
blockers: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# ── manual bypass (always first; never skippable when attempted) ─────────
|
||||||
|
if manual_bypass_attempted:
|
||||||
|
reasons = list(manual_bypass_reasons or [
|
||||||
|
"manual state/mtime/source-file bypass attempt detected (fail closed)"
|
||||||
|
])
|
||||||
|
blockers.append(_blocker(BLOCKER_MANUAL_BYPASS, reasons))
|
||||||
|
checks["manual_bypass"] = {"block": True, "reasons": reasons}
|
||||||
|
else:
|
||||||
|
checks["manual_bypass"] = {"block": False, "skipped": False}
|
||||||
|
|
||||||
|
# ── repo/org ─────────────────────────────────────────────────────────────
|
||||||
|
if check_repo and resolved_org is not None and resolved_repo is not None:
|
||||||
|
repo_assessment = remote_repo_guard.assess_remote_repo_match(
|
||||||
|
remote=remote or "",
|
||||||
|
resolved_org=resolved_org,
|
||||||
|
resolved_repo=resolved_repo,
|
||||||
|
local_remote_url=local_remote_url,
|
||||||
|
org_explicit=org_explicit,
|
||||||
|
repo_explicit=repo_explicit,
|
||||||
|
)
|
||||||
|
checks["repo"] = {
|
||||||
|
"block": bool(repo_assessment.get("block")),
|
||||||
|
"reasons": list(repo_assessment.get("reasons") or []),
|
||||||
|
"resolved_org": resolved_org,
|
||||||
|
"resolved_repo": resolved_repo,
|
||||||
|
}
|
||||||
|
if repo_assessment.get("block"):
|
||||||
|
blockers.append(
|
||||||
|
_blocker(
|
||||||
|
BLOCKER_WRONG_REPO,
|
||||||
|
list(repo_assessment.get("reasons") or ["repo/org mismatch"]),
|
||||||
|
detail={
|
||||||
|
"resolved_org": resolved_org,
|
||||||
|
"resolved_repo": resolved_repo,
|
||||||
|
"local_remote_url": local_remote_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["repo"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── profile/role + capability ────────────────────────────────────────────
|
||||||
|
# Role match OR possession of the task's required permission (Blocker A).
|
||||||
|
# Reviewer/merger may run issue-comment-class tasks when they hold
|
||||||
|
# gitea.issue.comment; unauthorized escalation without the permission
|
||||||
|
# still fails closed.
|
||||||
|
if check_role and req_role and role:
|
||||||
|
authorized = authorization_compatible(
|
||||||
|
role,
|
||||||
|
req_role,
|
||||||
|
required_permission=req_perm,
|
||||||
|
allowed_operations=allowed_operations,
|
||||||
|
)
|
||||||
|
if not authorized:
|
||||||
|
perm_clause = (
|
||||||
|
f", required_permission='{req_perm}'" if req_perm else ""
|
||||||
|
)
|
||||||
|
reasons = [
|
||||||
|
f"active profile role '{role}' is not authorized for task "
|
||||||
|
f"'{task_name or '(unknown)'}' (required_role='{req_role}'"
|
||||||
|
f"{perm_clause}; profile={profile_name or '(unknown)'})"
|
||||||
|
]
|
||||||
|
checks["role"] = {
|
||||||
|
"block": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
"required_permission": req_perm,
|
||||||
|
"capability_authorized": False,
|
||||||
|
}
|
||||||
|
blockers.append(
|
||||||
|
_blocker(
|
||||||
|
BLOCKER_WRONG_ROLE,
|
||||||
|
reasons,
|
||||||
|
detail={
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"profile_role": role,
|
||||||
|
"required_role": req_role,
|
||||||
|
"required_permission": req_perm,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["role"] = {
|
||||||
|
"block": False,
|
||||||
|
"reasons": [],
|
||||||
|
"required_permission": req_perm,
|
||||||
|
"capability_authorized": bool(
|
||||||
|
req_perm
|
||||||
|
and allowed_operations is not None
|
||||||
|
and req_perm in {
|
||||||
|
str(o).strip() for o in (allowed_operations or [])
|
||||||
|
if o is not None
|
||||||
|
}
|
||||||
|
and not roles_compatible(role, req_role)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
checks["role"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── stale runtime (master parity) ────────────────────────────────────────
|
||||||
|
if check_stale_runtime and (
|
||||||
|
startup_head is not None or current_code_head is not None
|
||||||
|
):
|
||||||
|
parity = master_parity_gate.assess_master_parity(
|
||||||
|
{"startup_head": startup_head},
|
||||||
|
current_code_head,
|
||||||
|
)
|
||||||
|
stale_reasons = master_parity_gate.parity_block_reasons(parity)
|
||||||
|
checks["stale_runtime"] = {
|
||||||
|
"block": bool(stale_reasons),
|
||||||
|
"reasons": list(stale_reasons),
|
||||||
|
"startup_head": startup_head,
|
||||||
|
"current_code_head": current_code_head,
|
||||||
|
"stale": bool(parity.get("stale")),
|
||||||
|
}
|
||||||
|
if stale_reasons:
|
||||||
|
blockers.append(
|
||||||
|
_blocker(
|
||||||
|
BLOCKER_STALE_RUNTIME,
|
||||||
|
list(stale_reasons),
|
||||||
|
detail={
|
||||||
|
"startup_head": startup_head,
|
||||||
|
"current_code_head": current_code_head,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["stale_runtime"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── root checkout ────────────────────────────────────────────────────────
|
||||||
|
if (
|
||||||
|
check_root_checkout
|
||||||
|
and workspace_path is not None
|
||||||
|
and project_root is not None
|
||||||
|
and root_porcelain is not None
|
||||||
|
):
|
||||||
|
root_assessment = root_checkout_guard.assess_root_checkout_guard(
|
||||||
|
workspace_path=workspace_path,
|
||||||
|
canonical_repo_root=project_root,
|
||||||
|
current_branch=current_branch,
|
||||||
|
head_sha=root_head_sha,
|
||||||
|
porcelain_status=root_porcelain,
|
||||||
|
remote_master_sha=remote_master_sha,
|
||||||
|
resolved_role=req_role or role,
|
||||||
|
actual_role=role,
|
||||||
|
)
|
||||||
|
checks["root_checkout"] = {
|
||||||
|
"block": bool(root_assessment.get("block")),
|
||||||
|
"reasons": list(root_assessment.get("reasons") or []),
|
||||||
|
}
|
||||||
|
if root_assessment.get("block"):
|
||||||
|
blockers.append(
|
||||||
|
_blocker(
|
||||||
|
BLOCKER_ROOT_CHECKOUT,
|
||||||
|
list(root_assessment.get("reasons") or [
|
||||||
|
"control checkout is not clean master"
|
||||||
|
]),
|
||||||
|
detail={
|
||||||
|
"workspace_path": workspace_path,
|
||||||
|
"project_root": project_root,
|
||||||
|
"current_branch": current_branch,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["root_checkout"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── worktree under branches (author) ─────────────────────────────────────
|
||||||
|
worktree_role = role or req_role
|
||||||
|
if (
|
||||||
|
check_worktree
|
||||||
|
and workspace_path is not None
|
||||||
|
and project_root is not None
|
||||||
|
and worktree_role in _BRANCHES_REQUIRED_ROLES
|
||||||
|
):
|
||||||
|
wt = author_mutation_worktree.assess_author_mutation_worktree(
|
||||||
|
workspace_path=workspace_path,
|
||||||
|
project_root=project_root,
|
||||||
|
current_branch=current_branch,
|
||||||
|
)
|
||||||
|
# #757: the #274 guard consults the server-derived create_issue
|
||||||
|
# bootstrap before blocking the canonical control checkout. Route this
|
||||||
|
# guard's decision through the *same* predicate on the *same*
|
||||||
|
# assessment so the two cannot disagree about identical evidence.
|
||||||
|
# Only the wrong-worktree verdict is waived; every other check in this
|
||||||
|
# assessment (root checkout, repo, role, stale runtime, lease, ...) is
|
||||||
|
# evaluated independently and still applies.
|
||||||
|
bootstrap_waived = wt.get("block") and (
|
||||||
|
create_issue_bootstrap.bootstrap_permits_control_checkout(
|
||||||
|
create_issue_bootstrap_assessment,
|
||||||
|
task=task_name,
|
||||||
|
workspace_path=workspace_path,
|
||||||
|
canonical_repo_root=project_root,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
checks["worktree"] = {
|
||||||
|
"block": bool(wt.get("block")) and not bootstrap_waived,
|
||||||
|
"reasons": list(wt.get("reasons") or []),
|
||||||
|
"under_branches": wt.get("under_branches"),
|
||||||
|
"create_issue_bootstrap_waived": bool(bootstrap_waived),
|
||||||
|
}
|
||||||
|
if wt.get("block") and not bootstrap_waived:
|
||||||
|
blockers.append(
|
||||||
|
_blocker(
|
||||||
|
BLOCKER_WRONG_WORKTREE,
|
||||||
|
list(wt.get("reasons") or [
|
||||||
|
"mutation worktree is not under branches/"
|
||||||
|
]),
|
||||||
|
detail={
|
||||||
|
"workspace_path": workspace_path,
|
||||||
|
"project_root": project_root,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["worktree"] = {
|
||||||
|
"block": False,
|
||||||
|
"skipped": worktree_role not in _BRANCHES_REQUIRED_ROLES
|
||||||
|
or workspace_path is None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── foreign lease ────────────────────────────────────────────────────────
|
||||||
|
lease_needed = lease_required or (
|
||||||
|
task_name.removeprefix("gitea_") in {
|
||||||
|
t.removeprefix("gitea_") for t in _LEASE_AWARE_TASKS
|
||||||
|
}
|
||||||
|
and foreign_lease is not None
|
||||||
|
)
|
||||||
|
if lease_needed or foreign_lease is True:
|
||||||
|
is_foreign = bool(foreign_lease)
|
||||||
|
if foreign_lease is None and lease_required:
|
||||||
|
# Ownership must be proven when lease_required; missing ownership
|
||||||
|
# facts fail closed.
|
||||||
|
owner_ok = (
|
||||||
|
(not lease_owner_session and not active_session_id)
|
||||||
|
or (
|
||||||
|
lease_owner_session
|
||||||
|
and active_session_id
|
||||||
|
and lease_owner_session == active_session_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
identity_ok = (
|
||||||
|
(not lease_owner_identity and not active_identity)
|
||||||
|
or (
|
||||||
|
lease_owner_identity
|
||||||
|
and active_identity
|
||||||
|
and lease_owner_identity == active_identity
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not owner_ok or not identity_ok:
|
||||||
|
is_foreign = True
|
||||||
|
reasons = list(lease_reasons or [])
|
||||||
|
if is_foreign and not reasons:
|
||||||
|
reasons = [
|
||||||
|
"active lease is owned by a foreign session or identity "
|
||||||
|
"(anti-stomp fail closed)"
|
||||||
|
]
|
||||||
|
checks["lease"] = {
|
||||||
|
"block": is_foreign,
|
||||||
|
"reasons": reasons if is_foreign else [],
|
||||||
|
"lease_owner_session": lease_owner_session,
|
||||||
|
"active_session_id": active_session_id,
|
||||||
|
}
|
||||||
|
if is_foreign:
|
||||||
|
blockers.append(
|
||||||
|
_blocker(BLOCKER_FOREIGN_LEASE, reasons)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["lease"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── terminal lock ────────────────────────────────────────────────────────
|
||||||
|
if terminal_lock_blocks is not None:
|
||||||
|
reasons = list(terminal_lock_reasons or [])
|
||||||
|
if terminal_lock_blocks and not reasons:
|
||||||
|
reasons = [
|
||||||
|
"terminal review-decision lock is active for this head "
|
||||||
|
"(anti-stomp fail closed)"
|
||||||
|
]
|
||||||
|
checks["terminal_lock"] = {
|
||||||
|
"block": bool(terminal_lock_blocks),
|
||||||
|
"reasons": reasons if terminal_lock_blocks else [],
|
||||||
|
}
|
||||||
|
if terminal_lock_blocks:
|
||||||
|
blockers.append(_blocker(BLOCKER_TERMINAL_LOCK, reasons))
|
||||||
|
else:
|
||||||
|
checks["terminal_lock"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── expected head SHA ────────────────────────────────────────────────────
|
||||||
|
if require_head_sha or (
|
||||||
|
expected_head_sha is not None and live_head_sha is not None
|
||||||
|
):
|
||||||
|
exp = (expected_head_sha or "").strip().lower()
|
||||||
|
live = (live_head_sha or "").strip().lower()
|
||||||
|
mismatch = False
|
||||||
|
reasons: list[str] = []
|
||||||
|
if require_head_sha and not exp:
|
||||||
|
mismatch = True
|
||||||
|
reasons.append(
|
||||||
|
"expected_head_sha is required before this mutation "
|
||||||
|
"(anti-stomp fail closed)"
|
||||||
|
)
|
||||||
|
elif exp and live and exp != live:
|
||||||
|
mismatch = True
|
||||||
|
reasons.append(
|
||||||
|
f"expected_head_sha '{exp}' does not match live PR head "
|
||||||
|
f"'{live}' (anti-stomp fail closed; stale prompt data)"
|
||||||
|
)
|
||||||
|
elif require_head_sha and exp and not live:
|
||||||
|
mismatch = True
|
||||||
|
reasons.append(
|
||||||
|
"live PR head SHA could not be determined to corroborate "
|
||||||
|
"expected_head_sha (anti-stomp fail closed)"
|
||||||
|
)
|
||||||
|
checks["head_sha"] = {
|
||||||
|
"block": mismatch,
|
||||||
|
"reasons": reasons,
|
||||||
|
"expected_head_sha": expected_head_sha,
|
||||||
|
"live_head_sha": live_head_sha,
|
||||||
|
}
|
||||||
|
if mismatch:
|
||||||
|
blockers.append(_blocker(BLOCKER_HEAD_SHA, reasons))
|
||||||
|
else:
|
||||||
|
checks["head_sha"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── workflow hash ────────────────────────────────────────────────────────
|
||||||
|
if workflow_hash_valid is not None:
|
||||||
|
reasons = list(workflow_hash_reasons or [])
|
||||||
|
if not workflow_hash_valid and not reasons:
|
||||||
|
reasons = [
|
||||||
|
"workflow load proof missing or hash stale "
|
||||||
|
"(anti-stomp fail closed)"
|
||||||
|
]
|
||||||
|
checks["workflow_hash"] = {
|
||||||
|
"block": not bool(workflow_hash_valid),
|
||||||
|
"reasons": reasons if not workflow_hash_valid else [],
|
||||||
|
}
|
||||||
|
if not workflow_hash_valid:
|
||||||
|
blockers.append(_blocker(BLOCKER_WORKFLOW_HASH, reasons))
|
||||||
|
else:
|
||||||
|
checks["workflow_hash"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── source contamination ─────────────────────────────────────────────────
|
||||||
|
if source_contaminated is not None:
|
||||||
|
reasons = list(contamination_reasons or [])
|
||||||
|
if source_contaminated and not reasons:
|
||||||
|
reasons = [
|
||||||
|
"session is source-contaminated (stable-branch push or "
|
||||||
|
"contaminated approval path); anti-stomp fail closed"
|
||||||
|
]
|
||||||
|
checks["source_contamination"] = {
|
||||||
|
"block": bool(source_contaminated),
|
||||||
|
"reasons": reasons if source_contaminated else [],
|
||||||
|
}
|
||||||
|
if source_contaminated:
|
||||||
|
blockers.append(
|
||||||
|
_blocker(BLOCKER_SOURCE_CONTAMINATION, reasons)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["source_contamination"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
if blockers:
|
||||||
|
return _blocked_result(blockers, checks)
|
||||||
|
return _allowed_result(checks)
|
||||||
|
|
||||||
|
|
||||||
|
def format_anti_stomp_error(assessment: dict[str, Any]) -> str:
|
||||||
|
"""Single RuntimeError message for MCP mutation gates."""
|
||||||
|
kind = assessment.get("blocker_kind") or "unknown"
|
||||||
|
reasons = "; ".join(
|
||||||
|
assessment.get("reasons")
|
||||||
|
or ["anti-stomp preflight blocked the mutation"]
|
||||||
|
)
|
||||||
|
next_action = assessment.get("exact_next_action") or "stop and diagnose"
|
||||||
|
return (
|
||||||
|
f"Anti-stomp preflight (#604) blocked mutation "
|
||||||
|
f"[{kind}]: {reasons}. "
|
||||||
|
f"exact_next_action: {next_action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def block_response(
|
||||||
|
assessment: dict[str, Any],
|
||||||
|
**extra_fields: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Structured tool-return payload for a blocked mutation."""
|
||||||
|
payload = {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"blocked": True,
|
||||||
|
"anti_stomp": True,
|
||||||
|
"blocker_kind": assessment.get("blocker_kind"),
|
||||||
|
"blockers": list(assessment.get("blockers") or []),
|
||||||
|
"reasons": list(assessment.get("reasons") or []),
|
||||||
|
"exact_next_action": assessment.get("exact_next_action"),
|
||||||
|
"checks": assessment.get("checks") or {},
|
||||||
|
}
|
||||||
|
payload.update(extra_fields)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def contamination_from_stable_marker(
|
||||||
|
marker: dict | None,
|
||||||
|
*,
|
||||||
|
task: str | None,
|
||||||
|
actual_role: str | None,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Derive source-contamination facts from a #671 durable marker."""
|
||||||
|
if not marker:
|
||||||
|
return False, []
|
||||||
|
gate = stable_branch_push_guard.assess_contamination_gate(
|
||||||
|
marker,
|
||||||
|
task=task,
|
||||||
|
actual_role=actual_role,
|
||||||
|
)
|
||||||
|
if gate.get("block"):
|
||||||
|
return True, list(gate.get("reasons") or [])
|
||||||
|
return False, []
|
||||||
+544
-3
@@ -1,7 +1,15 @@
|
|||||||
"""Branches-only author mutation worktree guard (#274).
|
"""Branches-only author mutation worktree guard (#274) with durable resolution (#618).
|
||||||
|
|
||||||
Author/coder mutations must run from a session-owned worktree under the
|
Author/coder mutations must run from a session-owned worktree under the
|
||||||
project's ``branches/`` directory, never from the stable control checkout.
|
project's ``branches/`` directory, never from the stable control checkout.
|
||||||
|
|
||||||
|
#618 durable resolution:
|
||||||
|
- Prefer an explicit validated ``worktree_path`` argument.
|
||||||
|
- Else derive the workspace from the active author issue lock's worktree.
|
||||||
|
- Env bindings (``GITEA_ACTIVE_WORKTREE`` / ``GITEA_AUTHOR_WORKTREE``) may bind
|
||||||
|
when present and valid.
|
||||||
|
- Author mutations never silently fall back to the control checkout or master.
|
||||||
|
- Missing configured bindings fail closed with a clear operator recovery action.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -15,6 +23,18 @@ AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
|||||||
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
||||||
# via namespace_workspace_binding (#510).
|
# via namespace_workspace_binding (#510).
|
||||||
|
|
||||||
|
BOUND_WORKTREE_MISSING = "bound_worktree_missing"
|
||||||
|
BOUND_WORKTREE_MISSING_MESSAGE = (
|
||||||
|
"bound worktree missing; operator must recreate or repoint the worktree "
|
||||||
|
"and reconnect"
|
||||||
|
)
|
||||||
|
OPERATOR_RECOVERY_RECREATE_REPOINT = (
|
||||||
|
"Recreate the worktree under branches/ (scripts/worktree-start or "
|
||||||
|
"git worktree add), set GITEA_AUTHOR_WORKTREE / GITEA_ACTIVE_WORKTREE "
|
||||||
|
"to that path (or pass worktree_path on mutation tools), keep the control "
|
||||||
|
"checkout clean on master, then reconnect the author MCP session and re-run."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_path(path: str) -> str:
|
def _normalize_path(path: str) -> str:
|
||||||
return (path or "").replace("\\", "/").rstrip("/")
|
return (path or "").replace("\\", "/").rstrip("/")
|
||||||
@@ -45,7 +65,11 @@ def resolve_mutation_workspace(
|
|||||||
active_worktree_env: str | None = None,
|
active_worktree_env: str | None = None,
|
||||||
author_worktree_env: str | None = None,
|
author_worktree_env: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Resolve the workspace path inspected before author mutations."""
|
"""Resolve the workspace path inspected before author mutations.
|
||||||
|
|
||||||
|
Legacy helper: returns the first non-empty candidate path. Prefer
|
||||||
|
:func:`resolve_durable_author_worktree` for mutation guards (#618).
|
||||||
|
"""
|
||||||
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
||||||
text = (candidate or "").strip()
|
text = (candidate or "").strip()
|
||||||
if text:
|
if text:
|
||||||
@@ -231,4 +255,521 @@ def format_author_mutation_worktree_error(assessment: dict) -> str:
|
|||||||
f"Branches-only mutation guard (#274): {reasons}. "
|
f"Branches-only mutation guard (#274): {reasons}. "
|
||||||
f"project root: {root}; workspace: {workspace}. "
|
f"project root: {root}; workspace: {workspace}. "
|
||||||
"Create a session-owned worktree under branches/ before mutating."
|
"Create a session-owned worktree under branches/ before mutating."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #618 durable author worktree resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _abs_real(path: str) -> str:
|
||||||
|
return os.path.realpath(os.path.abspath((path or "").strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def assess_path_traversal_safety(
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
canonical_repo_root: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Fail closed on traversal/symlink escapes outside the target repository.
|
||||||
|
|
||||||
|
Uses ``realpath`` so intermediate symlinks cannot walk outside
|
||||||
|
``canonical_repo_root``. Author mutation workspaces must also land under
|
||||||
|
``branches/`` of that root (enforced separately by the branches-only guard).
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
raw = (path or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"reasons": ["worktree path is empty (fail closed)"],
|
||||||
|
"workspace_path": None,
|
||||||
|
"canonical_repo_root": os.path.realpath(canonical_repo_root),
|
||||||
|
}
|
||||||
|
if "\x00" in raw:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"reasons": ["worktree path contains a null byte (fail closed)"],
|
||||||
|
"workspace_path": raw,
|
||||||
|
"canonical_repo_root": os.path.realpath(canonical_repo_root),
|
||||||
|
}
|
||||||
|
|
||||||
|
root = os.path.realpath(canonical_repo_root)
|
||||||
|
# Resolve without requiring existence first: abspath then realpath of parents.
|
||||||
|
abs_path = os.path.abspath(raw)
|
||||||
|
try:
|
||||||
|
real = os.path.realpath(abs_path)
|
||||||
|
except OSError as exc:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"reasons": [f"worktree path could not be resolved safely: {exc}"],
|
||||||
|
"workspace_path": abs_path,
|
||||||
|
"canonical_repo_root": root,
|
||||||
|
}
|
||||||
|
|
||||||
|
root_norm = _normalize_path(root)
|
||||||
|
real_norm = _normalize_path(real)
|
||||||
|
if real_norm != root_norm and not real_norm.startswith(f"{root_norm}/"):
|
||||||
|
reasons.append(
|
||||||
|
f"worktree path '{real}' escapes canonical repository root '{root}' "
|
||||||
|
"(traversal/symlink safety, fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": not reasons,
|
||||||
|
"block": bool(reasons),
|
||||||
|
"reasons": reasons,
|
||||||
|
"workspace_path": real,
|
||||||
|
"canonical_repo_root": root,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_git_worktree_paths(canonical_repo_root: str) -> list[str]:
|
||||||
|
"""Return realpaths registered in ``git worktree list --porcelain``."""
|
||||||
|
root = os.path.realpath(canonical_repo_root)
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", root, "worktree", "list", "--porcelain"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if res.returncode != 0:
|
||||||
|
return []
|
||||||
|
paths: list[str] = []
|
||||||
|
for line in (res.stdout or "").splitlines():
|
||||||
|
if line.startswith("worktree "):
|
||||||
|
raw = line[len("worktree ") :].strip()
|
||||||
|
if raw:
|
||||||
|
paths.append(os.path.realpath(raw))
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def path_in_git_worktree_list(path: str, canonical_repo_root: str) -> bool | None:
|
||||||
|
"""True/False when inventory is available; None when git inventory fails.
|
||||||
|
|
||||||
|
An empty inventory with a working git root is treated as inconclusive
|
||||||
|
(``None``) so unit tests and partial sandboxes are not false-negative
|
||||||
|
blocked when ``git worktree list`` is mocked/unavailable.
|
||||||
|
"""
|
||||||
|
root = os.path.realpath(canonical_repo_root)
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", root, "worktree", "list", "--porcelain"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if res.returncode != 0:
|
||||||
|
return None
|
||||||
|
inventory: list[str] = []
|
||||||
|
for line in (res.stdout or "").splitlines():
|
||||||
|
if line.startswith("worktree "):
|
||||||
|
raw = line[len("worktree ") :].strip()
|
||||||
|
if raw:
|
||||||
|
inventory.append(os.path.realpath(raw))
|
||||||
|
if not inventory:
|
||||||
|
return None
|
||||||
|
return os.path.realpath(path) in inventory
|
||||||
|
|
||||||
|
|
||||||
|
def assess_bound_worktree_existence(
|
||||||
|
*,
|
||||||
|
configured_path: str,
|
||||||
|
binding_source: str,
|
||||||
|
canonical_repo_root: str | None = None,
|
||||||
|
role_kind: str = "author",
|
||||||
|
profile_name: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Fail closed when a configured role-bound worktree path is missing (#618)."""
|
||||||
|
raw = (configured_path or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"bound_worktree_missing": False,
|
||||||
|
"path_exists": None,
|
||||||
|
"in_git_worktree_list": None,
|
||||||
|
"inspected_git_root": None,
|
||||||
|
"reasons": [],
|
||||||
|
"configured_path": None,
|
||||||
|
"binding_source": binding_source,
|
||||||
|
"role_kind": role_kind,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"blocker_kind": None,
|
||||||
|
"operator_recovery": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
real = _abs_real(raw)
|
||||||
|
except OSError:
|
||||||
|
real = os.path.abspath(raw)
|
||||||
|
|
||||||
|
path_exists = os.path.isdir(real)
|
||||||
|
in_list: bool | None = None
|
||||||
|
inspected_git_root: str | None = None
|
||||||
|
root = (canonical_repo_root or "").strip()
|
||||||
|
if root:
|
||||||
|
in_list = path_in_git_worktree_list(real, root) if path_exists else False
|
||||||
|
if path_exists:
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", real, "rev-parse", "--show-toplevel"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if res.returncode == 0:
|
||||||
|
inspected_git_root = (res.stdout or "").strip() or None
|
||||||
|
except Exception:
|
||||||
|
inspected_git_root = None
|
||||||
|
|
||||||
|
if path_exists:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"bound_worktree_missing": False,
|
||||||
|
"path_exists": True,
|
||||||
|
"in_git_worktree_list": in_list,
|
||||||
|
"inspected_git_root": inspected_git_root,
|
||||||
|
"reasons": [],
|
||||||
|
"configured_path": real,
|
||||||
|
"binding_source": binding_source,
|
||||||
|
"role_kind": role_kind,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"blocker_kind": None,
|
||||||
|
"operator_recovery": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons = [
|
||||||
|
BOUND_WORKTREE_MISSING_MESSAGE,
|
||||||
|
(
|
||||||
|
f"role/profile '{profile_name or role_kind}' binding via {binding_source} "
|
||||||
|
f"points to '{real}' which does not exist on disk"
|
||||||
|
),
|
||||||
|
f"path_exists=false; in_git_worktree_list={in_list}; inspected_git_root=null",
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"bound_worktree_missing": True,
|
||||||
|
"path_exists": False,
|
||||||
|
"in_git_worktree_list": False if in_list is not None else False,
|
||||||
|
"inspected_git_root": None,
|
||||||
|
"reasons": reasons,
|
||||||
|
"configured_path": real,
|
||||||
|
"binding_source": binding_source,
|
||||||
|
"role_kind": role_kind,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"blocker_kind": BOUND_WORKTREE_MISSING,
|
||||||
|
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_bound_worktree_missing_error(assessment: dict) -> str:
|
||||||
|
"""Canonical operator-facing message for a missing author worktree binding."""
|
||||||
|
reasons = list(assessment.get("reasons") or [BOUND_WORKTREE_MISSING_MESSAGE])
|
||||||
|
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
|
||||||
|
profile = assessment.get("profile_name") or assessment.get("role_kind") or "author"
|
||||||
|
source = (
|
||||||
|
assessment.get("binding_source")
|
||||||
|
or assessment.get("workspace_binding_source")
|
||||||
|
or "unknown binding"
|
||||||
|
)
|
||||||
|
path = (
|
||||||
|
assessment.get("configured_path")
|
||||||
|
or assessment.get("workspace_path")
|
||||||
|
or "(unknown)"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"Author worktree binding unhealthy (#618): {'; '.join(reasons)}. "
|
||||||
|
f"role/profile: {profile}; binding_source: {source}; configured_path: {path}. "
|
||||||
|
f"Operator recovery: {recovery}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_lock_worktree_ownership(
|
||||||
|
*,
|
||||||
|
workspace_path: str,
|
||||||
|
session_lock_worktree: str | None,
|
||||||
|
) -> dict:
|
||||||
|
"""When a live lock records a worktree, mutation workspace must match it."""
|
||||||
|
locked = (session_lock_worktree or "").strip()
|
||||||
|
if not locked:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"reasons": [],
|
||||||
|
"workspace_path": os.path.realpath(workspace_path) if workspace_path else None,
|
||||||
|
"lock_worktree_path": None,
|
||||||
|
}
|
||||||
|
workspace = os.path.realpath(workspace_path)
|
||||||
|
locked_real = os.path.realpath(locked)
|
||||||
|
if workspace != locked_real:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"reasons": [
|
||||||
|
f"active author issue lock worktree '{locked_real}' does not match "
|
||||||
|
f"mutation workspace '{workspace}' (lock ownership, fail closed)"
|
||||||
|
],
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"lock_worktree_path": locked_real,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"reasons": [],
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"lock_worktree_path": locked_real,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_durable_author_worktree(
|
||||||
|
*,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
worktree: str | None = None,
|
||||||
|
process_project_root: str,
|
||||||
|
active_worktree_env: str | None = None,
|
||||||
|
author_worktree_env: str | None = None,
|
||||||
|
session_lock_worktree: str | None = None,
|
||||||
|
canonical_repo_root: str | None = None,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
validate: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Resolve author mutation workspace without silent control-checkout fallback (#618).
|
||||||
|
|
||||||
|
Candidate priority:
|
||||||
|
1. explicit ``worktree_path`` argument
|
||||||
|
2. ``worktree`` argument
|
||||||
|
3. ``GITEA_ACTIVE_WORKTREE``
|
||||||
|
4. ``GITEA_AUTHOR_WORKTREE``
|
||||||
|
5. active author issue lock ``worktree_path``
|
||||||
|
6. process project root **only** when it is already under ``branches/``
|
||||||
|
|
||||||
|
Configured bindings that point at a missing path fail closed immediately
|
||||||
|
(no demotion to the control checkout). Validation (when *validate*) covers
|
||||||
|
existence, traversal/symlink safety, repository identity, branches/
|
||||||
|
containment, and lock ownership.
|
||||||
|
"""
|
||||||
|
process_root = os.path.realpath(process_project_root)
|
||||||
|
canonical = os.path.realpath(canonical_repo_root or process_root)
|
||||||
|
reasons: list[str] = []
|
||||||
|
role = "author"
|
||||||
|
|
||||||
|
candidates: list[tuple[str | None, str, bool]] = [
|
||||||
|
(worktree_path, "worktree_path argument", False),
|
||||||
|
(worktree, "worktree argument", False),
|
||||||
|
(active_worktree_env, f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
||||||
|
(author_worktree_env, f"{AUTHOR_WORKTREE_ENV} environment variable", True),
|
||||||
|
(session_lock_worktree, "active author issue lock worktree", False),
|
||||||
|
]
|
||||||
|
|
||||||
|
selected_path: str | None = None
|
||||||
|
selected_source: str | None = None
|
||||||
|
existence: dict | None = None
|
||||||
|
|
||||||
|
for candidate, source, _configured in candidates:
|
||||||
|
text = (candidate or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
real = _abs_real(text)
|
||||||
|
except OSError:
|
||||||
|
real = os.path.abspath(text)
|
||||||
|
|
||||||
|
existence = assess_bound_worktree_existence(
|
||||||
|
configured_path=real,
|
||||||
|
binding_source=source,
|
||||||
|
canonical_repo_root=canonical,
|
||||||
|
role_kind=role,
|
||||||
|
profile_name=profile_name,
|
||||||
|
)
|
||||||
|
if existence["block"]:
|
||||||
|
# Missing configured binding: fail closed, never fall back (#618).
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"workspace_path": real,
|
||||||
|
"workspace_binding_source": source,
|
||||||
|
"process_project_root": process_root,
|
||||||
|
"canonical_repo_root": canonical,
|
||||||
|
"bound_worktree_missing": True,
|
||||||
|
"path_exists": False,
|
||||||
|
"in_git_worktree_list": existence.get("in_git_worktree_list"),
|
||||||
|
"inspected_git_root": None,
|
||||||
|
"reasons": list(existence.get("reasons") or []),
|
||||||
|
"blocker_kind": BOUND_WORKTREE_MISSING,
|
||||||
|
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||||
|
"silent_control_fallback": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
selected_path = real
|
||||||
|
selected_source = source
|
||||||
|
break
|
||||||
|
|
||||||
|
if selected_path is None:
|
||||||
|
# No explicit/env/lock binding. Allow process root only when it is a
|
||||||
|
# branches/ worktree (MCP launched from the task worktree). Never
|
||||||
|
# silently bind the stable control checkout.
|
||||||
|
if is_path_under_branches(process_root, canonical):
|
||||||
|
selected_path = process_root
|
||||||
|
selected_source = "MCP process root under branches/ (session-owned)"
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"workspace_path": process_root,
|
||||||
|
"workspace_binding_source": "no author worktree binding",
|
||||||
|
"process_project_root": process_root,
|
||||||
|
"canonical_repo_root": canonical,
|
||||||
|
"bound_worktree_missing": False,
|
||||||
|
"path_exists": os.path.isdir(process_root),
|
||||||
|
"in_git_worktree_list": None,
|
||||||
|
"inspected_git_root": None,
|
||||||
|
"reasons": [
|
||||||
|
"author mutation blocked: workspace is the stable control checkout; "
|
||||||
|
"author mutation requires an explicit validated worktree_path "
|
||||||
|
"or a worktree derived from the active author issue lock; "
|
||||||
|
"silent fallback to the control checkout/master is forbidden (#618)"
|
||||||
|
],
|
||||||
|
"blocker_kind": "author_worktree_unbound_control_checkout",
|
||||||
|
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||||
|
"silent_control_fallback": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace = selected_path
|
||||||
|
source = selected_source or "unknown"
|
||||||
|
path_exists = os.path.isdir(workspace)
|
||||||
|
inspected_git_root: str | None = None
|
||||||
|
in_list: bool | None = None
|
||||||
|
|
||||||
|
if not validate:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"workspace_binding_source": source,
|
||||||
|
"process_project_root": process_root,
|
||||||
|
"canonical_repo_root": canonical,
|
||||||
|
"bound_worktree_missing": False,
|
||||||
|
"path_exists": path_exists,
|
||||||
|
"in_git_worktree_list": None,
|
||||||
|
"inspected_git_root": None,
|
||||||
|
"reasons": [],
|
||||||
|
"blocker_kind": None,
|
||||||
|
"operator_recovery": None,
|
||||||
|
"silent_control_fallback": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Traversal / symlink safety
|
||||||
|
safety = assess_path_traversal_safety(
|
||||||
|
path=workspace, canonical_repo_root=canonical
|
||||||
|
)
|
||||||
|
if safety["block"]:
|
||||||
|
reasons.extend(safety["reasons"])
|
||||||
|
else:
|
||||||
|
workspace = safety["workspace_path"] or workspace
|
||||||
|
|
||||||
|
# Existence + git inventory
|
||||||
|
if not path_exists:
|
||||||
|
reasons.append(BOUND_WORKTREE_MISSING_MESSAGE)
|
||||||
|
reasons.append(f"resolved worktree '{workspace}' does not exist")
|
||||||
|
else:
|
||||||
|
in_list = path_in_git_worktree_list(workspace, canonical)
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", workspace, "rev-parse", "--show-toplevel"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if res.returncode == 0:
|
||||||
|
inspected_git_root = (res.stdout or "").strip() or None
|
||||||
|
except Exception:
|
||||||
|
inspected_git_root = None
|
||||||
|
if in_list is False:
|
||||||
|
# Only hard-fail when inventory was obtained and the path is absent.
|
||||||
|
reasons.append(
|
||||||
|
f"worktree '{workspace}' is not listed in git worktree list for "
|
||||||
|
f"'{canonical}' (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Repository identity
|
||||||
|
if path_exists:
|
||||||
|
membership = assess_workspace_repo_membership(
|
||||||
|
workspace_path=workspace,
|
||||||
|
canonical_repo_root=canonical,
|
||||||
|
)
|
||||||
|
if membership["block"]:
|
||||||
|
reasons.extend(membership["reasons"])
|
||||||
|
|
||||||
|
# branches/ containment
|
||||||
|
branches = assess_author_mutation_worktree(
|
||||||
|
workspace_path=workspace,
|
||||||
|
project_root=canonical,
|
||||||
|
)
|
||||||
|
if branches["block"]:
|
||||||
|
reasons.extend(branches["reasons"])
|
||||||
|
|
||||||
|
# Lock ownership (when a lock worktree is recorded)
|
||||||
|
lock_own = assess_lock_worktree_ownership(
|
||||||
|
workspace_path=workspace,
|
||||||
|
session_lock_worktree=session_lock_worktree,
|
||||||
|
)
|
||||||
|
if lock_own["block"]:
|
||||||
|
reasons.extend(lock_own["reasons"])
|
||||||
|
|
||||||
|
# Forbid resolved control checkout even if somehow selected
|
||||||
|
if workspace == canonical or workspace == process_root:
|
||||||
|
if not is_path_under_branches(workspace, canonical):
|
||||||
|
if not any("control checkout" in r for r in reasons):
|
||||||
|
reasons.append(
|
||||||
|
"author mutation blocked: resolved workspace is the stable "
|
||||||
|
"control checkout; silent fallback forbidden (#618)"
|
||||||
|
)
|
||||||
|
|
||||||
|
block = bool(reasons)
|
||||||
|
bound_missing = any("does not exist" in r or BOUND_WORKTREE_MISSING_MESSAGE in r for r in reasons)
|
||||||
|
return {
|
||||||
|
"proven": not block,
|
||||||
|
"block": block,
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"workspace_binding_source": source,
|
||||||
|
"process_project_root": process_root,
|
||||||
|
"canonical_repo_root": canonical,
|
||||||
|
"bound_worktree_missing": bound_missing,
|
||||||
|
"path_exists": path_exists,
|
||||||
|
"in_git_worktree_list": in_list,
|
||||||
|
"inspected_git_root": inspected_git_root,
|
||||||
|
"reasons": reasons,
|
||||||
|
"blocker_kind": BOUND_WORKTREE_MISSING if bound_missing else (
|
||||||
|
"author_worktree_validation_failed" if block else None
|
||||||
|
),
|
||||||
|
"operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT if block else None,
|
||||||
|
"silent_control_fallback": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_durable_author_worktree_error(assessment: dict) -> str:
|
||||||
|
"""Format fail-closed error for durable author worktree resolution."""
|
||||||
|
if assessment.get("bound_worktree_missing") or assessment.get("blocker_kind") == BOUND_WORKTREE_MISSING:
|
||||||
|
return format_bound_worktree_missing_error(assessment)
|
||||||
|
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||||
|
source = assessment.get("workspace_binding_source") or "unknown"
|
||||||
|
reasons = "; ".join(
|
||||||
|
assessment.get("reasons") or ["author worktree resolution failed"]
|
||||||
|
)
|
||||||
|
recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT
|
||||||
|
return (
|
||||||
|
f"Durable author worktree resolution blocked (#618): {reasons}. "
|
||||||
|
f"workspace: {workspace}; binding_source: {source}. "
|
||||||
|
f"Operator recovery: {recovery}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ from typing import Any
|
|||||||
|
|
||||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||||
|
|
||||||
|
# Evidence / preservation branches must never be removed by cleanup tools
|
||||||
|
# (e.g. chore/issue-681-preserve-review-session-wip).
|
||||||
|
_PRESERVATION_MARKERS = ("preserve", "preservation", "evidence")
|
||||||
|
|
||||||
|
|
||||||
|
def is_preservation_or_evidence_branch(branch: str | None) -> bool:
|
||||||
|
"""Return True when *branch* is a preservation/evidence ref that must stay."""
|
||||||
|
if not branch:
|
||||||
|
return False
|
||||||
|
name = str(branch).lower()
|
||||||
|
return any(marker in name for marker in _PRESERVATION_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
_RAW_BRANCH_DELETE_PATTERNS = (
|
_RAW_BRANCH_DELETE_PATTERNS = (
|
||||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
|
||||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
|
||||||
@@ -72,6 +85,11 @@ def assess_merged_pr_branch_cleanup(
|
|||||||
reasons.append("PR head branch is missing")
|
reasons.append("PR head branch is missing")
|
||||||
if head_branch in protected:
|
if head_branch in protected:
|
||||||
reasons.append(f"branch '{head_branch}' is protected")
|
reasons.append(f"branch '{head_branch}' is protected")
|
||||||
|
if is_preservation_or_evidence_branch(head_branch):
|
||||||
|
reasons.append(
|
||||||
|
f"branch '{head_branch}' is a preservation/evidence branch and "
|
||||||
|
"cannot be deleted through merged-PR cleanup"
|
||||||
|
)
|
||||||
if head_branch in open_pr_heads:
|
if head_branch in open_pr_heads:
|
||||||
reasons.append("an open PR still references this head branch")
|
reasons.append("an open PR still references this head branch")
|
||||||
if head_on_target is False:
|
if head_on_target is False:
|
||||||
@@ -96,3 +114,490 @@ def assess_merged_pr_branch_cleanup(
|
|||||||
"block_reasons": reasons,
|
"block_reasons": reasons,
|
||||||
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
|
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #687 remediation: post-delete readback + active ownership protection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
READBACK_NOT_FOUND = "not_found"
|
||||||
|
READBACK_EXISTS = "exists"
|
||||||
|
READBACK_AUTHENTICATION = "authentication_error"
|
||||||
|
READBACK_AUTHORIZATION = "authorization_error"
|
||||||
|
READBACK_TRANSPORT = "transport_error"
|
||||||
|
READBACK_UNEXPECTED = "unexpected_response"
|
||||||
|
READBACK_AMBIGUOUS_404 = "ambiguous_not_found"
|
||||||
|
|
||||||
|
# Scope of a 404: only branch-scoped absence may set verified_absent.
|
||||||
|
NOT_FOUND_SCOPE_BRANCH = "branch"
|
||||||
|
NOT_FOUND_SCOPE_REPOSITORY = "repository"
|
||||||
|
NOT_FOUND_SCOPE_HOST = "host"
|
||||||
|
NOT_FOUND_SCOPE_UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
ERROR_CLASS_AUTHENTICATION = "authentication"
|
||||||
|
ERROR_CLASS_AUTHORIZATION = "authorization"
|
||||||
|
ERROR_CLASS_TRANSPORT = "transport"
|
||||||
|
ERROR_CLASS_UNEXPECTED = "unexpected"
|
||||||
|
|
||||||
|
OWNERSHIP_CATEGORY_AUTHOR_SESSION = "author_session"
|
||||||
|
OWNERSHIP_CATEGORY_AUTHOR_LEASE = "author_lease"
|
||||||
|
OWNERSHIP_CATEGORY_REVIEWER_LEASE = "reviewer_lease"
|
||||||
|
OWNERSHIP_CATEGORY_MERGER_LEASE = "merger_lease"
|
||||||
|
OWNERSHIP_CATEGORY_CONTROLLER_LEASE = "controller_lease"
|
||||||
|
OWNERSHIP_CATEGORY_RECONCILER_LEASE = "reconciler_lease"
|
||||||
|
OWNERSHIP_CATEGORY_WORKTREE_BINDING = "worktree_binding"
|
||||||
|
OWNERSHIP_CATEGORY_INVENTORY_ERROR = "ownership_inventory_error"
|
||||||
|
|
||||||
|
_ROLE_TO_OWNERSHIP_CATEGORY = {
|
||||||
|
"author": OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||||
|
"reviewer": OWNERSHIP_CATEGORY_REVIEWER_LEASE,
|
||||||
|
"merger": OWNERSHIP_CATEGORY_MERGER_LEASE,
|
||||||
|
"controller": OWNERSHIP_CATEGORY_CONTROLLER_LEASE,
|
||||||
|
"reconciler": OWNERSHIP_CATEGORY_RECONCILER_LEASE,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ACTIVE_OWNERSHIP_STATUSES = frozenset(
|
||||||
|
{"active", "live", "claimed", "in_progress", "working", "pushing", "pushed"}
|
||||||
|
)
|
||||||
|
_TERMINAL_OWNERSHIP_STATUSES = frozenset(
|
||||||
|
{"released", "abandoned", "done", "blocked", "terminal", "closed"}
|
||||||
|
)
|
||||||
|
_EXPIRED_STATUSES = frozenset({"expired"})
|
||||||
|
_STALE_STATUSES = frozenset({"stale", "stale_dead_process", "stale_missing_worktree"})
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_str(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_host(host: str | None) -> str:
|
||||||
|
"""Normalize a host identity for ownership matching (no credentials)."""
|
||||||
|
text = _norm_str(host).lower()
|
||||||
|
for prefix in ("https://", "http://"):
|
||||||
|
if text.startswith(prefix):
|
||||||
|
text = text[len(prefix) :]
|
||||||
|
# Drop path/query if a full URL slipped through.
|
||||||
|
text = text.split("/", 1)[0]
|
||||||
|
text = text.split("?", 1)[0]
|
||||||
|
return text.rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def ownership_category_for_role(role: str | None) -> str:
|
||||||
|
"""Map a role kind to a non-secret ownership category label."""
|
||||||
|
key = _norm_str(role).lower()
|
||||||
|
return _ROLE_TO_OWNERSHIP_CATEGORY.get(key, f"{key or 'unknown'}_lease")
|
||||||
|
|
||||||
|
|
||||||
|
def classify_branch_readback_http_status(
|
||||||
|
status_code: int | None,
|
||||||
|
*,
|
||||||
|
not_found_scope: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify a GET-branch HTTP status into a secret-free readback result.
|
||||||
|
|
||||||
|
R1: A bare/generic/repository/wrong-host 404 never yields
|
||||||
|
``verified_absent=True``. Only an authoritative *branch-scoped* not-found
|
||||||
|
(``not_found_scope='branch'``) may verify deletion.
|
||||||
|
"""
|
||||||
|
if status_code == 404:
|
||||||
|
scope = _norm_str(not_found_scope).lower() or NOT_FOUND_SCOPE_UNKNOWN
|
||||||
|
if scope == NOT_FOUND_SCOPE_BRANCH:
|
||||||
|
return {
|
||||||
|
"status": READBACK_NOT_FOUND,
|
||||||
|
"error_class": None,
|
||||||
|
"verified_absent": True,
|
||||||
|
"branch_present": False,
|
||||||
|
"not_found_scope": NOT_FOUND_SCOPE_BRANCH,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
# repository / host / unknown / generic 404 — not verified absence
|
||||||
|
reason = {
|
||||||
|
NOT_FOUND_SCOPE_REPOSITORY: (
|
||||||
|
"post-delete readback 404 is repository-scoped, not branch absence"
|
||||||
|
),
|
||||||
|
NOT_FOUND_SCOPE_HOST: (
|
||||||
|
"post-delete readback 404 is host-scoped, not branch absence"
|
||||||
|
),
|
||||||
|
}.get(
|
||||||
|
scope,
|
||||||
|
"post-delete readback 404 is ambiguous (not branch-scoped); "
|
||||||
|
"cannot verify absence",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": READBACK_AMBIGUOUS_404,
|
||||||
|
"error_class": ERROR_CLASS_UNEXPECTED,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"not_found_scope": scope,
|
||||||
|
"reasons": [reason],
|
||||||
|
}
|
||||||
|
if status_code in (401, 407):
|
||||||
|
return {
|
||||||
|
"status": READBACK_AUTHENTICATION,
|
||||||
|
"error_class": ERROR_CLASS_AUTHENTICATION,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": ["post-delete branch readback authentication failed"],
|
||||||
|
}
|
||||||
|
if status_code == 403:
|
||||||
|
return {
|
||||||
|
"status": READBACK_AUTHORIZATION,
|
||||||
|
"error_class": ERROR_CLASS_AUTHORIZATION,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": ["post-delete branch readback authorization failed"],
|
||||||
|
}
|
||||||
|
if status_code is not None and 200 <= int(status_code) < 300:
|
||||||
|
return {
|
||||||
|
"status": READBACK_EXISTS,
|
||||||
|
"error_class": None,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": True,
|
||||||
|
"reasons": ["post-delete readback found branch still present"],
|
||||||
|
}
|
||||||
|
if status_code is not None and int(status_code) >= 500:
|
||||||
|
return {
|
||||||
|
"status": READBACK_TRANSPORT,
|
||||||
|
"error_class": ERROR_CLASS_TRANSPORT,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": ["post-delete branch readback transport/upstream failure"],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": READBACK_UNEXPECTED,
|
||||||
|
"error_class": ERROR_CLASS_UNEXPECTED,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": ["post-delete branch readback returned an unexpected response"],
|
||||||
|
"http_status": status_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_http_status(exc: BaseException) -> int | None:
|
||||||
|
"""Extract an HTTP status code from an exception chain (secret-free)."""
|
||||||
|
seen: set[int] = set()
|
||||||
|
current: BaseException | None = exc
|
||||||
|
while current is not None and id(current) not in seen:
|
||||||
|
seen.add(id(current))
|
||||||
|
for attr in ("code", "status", "status_code"):
|
||||||
|
value = getattr(current, attr, None)
|
||||||
|
if isinstance(value, int) and 100 <= value <= 599:
|
||||||
|
return value
|
||||||
|
text = str(current) if current is not None else ""
|
||||||
|
match = re.match(r"HTTP\s+(\d{3})\b", text)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
current = current.__cause__ or current.__context__
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def classify_branch_readback_exception(
|
||||||
|
exc: BaseException,
|
||||||
|
*,
|
||||||
|
not_found_scope: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify a GET-branch exception without leaking credentials or bodies.
|
||||||
|
|
||||||
|
R1: substring ``404`` / ``not found`` alone never becomes verified_absent.
|
||||||
|
Callers must pass ``not_found_scope='branch'`` only after authoritative
|
||||||
|
proof that the repository/host is still reachable and the 404 is branch-level.
|
||||||
|
"""
|
||||||
|
status_code = _extract_http_status(exc)
|
||||||
|
if status_code is None:
|
||||||
|
lower = str(exc).lower() if exc is not None else ""
|
||||||
|
if any(
|
||||||
|
token in lower
|
||||||
|
for token in (
|
||||||
|
"timed out",
|
||||||
|
"timeout",
|
||||||
|
"connection",
|
||||||
|
"network",
|
||||||
|
"temporarily unavailable",
|
||||||
|
"name or service not known",
|
||||||
|
"nodename nor servname",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"status": READBACK_TRANSPORT,
|
||||||
|
"error_class": ERROR_CLASS_TRANSPORT,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": [
|
||||||
|
"post-delete branch readback transport/upstream failure"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if "unauthorized" in lower:
|
||||||
|
status_code = 401
|
||||||
|
elif "forbidden" in lower:
|
||||||
|
status_code = 403
|
||||||
|
elif "404" in lower or "not found" in lower:
|
||||||
|
# Ambiguous: do NOT treat as branch absence without scope proof.
|
||||||
|
status_code = 404
|
||||||
|
if not_found_scope is None:
|
||||||
|
not_found_scope = NOT_FOUND_SCOPE_UNKNOWN
|
||||||
|
|
||||||
|
if status_code is not None:
|
||||||
|
return classify_branch_readback_http_status(
|
||||||
|
status_code, not_found_scope=not_found_scope
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": READBACK_UNEXPECTED,
|
||||||
|
"error_class": ERROR_CLASS_UNEXPECTED,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": ["post-delete branch readback returned an unexpected response"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_post_delete_readback(readback: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Decide whether DELETE success may be reported after branch readback.
|
||||||
|
|
||||||
|
Success requires authoritative branch-scoped not-found
|
||||||
|
(``verified_absent=True``). DELETE HTTP success alone is never enough.
|
||||||
|
Generic/repository/host 404 cannot verify absence (R1).
|
||||||
|
"""
|
||||||
|
payload = dict(readback or {})
|
||||||
|
status = _norm_str(payload.get("status")) or READBACK_UNEXPECTED
|
||||||
|
# Only explicit verified_absent flag counts — never infer from status alone
|
||||||
|
# when status is a bare not_found without branch scope proof.
|
||||||
|
verified = bool(payload.get("verified_absent")) is True
|
||||||
|
if verified and status == READBACK_NOT_FOUND:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"success": True,
|
||||||
|
"verified_absent": True,
|
||||||
|
"readback": {
|
||||||
|
"status": READBACK_NOT_FOUND,
|
||||||
|
"verified_absent": True,
|
||||||
|
"branch_present": False,
|
||||||
|
"error_class": None,
|
||||||
|
"not_found_scope": NOT_FOUND_SCOPE_BRANCH,
|
||||||
|
},
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons = list(payload.get("reasons") or [])
|
||||||
|
if not reasons:
|
||||||
|
if status == READBACK_EXISTS:
|
||||||
|
reasons = ["post-delete readback found branch still present"]
|
||||||
|
elif status == READBACK_AUTHENTICATION:
|
||||||
|
reasons = ["post-delete branch readback authentication failed"]
|
||||||
|
elif status == READBACK_AUTHORIZATION:
|
||||||
|
reasons = ["post-delete branch readback authorization failed"]
|
||||||
|
elif status == READBACK_TRANSPORT:
|
||||||
|
reasons = ["post-delete branch readback transport/upstream failure"]
|
||||||
|
elif status == READBACK_AMBIGUOUS_404:
|
||||||
|
reasons = [
|
||||||
|
"post-delete readback 404 is not branch-scoped; "
|
||||||
|
"cannot verify absence"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
reasons = ["post-delete branch readback could not verify deletion"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"success": False,
|
||||||
|
"verified_absent": False,
|
||||||
|
"readback": {
|
||||||
|
"status": status,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": payload.get("branch_present"),
|
||||||
|
"error_class": payload.get("error_class"),
|
||||||
|
"not_found_scope": payload.get("not_found_scope"),
|
||||||
|
},
|
||||||
|
"reasons": reasons,
|
||||||
|
"blocker_kind": "post_delete_readback_failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_result_envelope(
|
||||||
|
*,
|
||||||
|
success: bool,
|
||||||
|
performed: bool,
|
||||||
|
delete_acknowledged: bool,
|
||||||
|
verified_absent: bool,
|
||||||
|
**extra: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""R2: consistent top-level cleanup result fields on every return path."""
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"success": bool(success),
|
||||||
|
"performed": bool(performed),
|
||||||
|
"delete_acknowledged": bool(delete_acknowledged),
|
||||||
|
"verified_absent": bool(verified_absent),
|
||||||
|
}
|
||||||
|
out.update(extra)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_matches(
|
||||||
|
record: dict[str, Any],
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
host: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Match ownership record to target remote/org/repo and normalized host."""
|
||||||
|
if (
|
||||||
|
_norm_str(record.get("remote")).lower() != _norm_str(remote).lower()
|
||||||
|
or _norm_str(record.get("org")).lower() != _norm_str(org).lower()
|
||||||
|
or _norm_str(record.get("repo")).lower() != _norm_str(repo).lower()
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
expected_host = normalize_host(host)
|
||||||
|
record_host = normalize_host(record.get("host") or record.get("host_name"))
|
||||||
|
# When both sides declare a host, they must agree after normalization.
|
||||||
|
# A record host that disagrees with the expected host is out of scope.
|
||||||
|
# Legacy records without host still match when remote/org/repo agree.
|
||||||
|
if expected_host and record_host and expected_host != record_host:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _branch_matches(record: dict[str, Any], branch: str) -> bool:
|
||||||
|
return _norm_str(record.get("branch")) == _norm_str(branch)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Classify one ownership record as blocking or non-blocking.
|
||||||
|
|
||||||
|
Distinguishes active ownership from expired/released/terminal/stale records.
|
||||||
|
Expired/stale records block unless reclaim_allowed is *explicitly* True
|
||||||
|
(O2: never treat missing/unknown reclaim as auto-allowed).
|
||||||
|
"""
|
||||||
|
status = _norm_str(record.get("status")).lower()
|
||||||
|
category = _norm_str(record.get("category")) or "unknown"
|
||||||
|
reclaim_allowed = record.get("reclaim_allowed")
|
||||||
|
|
||||||
|
if category == OWNERSHIP_CATEGORY_INVENTORY_ERROR:
|
||||||
|
return {
|
||||||
|
"blocks": True,
|
||||||
|
"status": status or "unknown",
|
||||||
|
"category": category,
|
||||||
|
"reason": "ownership inventory failed closed",
|
||||||
|
}
|
||||||
|
|
||||||
|
if status in _TERMINAL_OWNERSHIP_STATUSES:
|
||||||
|
return {
|
||||||
|
"blocks": False,
|
||||||
|
"status": status,
|
||||||
|
"category": category,
|
||||||
|
"reason": f"{category} ownership is terminal/released ({status})",
|
||||||
|
}
|
||||||
|
if status in _ACTIVE_OWNERSHIP_STATUSES:
|
||||||
|
return {
|
||||||
|
"blocks": True,
|
||||||
|
"status": status,
|
||||||
|
"category": category,
|
||||||
|
"reason": f"active {category} ownership still uses the target branch",
|
||||||
|
}
|
||||||
|
if status in _EXPIRED_STATUSES or status in _STALE_STATUSES:
|
||||||
|
# O2: only explicit reclaim_allowed=True skips the block.
|
||||||
|
if reclaim_allowed is True:
|
||||||
|
return {
|
||||||
|
"blocks": False,
|
||||||
|
"status": status,
|
||||||
|
"category": category,
|
||||||
|
"reason": (
|
||||||
|
f"{category} ownership is {status} and reclaimable; "
|
||||||
|
"does not block deletion"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"blocks": True,
|
||||||
|
"status": status,
|
||||||
|
"category": category,
|
||||||
|
"reason": (
|
||||||
|
f"{status} {category} ownership still protects the target "
|
||||||
|
"branch (reclaim not proven; fail closed)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
# Unknown status → fail closed
|
||||||
|
return {
|
||||||
|
"blocks": True,
|
||||||
|
"status": status or "unknown",
|
||||||
|
"category": category,
|
||||||
|
"reason": (
|
||||||
|
f"unclassified {category} ownership status "
|
||||||
|
f"'{status or 'unknown'}'; fail closed"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_active_branch_ownership(
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
branch: str,
|
||||||
|
host: str | None = None,
|
||||||
|
records: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether any active ownership still uses *branch* in *repo*.
|
||||||
|
|
||||||
|
Matching requires remote/org/repo/branch and, when provided, normalized
|
||||||
|
host identity. Other repositories, hosts, or branches never false-block.
|
||||||
|
"""
|
||||||
|
target_branch = _norm_str(branch)
|
||||||
|
expected_host = normalize_host(host)
|
||||||
|
considered: list[dict[str, Any]] = []
|
||||||
|
blocking: list[dict[str, Any]] = []
|
||||||
|
ignored: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for raw in records or []:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
if not _repo_matches(
|
||||||
|
raw, remote=remote, org=org, repo=repo, host=expected_host or None
|
||||||
|
):
|
||||||
|
ignored.append(
|
||||||
|
{
|
||||||
|
"category": _norm_str(raw.get("category")) or "unknown",
|
||||||
|
"reason": "different repository or host scope",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not _branch_matches(raw, target_branch):
|
||||||
|
ignored.append(
|
||||||
|
{
|
||||||
|
"category": _norm_str(raw.get("category")) or "unknown",
|
||||||
|
"reason": "different branch",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
activity = assess_ownership_record_activity(raw)
|
||||||
|
entry = {
|
||||||
|
"category": activity["category"],
|
||||||
|
"status": activity["status"],
|
||||||
|
"blocks": activity["blocks"],
|
||||||
|
"reason": activity["reason"],
|
||||||
|
}
|
||||||
|
considered.append(entry)
|
||||||
|
if activity["blocks"]:
|
||||||
|
blocking.append(entry)
|
||||||
|
|
||||||
|
block = bool(blocking)
|
||||||
|
categories = sorted({b["category"] for b in blocking})
|
||||||
|
reasons = [
|
||||||
|
(
|
||||||
|
"active ownership protects branch "
|
||||||
|
f"'{target_branch}': " + "; ".join(b["reason"] for b in blocking)
|
||||||
|
)
|
||||||
|
] if block else []
|
||||||
|
return {
|
||||||
|
"block": block,
|
||||||
|
"safe_to_delete": not block,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"host": expected_host or None,
|
||||||
|
"branch": target_branch,
|
||||||
|
"blocking_categories": categories,
|
||||||
|
"blocking": blocking,
|
||||||
|
"considered": considered,
|
||||||
|
"ignored_out_of_scope": ignored,
|
||||||
|
"reasons": reasons,
|
||||||
|
"blocker_kind": "active_branch_ownership" if block else None,
|
||||||
|
"recommended_action": "keep_remote_branch" if block else "delete_remote_branch",
|
||||||
|
}
|
||||||
|
|||||||
@@ -386,6 +386,27 @@ def assess_canonical_comment(
|
|||||||
if not related or related.lower() in {"none", "n/a", "-"}:
|
if not related or related.lower() in {"none", "n/a", "-"}:
|
||||||
missing.append("RELATED_PRS")
|
missing.append("RELATED_PRS")
|
||||||
|
|
||||||
|
# #695 AC7: untrusted / offline approval claims cannot certify merger handoff.
|
||||||
|
try:
|
||||||
|
import review_quarantine as _rq
|
||||||
|
|
||||||
|
for claim_reason in _rq.assess_untrusted_canonical_approval_claim(text):
|
||||||
|
extra.append(claim_reason)
|
||||||
|
except Exception:
|
||||||
|
# Fail closed on import/runtime errors for approval-shaped claims only.
|
||||||
|
lower = text.lower()
|
||||||
|
if (
|
||||||
|
"state:\napproved" in lower
|
||||||
|
or "who_is_next:\nmerger" in lower
|
||||||
|
or "merge_ready: true" in lower
|
||||||
|
or "merge_ready:\ntrue" in lower
|
||||||
|
or "ready-to-merge" in lower
|
||||||
|
):
|
||||||
|
extra.append(
|
||||||
|
"canonical approval/merge-ready claim could not be verified "
|
||||||
|
"for native review proof (#695 AC7; fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
allowed = not missing and not vague and not extra
|
allowed = not missing and not vague and not extra
|
||||||
correction = ""
|
correction = ""
|
||||||
if not allowed:
|
if not allowed:
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""Immutable canonical repository root for cross-repository namespaces (#706).
|
||||||
|
|
||||||
|
The Gitea-Tools MCP server historically derived the ``canonical_repo_root`` from
|
||||||
|
the *install checkout* the server script lives in
|
||||||
|
(``PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))``). A namespace
|
||||||
|
that runs the same server script against an *external* repository (e.g.
|
||||||
|
``eagenda-author`` targeting ``eAgenda``) then failed every mutation: the
|
||||||
|
branches-only / worktree-membership guards (#274) compared the task workspace
|
||||||
|
against the Gitea-Tools ``.git`` directory, which it can never belong to.
|
||||||
|
|
||||||
|
This module separates two distinct concepts:
|
||||||
|
|
||||||
|
* the immutable code/install root (``PROJECT_ROOT``) — where the server lives, and
|
||||||
|
* the namespace-scoped **canonical repository root** — the working root of the
|
||||||
|
repository whose issues/PRs the namespace mutates.
|
||||||
|
|
||||||
|
The canonical repository root is configured per namespace (profile field or an
|
||||||
|
environment variable, typically set alongside the namespace ``cwd`` in the MCP
|
||||||
|
config). It is validated (existence, git identity, git common-directory
|
||||||
|
membership) and pinned immutably into the session context so a later call cannot
|
||||||
|
forge or swap it. When *no* binding is configured the single-repo default is
|
||||||
|
preserved unchanged: the canonical root is derived from the process checkout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
import remote_repo_guard
|
||||||
|
|
||||||
|
# Namespace-scoped override, typically exported next to the server ``cwd`` in the
|
||||||
|
# MCP config for a cross-repository namespace.
|
||||||
|
CANONICAL_ROOT_ENV = "GITEA_CANONICAL_REPOSITORY_ROOT"
|
||||||
|
|
||||||
|
# Candidate git remote names probed when deriving repository identity.
|
||||||
|
_IDENTITY_REMOTE_CANDIDATES = ("prgs", "origin", "dadeschools", "mdcps")
|
||||||
|
|
||||||
|
|
||||||
|
def configured_canonical_root(
|
||||||
|
profile: Mapping | None,
|
||||||
|
env: Mapping | None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Return ``(value, source)`` for the declared canonical repository root.
|
||||||
|
|
||||||
|
Precedence: the ``GITEA_CANONICAL_REPOSITORY_ROOT`` environment variable
|
||||||
|
(namespace-scoped) overrides the profile ``canonical_repository_root``
|
||||||
|
field. Blank values are treated as unset. Returns ``(None, None)`` when no
|
||||||
|
binding is declared (the single-repo default).
|
||||||
|
"""
|
||||||
|
env_map = env if env is not None else os.environ
|
||||||
|
env_val = (env_map.get(CANONICAL_ROOT_ENV) or "").strip()
|
||||||
|
if env_val:
|
||||||
|
return env_val, f"{CANONICAL_ROOT_ENV} environment variable"
|
||||||
|
if profile:
|
||||||
|
prof_val = (profile.get("canonical_repository_root") or "").strip()
|
||||||
|
if prof_val:
|
||||||
|
return prof_val, "profile canonical_repository_root"
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repo_toplevel(path: str) -> str | None:
|
||||||
|
"""Realpath of the git working-tree top level for *path*, or None."""
|
||||||
|
text = (path or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", text, "rev-parse", "--show-toplevel"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
top = (res.stdout or "").strip()
|
||||||
|
return os.path.realpath(top) if top else None
|
||||||
|
|
||||||
|
|
||||||
|
def repository_identity_slug(path: str, *, remote: str | None = None) -> str | None:
|
||||||
|
"""``owner/repository`` derived from a git remote configured at *path*.
|
||||||
|
|
||||||
|
Tries the caller-named remote first, then a small set of known remote names,
|
||||||
|
then whatever remote the repository actually has. Returns None when no remote
|
||||||
|
URL is parseable (identity cannot be proven).
|
||||||
|
"""
|
||||||
|
text = (path or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ordered: list[str] = []
|
||||||
|
for name in (remote, *_IDENTITY_REMOTE_CANDIDATES):
|
||||||
|
clean = (name or "").strip()
|
||||||
|
if clean and clean not in ordered:
|
||||||
|
ordered.append(clean)
|
||||||
|
|
||||||
|
try:
|
||||||
|
listed = subprocess.run(
|
||||||
|
["git", "-C", text, "remote"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.split()
|
||||||
|
except Exception:
|
||||||
|
listed = []
|
||||||
|
for name in listed:
|
||||||
|
if name and name not in ordered:
|
||||||
|
ordered.append(name)
|
||||||
|
|
||||||
|
for name in ordered:
|
||||||
|
try:
|
||||||
|
url = subprocess.run(
|
||||||
|
["git", "-C", text, "remote", "get-url", name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||||
|
if parsed:
|
||||||
|
return f"{parsed[0]}/{parsed[1]}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_canonical_repository_root(
|
||||||
|
*,
|
||||||
|
configured_value: str | None,
|
||||||
|
source: str | None,
|
||||||
|
expected_slug: str | None,
|
||||||
|
process_project_root: str,
|
||||||
|
remote: str | None = None,
|
||||||
|
require_binding: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Validate the canonical repository root binding, failing closed on forgery.
|
||||||
|
|
||||||
|
Returns a dict with ``proven`` / ``block`` / ``reasons`` plus the resolved
|
||||||
|
``canonical_repo_root`` (the value downstream guards must use),
|
||||||
|
``configured`` (whether a cross-repo binding was declared),
|
||||||
|
``resolved_slug`` and ``source``.
|
||||||
|
|
||||||
|
Without a configured binding the single-repo default is preserved: the
|
||||||
|
canonical root is derived from *process_project_root* and never blocks
|
||||||
|
(unless *require_binding* explicitly demands one).
|
||||||
|
|
||||||
|
With a configured binding the path must exist, be a git repository, and —
|
||||||
|
when *expected_slug* is known — carry a matching repository identity. A
|
||||||
|
mismatched or (when *require_binding*) unprovable identity is a forged or
|
||||||
|
conflicting binding and fails closed.
|
||||||
|
"""
|
||||||
|
process_root = os.path.realpath(process_project_root)
|
||||||
|
declared = (configured_value or "").strip()
|
||||||
|
|
||||||
|
if not declared:
|
||||||
|
if require_binding:
|
||||||
|
return _assessment(
|
||||||
|
proven=False,
|
||||||
|
reasons=[
|
||||||
|
"no canonical_repository_root configured for a cross-repository "
|
||||||
|
f"namespace; set {CANONICAL_ROOT_ENV} or the profile "
|
||||||
|
"canonical_repository_root field (fail closed)"
|
||||||
|
],
|
||||||
|
configured=False,
|
||||||
|
canonical_repo_root=process_root,
|
||||||
|
resolved_slug=None,
|
||||||
|
source=None,
|
||||||
|
)
|
||||||
|
# Single-repo default: canonical root follows the install checkout.
|
||||||
|
derived = resolve_repo_toplevel(process_root) or process_root
|
||||||
|
return _assessment(
|
||||||
|
proven=True,
|
||||||
|
reasons=[],
|
||||||
|
configured=False,
|
||||||
|
canonical_repo_root=derived,
|
||||||
|
resolved_slug=None,
|
||||||
|
source=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
real = os.path.realpath(os.path.abspath(declared))
|
||||||
|
if not os.path.isdir(real):
|
||||||
|
return _assessment(
|
||||||
|
proven=False,
|
||||||
|
reasons=[
|
||||||
|
f"configured canonical repository root '{real}' does not exist "
|
||||||
|
"or is not a directory (fail closed)"
|
||||||
|
],
|
||||||
|
configured=True,
|
||||||
|
canonical_repo_root=real,
|
||||||
|
resolved_slug=None,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
toplevel = resolve_repo_toplevel(real)
|
||||||
|
if not toplevel:
|
||||||
|
return _assessment(
|
||||||
|
proven=False,
|
||||||
|
reasons=[
|
||||||
|
f"configured canonical repository root '{real}' is not a git "
|
||||||
|
"repository (fail closed)"
|
||||||
|
],
|
||||||
|
configured=True,
|
||||||
|
canonical_repo_root=real,
|
||||||
|
resolved_slug=None,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_slug = repository_identity_slug(toplevel, remote=remote)
|
||||||
|
reasons: list[str] = []
|
||||||
|
expected = (expected_slug or "").strip() or None
|
||||||
|
if expected:
|
||||||
|
if resolved_slug and resolved_slug.lower() != expected.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root identity mismatch: '{toplevel}' resolves "
|
||||||
|
f"to repository '{resolved_slug}' but the session is authorized for "
|
||||||
|
f"'{expected}' (forged or conflicting binding, fail closed)"
|
||||||
|
)
|
||||||
|
elif not resolved_slug and require_binding:
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root '{toplevel}' has no resolvable git "
|
||||||
|
f"remote identity to confirm authorization for '{expected}' "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _assessment(
|
||||||
|
proven=not reasons,
|
||||||
|
reasons=reasons,
|
||||||
|
configured=True,
|
||||||
|
canonical_repo_root=toplevel,
|
||||||
|
resolved_slug=resolved_slug,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_canonical_repository_root_error(assessment: Mapping) -> str:
|
||||||
|
"""Single RuntimeError message for MCP preflight gates."""
|
||||||
|
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||||
|
source = assessment.get("source") or "(unconfigured)"
|
||||||
|
reasons = "; ".join(
|
||||||
|
assessment.get("reasons") or ["unknown canonical repository root violation"]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"Canonical repository root guard (#706): {reasons}. "
|
||||||
|
f"binding source: {source}; canonical repository root: {root}. "
|
||||||
|
"Configure a valid canonical_repository_root for the target repository "
|
||||||
|
"and relaunch; do not point it at the Gitea-Tools install checkout."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assessment(
|
||||||
|
*,
|
||||||
|
proven: bool,
|
||||||
|
reasons: list[str],
|
||||||
|
configured: bool,
|
||||||
|
canonical_repo_root: str,
|
||||||
|
resolved_slug: str | None,
|
||||||
|
source: str | None,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"proven": proven,
|
||||||
|
"block": not proven,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"configured": configured,
|
||||||
|
"canonical_repo_root": canonical_repo_root,
|
||||||
|
"resolved_slug": resolved_slug,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
+1751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
"""Controller-closure baseline-proof gate (#529, criterion 6).
|
||||||
|
|
||||||
|
A controller (or any session) may close a tracking issue with a closure
|
||||||
|
report that summarizes validation. When that report calls a non-zero
|
||||||
|
test-suite exit an "expected pre-existing failure" (or baseline / known
|
||||||
|
failure) it must carry pre-merge proof; otherwise the closure buries an
|
||||||
|
unproven regression as an accepted baseline and weakens controller
|
||||||
|
confidence in the durable state.
|
||||||
|
|
||||||
|
This module fails closed on exactly that case by reusing the pre-merge
|
||||||
|
baseline proof verifier (#533). A closure report is allowed when it is
|
||||||
|
empty (no validation claim), a clean pass, or a baseline failure proven on
|
||||||
|
the PR pre-merge base commit (or a documented known-failure record that
|
||||||
|
predates the PR).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from premerge_baseline_proof import CLEAN_PASS, assess_premerge_baseline_proof
|
||||||
|
|
||||||
|
|
||||||
|
def assess_controller_closure_baseline_proof(
|
||||||
|
closure_report: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether an issue-closure report may proceed.
|
||||||
|
|
||||||
|
Returns a dict with ``block``, ``proven``, ``label``, ``reasons``,
|
||||||
|
``skipped`` and ``safe_next_action``. Only blocks when the closure
|
||||||
|
report claims a non-zero validation exit is an expected
|
||||||
|
pre-existing/baseline failure without valid pre-merge proof.
|
||||||
|
"""
|
||||||
|
text = (closure_report or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {
|
||||||
|
"block": False,
|
||||||
|
"proven": True,
|
||||||
|
"label": CLEAN_PASS,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": True,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = assess_premerge_baseline_proof(text)
|
||||||
|
block = bool(result.get("block"))
|
||||||
|
return {
|
||||||
|
"block": block,
|
||||||
|
"proven": not block,
|
||||||
|
"label": result.get("label"),
|
||||||
|
"reasons": list(result.get("reasons") or []),
|
||||||
|
"skipped": bool(result.get("skipped", False)),
|
||||||
|
"safe_next_action": (
|
||||||
|
result.get("safe_next_action")
|
||||||
|
or (
|
||||||
|
"provide pre-merge baseline proof (base commit, tested commit, "
|
||||||
|
"command, exit status, failure signature) before closing on an "
|
||||||
|
"expected pre-existing failure"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if block
|
||||||
|
else "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""Sanctioned pre-issue bootstrap for ``create_issue`` (#749).
|
||||||
|
|
||||||
|
``gitea_create_issue`` is a pure remote mutation: it creates a tracking issue
|
||||||
|
and writes nothing to the local working tree. The issue-first gate forbids
|
||||||
|
creating ``branches/issue-<N>-*`` before the issue number exists, while the
|
||||||
|
#274 branches-only guard previously demanded that worktree first — a deadlock.
|
||||||
|
|
||||||
|
This module defines a **narrow, phase-scoped** exemption:
|
||||||
|
|
||||||
|
* Only tasks in :data:`CREATE_ISSUE_TASKS` may use it.
|
||||||
|
* Only the **canonical control checkout** may be used (never an arbitrary
|
||||||
|
directory, unrelated worktree, or foreign clone).
|
||||||
|
* The control checkout must be clean, on an accepted base branch, and
|
||||||
|
base-equivalent to live master when a remote tip is known.
|
||||||
|
* Every post-creation author mutation keeps the ordinary ``branches/`` rule.
|
||||||
|
|
||||||
|
The exemption cannot widen: unknown tasks, dirty roots, drifted HEADs, non-base
|
||||||
|
branches, and non-control workspaces fall through to the existing fail-closed
|
||||||
|
guards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from author_mutation_worktree import BASE_BRANCHES, is_path_under_branches
|
||||||
|
from reviewer_worktree import parse_dirty_tracked_files
|
||||||
|
|
||||||
|
CREATE_ISSUE_TASKS = frozenset({"create_issue", "gitea_create_issue"})
|
||||||
|
|
||||||
|
# Satisfiable before an issue number exists — never names issue-<N>.
|
||||||
|
EXACT_NEXT_ACTION_BOOTSTRAP = (
|
||||||
|
"Restore the canonical control checkout to a clean accepted base branch "
|
||||||
|
"(master/main/dev) that matches live master, with no tracked local edits "
|
||||||
|
"and no detached HEAD. Re-resolve the exact create_issue task, then re-run "
|
||||||
|
"gitea_create_issue from that clean control checkout. Do not create "
|
||||||
|
"branches/issue-<N>-* worktrees, dummy directories, or borrow unrelated "
|
||||||
|
"worktrees before the issue exists."
|
||||||
|
)
|
||||||
|
|
||||||
|
EXACT_NEXT_ACTION_POST_CREATE = (
|
||||||
|
"After the issue exists: create a registered worktree under "
|
||||||
|
"branches/issue-<N>-* from clean master, claim/lock the issue, set "
|
||||||
|
"GITEA_AUTHOR_WORKTREE / worktree_path to that path, then continue author "
|
||||||
|
"mutations from the issue-backed worktree only."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_create_issue_task(task: str | None) -> bool:
|
||||||
|
"""True when *task* is the create_issue mutation (or tool alias)."""
|
||||||
|
return (task or "").strip() in CREATE_ISSUE_TASKS
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_sha(value: str | None) -> str | None:
|
||||||
|
"""Normalize a Git object id for comparison, or ``None`` when unknown.
|
||||||
|
|
||||||
|
Whitespace and case are the only permitted variation between two spellings
|
||||||
|
of the same commit; anything else is a different commit. Empty and
|
||||||
|
whitespace-only values normalize to ``None`` so an unknown tip can never
|
||||||
|
compare equal to another unknown tip.
|
||||||
|
"""
|
||||||
|
normalized = (value or "").strip().lower()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_create_issue_bootstrap(
|
||||||
|
*,
|
||||||
|
workspace_path: str,
|
||||||
|
canonical_repo_root: str,
|
||||||
|
current_branch: str | None = None,
|
||||||
|
head_sha: str | None = None,
|
||||||
|
porcelain_status: str = "",
|
||||||
|
remote_master_sha: str | None = None,
|
||||||
|
remote_master_sha_error: str | None = None,
|
||||||
|
task: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether create_issue may proceed from the control checkout.
|
||||||
|
|
||||||
|
Returns a structured assessment:
|
||||||
|
|
||||||
|
* ``not_applicable`` — not a create_issue task, or workspace is already a
|
||||||
|
``branches/`` worktree (use ordinary guards).
|
||||||
|
* ``allowed`` — create_issue bootstrap may proceed from this control root.
|
||||||
|
* ``block`` — create_issue was attempted from control checkout but gates
|
||||||
|
failed (dirty, wrong branch, base race, etc.).
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
root = os.path.realpath(canonical_repo_root or "")
|
||||||
|
workspace = os.path.realpath(workspace_path or root or ".")
|
||||||
|
branch = (current_branch or "").strip()
|
||||||
|
dirty = parse_dirty_tracked_files(porcelain_status or "")
|
||||||
|
under_branches = is_path_under_branches(workspace, root) if root else False
|
||||||
|
|
||||||
|
if not is_create_issue_task(task):
|
||||||
|
return _result(
|
||||||
|
not_applicable=True,
|
||||||
|
allowed=False,
|
||||||
|
block=False,
|
||||||
|
reasons=["task is not create_issue"],
|
||||||
|
workspace=workspace,
|
||||||
|
root=root,
|
||||||
|
branch=branch,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=under_branches,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Registered branches/ worktrees keep the normal path (no bootstrap).
|
||||||
|
if under_branches:
|
||||||
|
return _result(
|
||||||
|
not_applicable=True,
|
||||||
|
allowed=False,
|
||||||
|
block=False,
|
||||||
|
reasons=["workspace is under branches/; ordinary #274 path applies"],
|
||||||
|
workspace=workspace,
|
||||||
|
root=root,
|
||||||
|
branch=branch,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only the exact canonical control checkout is eligible.
|
||||||
|
if not root or workspace != root:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap requires the canonical control checkout; "
|
||||||
|
f"workspace '{workspace}' is not the repository root '{root or '(unknown)'}'"
|
||||||
|
)
|
||||||
|
return _result(
|
||||||
|
not_applicable=False,
|
||||||
|
allowed=False,
|
||||||
|
block=True,
|
||||||
|
reasons=reasons,
|
||||||
|
workspace=workspace,
|
||||||
|
root=root,
|
||||||
|
branch=branch,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=False,
|
||||||
|
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
if dirty:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: control checkout has tracked local "
|
||||||
|
f"edits (dirty files: {', '.join(dirty)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not branch:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: control checkout is detached HEAD; "
|
||||||
|
"expected an accepted base branch (master/main/dev)"
|
||||||
|
)
|
||||||
|
elif branch not in BASE_BRANCHES:
|
||||||
|
reasons.append(
|
||||||
|
f"create_issue bootstrap blocked: control checkout branch '{branch}' "
|
||||||
|
f"is not an accepted base branch ({'/'.join(sorted(BASE_BRANCHES))})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# #757 AC3/AC4: base-equivalence must be *proven*, never assumed. An
|
||||||
|
# unknown tip on either side is not evidence of agreement, so a missing
|
||||||
|
# local HEAD, an unresolvable live master, or a resolver failure all block.
|
||||||
|
remote_tip = normalize_sha(remote_master_sha)
|
||||||
|
local_tip = normalize_sha(head_sha)
|
||||||
|
resolver_error = (remote_master_sha_error or "").strip() or None
|
||||||
|
|
||||||
|
if not local_tip:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: control checkout HEAD SHA is "
|
||||||
|
"unknown; base equivalence to live master cannot be proven "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolver_error:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: live master tip could not be "
|
||||||
|
f"resolved ({resolver_error}); base equivalence cannot be proven "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
elif not remote_tip:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: live master tip is unknown; base "
|
||||||
|
"equivalence to live master cannot be proven (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if remote_tip and local_tip and remote_tip != local_tip:
|
||||||
|
reasons.append(
|
||||||
|
"create_issue bootstrap blocked: control checkout HEAD does not match "
|
||||||
|
f"live master (HEAD {local_tip[:12]}, live master {remote_tip[:12]})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return _result(
|
||||||
|
not_applicable=False,
|
||||||
|
allowed=False,
|
||||||
|
block=True,
|
||||||
|
reasons=reasons,
|
||||||
|
workspace=workspace,
|
||||||
|
root=root,
|
||||||
|
branch=branch or None,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=False,
|
||||||
|
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
|
||||||
|
local_head_sha=local_tip,
|
||||||
|
remote_master_sha=remote_tip,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _result(
|
||||||
|
not_applicable=False,
|
||||||
|
allowed=True,
|
||||||
|
block=False,
|
||||||
|
reasons=[],
|
||||||
|
workspace=workspace,
|
||||||
|
root=root,
|
||||||
|
branch=branch or None,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=False,
|
||||||
|
exact_next_action=EXACT_NEXT_ACTION_POST_CREATE,
|
||||||
|
bootstrap_path="clean_canonical_control_checkout",
|
||||||
|
local_head_sha=local_tip,
|
||||||
|
remote_master_sha=remote_tip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_permits_control_checkout(
|
||||||
|
assessment: Any,
|
||||||
|
*,
|
||||||
|
task: str | None,
|
||||||
|
workspace_path: str | None,
|
||||||
|
canonical_repo_root: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Single interpretation of a bootstrap assessment (#757).
|
||||||
|
|
||||||
|
Both author-mutation guards — the #274 branches-only enforcer and the #604
|
||||||
|
anti-stomp preflight — route their "may this workspace mutate" decision
|
||||||
|
through this predicate, so the two can never reach opposite conclusions
|
||||||
|
about identical evidence.
|
||||||
|
|
||||||
|
Fail-closed by construction. Every proof obligation must be present and
|
||||||
|
affirmative in *assessment*, and the assessment must describe the very
|
||||||
|
workspace and canonical root being guarded. A missing, malformed, refused,
|
||||||
|
incomplete, or contradictory assessment returns ``False``, which leaves the
|
||||||
|
caller's ordinary block in force.
|
||||||
|
|
||||||
|
``assessment`` is server-derived only: it is produced by
|
||||||
|
:func:`assess_create_issue_bootstrap` from inspected repository state. It is
|
||||||
|
never accepted from an MCP tool argument, so no caller can assert
|
||||||
|
eligibility it has not proven.
|
||||||
|
"""
|
||||||
|
if not isinstance(assessment, dict):
|
||||||
|
return False
|
||||||
|
if not is_create_issue_task(task):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Positive proof: the assessment must affirmatively allow, with no
|
||||||
|
# competing refusal or not-applicable disposition recorded alongside it.
|
||||||
|
if assessment.get("allowed") is not True:
|
||||||
|
return False
|
||||||
|
if assessment.get("proven") is not True:
|
||||||
|
return False
|
||||||
|
if assessment.get("block") is not False:
|
||||||
|
return False
|
||||||
|
if assessment.get("not_applicable") is not False:
|
||||||
|
return False
|
||||||
|
if assessment.get("reasons"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Scope proof: only the create_issue bootstrap, only via the clean
|
||||||
|
# canonical control checkout path.
|
||||||
|
if assessment.get("task_scope") != "create_issue_only":
|
||||||
|
return False
|
||||||
|
if assessment.get("bootstrap_path") != "clean_canonical_control_checkout":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# State proof: clean, and not a branches/ worktree (those keep #274).
|
||||||
|
if assessment.get("dirty_files"):
|
||||||
|
return False
|
||||||
|
if assessment.get("under_branches") is not False:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Base-equivalence proof (#757 AC3/AC4): both tips must be recorded,
|
||||||
|
# nonempty, and equal. Re-derived here rather than trusted from the
|
||||||
|
# assessment's own flag, so a hand-built or truncated assessment cannot
|
||||||
|
# assert agreement it never proved.
|
||||||
|
if assessment.get("base_tips_verified") is not True:
|
||||||
|
return False
|
||||||
|
local_tip = normalize_sha(assessment.get("local_head_sha"))
|
||||||
|
remote_tip = normalize_sha(assessment.get("remote_master_sha"))
|
||||||
|
if not local_tip or not remote_tip or local_tip != remote_tip:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Binding proof: the assessment must describe *this* workspace and root,
|
||||||
|
# and that workspace must be exactly the canonical control checkout.
|
||||||
|
root = os.path.realpath(canonical_repo_root or "")
|
||||||
|
workspace = os.path.realpath(workspace_path or root or ".")
|
||||||
|
if not root or workspace != root:
|
||||||
|
return False
|
||||||
|
if assessment.get("canonical_repo_root") != root:
|
||||||
|
return False
|
||||||
|
if assessment.get("workspace_path") != workspace:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def format_create_issue_bootstrap_error(assessment: dict[str, Any]) -> str:
|
||||||
|
"""RuntimeError / typed-block message for a failed bootstrap assessment."""
|
||||||
|
reasons = "; ".join(
|
||||||
|
assessment.get("reasons") or ["create_issue bootstrap failed"]
|
||||||
|
)
|
||||||
|
next_action = (
|
||||||
|
assessment.get("exact_next_action") or EXACT_NEXT_ACTION_BOOTSTRAP
|
||||||
|
)
|
||||||
|
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||||
|
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||||
|
return (
|
||||||
|
f"Create-issue bootstrap guard (#749): {reasons}. "
|
||||||
|
f"canonical repository root: {root}; workspace: {workspace}. "
|
||||||
|
f"exact_next_action: {next_action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
*,
|
||||||
|
not_applicable: bool,
|
||||||
|
allowed: bool,
|
||||||
|
block: bool,
|
||||||
|
reasons: list[str],
|
||||||
|
workspace: str,
|
||||||
|
root: str,
|
||||||
|
branch: str | None,
|
||||||
|
dirty: list[str],
|
||||||
|
under_branches: bool,
|
||||||
|
exact_next_action: str | None = None,
|
||||||
|
bootstrap_path: str | None = None,
|
||||||
|
local_head_sha: str | None = None,
|
||||||
|
remote_master_sha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# #757 AC3/AC4: equality is recorded only when BOTH tips are known, so a
|
||||||
|
# consumer can never read agreement out of two missing values.
|
||||||
|
base_tips_verified = bool(
|
||||||
|
local_head_sha and remote_master_sha and local_head_sha == remote_master_sha
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"not_applicable": not_applicable,
|
||||||
|
"allowed": allowed,
|
||||||
|
"block": block,
|
||||||
|
"proven": allowed and not block,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"canonical_repo_root": root,
|
||||||
|
"current_branch": branch,
|
||||||
|
"dirty_files": list(dirty),
|
||||||
|
"under_branches": under_branches,
|
||||||
|
"exact_next_action": exact_next_action,
|
||||||
|
"bootstrap_path": bootstrap_path,
|
||||||
|
"task_scope": "create_issue_only",
|
||||||
|
"local_head_sha": local_head_sha,
|
||||||
|
"remote_master_sha": remote_master_sha,
|
||||||
|
"base_tips_verified": base_tips_verified,
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Control-plane DB substrate (#613)
|
||||||
|
|
||||||
|
**Status:** Implemented (SQLite single-writer MVP)
|
||||||
|
|
||||||
|
**ADR:** [`mcp-allocator-control-plane-observability-adr.md`](mcp-allocator-control-plane-observability-adr.md)
|
||||||
|
|
||||||
|
**Module:** `control_plane_db.py`
|
||||||
|
|
||||||
|
## Architecture statement
|
||||||
|
|
||||||
|
> **DB coordinates, Gitea records, Sentry/GlitchTip observe; the bridge is the only path that turns observations into Gitea work.**
|
||||||
|
|
||||||
|
## What this ships
|
||||||
|
|
||||||
|
| Capability | Notes |
|
||||||
|
|------------|--------|
|
||||||
|
| Schema | `sessions`, `work_items`, `leases`, `assignments`, `terminal_locks`, `events`, `incident_links` |
|
||||||
|
| Atomic assign+lease | `ControlPlaneDB.assign_and_lease` — one `BEGIN IMMEDIATE` transaction |
|
||||||
|
| Mutation gate | `require_valid_assignment` — live lease + allowed action + non-terminal work + non-stale head |
|
||||||
|
| Heartbeat / release / expire | Lease lifecycle helpers |
|
||||||
|
| Terminal-lock index | Routing signal for #600 (terminal path first) |
|
||||||
|
| `incident_links` | Provider-neutral link model for #612 — **not** assignable work; scope keys NULL-safe |
|
||||||
|
|
||||||
|
## Hard rules (enforced in code)
|
||||||
|
|
||||||
|
1. Assignable `work_items.kind` ∈ {`issue`, `pr`} only — **never** raw Sentry/GlitchTip incidents.
|
||||||
|
2. Two concurrent sessions cannot both receive an active assignment on the same open work item (second gets `wait`).
|
||||||
|
3. Merged/closed work items return `no_safe_work` at assign time, and `require_valid_assignment` fails closed if the work item becomes terminal later.
|
||||||
|
4. Assignments pin `expected_head_sha`; mutations fail closed if the work item head drifts.
|
||||||
|
5. SQLite path is the **single-writer MVP** (`GITEA_CONTROL_PLANE_DB`, default under `~/.cache/gitea-tools/control-plane/`). Multi-session multi-host production requires **Postgres** or a **single allocator daemon** (ADR §6).
|
||||||
|
6. `incident_links` optional scope fields are stored as empty strings (never NULL) so UNIQUE is canonical across minimal upserts.
|
||||||
|
7. Legacy `incident_links` migration collapses NULL-scope duplicates **only** when Gitea targets **and** all meaningful observation metadata agree (fingerprint, status, event_count, permalink, timestamps, linked PRs, etc.). Conflicting metadata fails closed — no silent discard.
|
||||||
|
|
||||||
|
## Dependency chain
|
||||||
|
|
||||||
|
```text
|
||||||
|
#613 control-plane DB (this) → #600 allocator API → #612 incident bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
- **#600** must call this substrate (not file locks / comment-only leases alone) for completion.
|
||||||
|
- **#612** must write `incident_links` here and create **Gitea issues**; the allocator assigns those issues, not raw incidents.
|
||||||
|
|
||||||
|
## Allocator API (#600)
|
||||||
|
|
||||||
|
Module: `allocator_service.py` · MCP tool: `gitea_allocate_next_work`
|
||||||
|
|
||||||
|
- Workers call `gitea_allocate_next_work(apply=false|true)` instead of self-selecting work.
|
||||||
|
- `apply=false` returns a dry-run selection (`outcome=preview`) with skip reasons.
|
||||||
|
- `apply=true` reserves via `ControlPlaneDB.assign_and_lease` (atomic assignment+lease).
|
||||||
|
- Coordination source is **always** the control-plane DB — not file locks or comment-only leases.
|
||||||
|
- Routing follows ADR §5.3 (REQUEST_CHANGES → author, terminal path first, foreign lease → wait).
|
||||||
|
- Raw monitoring incidents are never candidates.
|
||||||
|
|
||||||
|
|
||||||
|
## Lease lifecycle API (#601)
|
||||||
|
|
||||||
|
Module: `lease_lifecycle.py` · MCP tools: `gitea_*_workflow_lease(s)`
|
||||||
|
|
||||||
|
Active control-plane leases are **first-class workflow state**:
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `gitea_list_workflow_leases` | List active (or all) leases for a repo |
|
||||||
|
| `gitea_inspect_workflow_lease` | Freshness + `safe_next_action` for one lease id |
|
||||||
|
| `gitea_adopt_workflow_lease` | Owner-resume or sanctioned reclaim with provenance |
|
||||||
|
| `gitea_release_workflow_lease` | Explicit owner release (audited) |
|
||||||
|
| `gitea_expire_workflow_leases` | Deterministic expire of past-`expires_at` leases |
|
||||||
|
| `gitea_abandon_workflow_lease` | Abandon with required proof |
|
||||||
|
| `gitea_reclaim_expired_workflow_lease` | Expire + re-assign with provenance |
|
||||||
|
|
||||||
|
### Hard rules
|
||||||
|
|
||||||
|
1. Control-plane DB is the coordination authority — file locks / comment-only leases are not authoritative alone.
|
||||||
|
2. Active foreign leases cannot be stolen; inspect returns `wait_foreign_active`.
|
||||||
|
3. Abandon requires `(dead_process OR missing_worktree)` and `no_live_mutation_risk`; foreign abandon also needs `operator_authorized` or full dead+missing+no_open_pr proof.
|
||||||
|
4. Adopt provenance always records `adopted_from_session_id`, `adopted_by_session_id`, work identity, optional head SHA, and worktree path.
|
||||||
|
5. Reviewer→merger comment handoff (`gitea_adopt_merger_pr_lease`) is unchanged and remains the Gitea-thread durability path.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pytest tests/test_lease_lifecycle.py tests/test_control_plane_db.py tests/test_allocator_service.py tests/test_merger_lease_adoption.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Incident bridge (#612)
|
||||||
|
|
||||||
|
Module: `incident_bridge.py` · MCP tools: `gitea_observability_*`
|
||||||
|
|
||||||
|
- Bridge converts Sentry/GlitchTip observations → **normal Gitea issues** + `incident_links`.
|
||||||
|
- Phase-1: `gitea_observability_reconcile_incident(apply=false|true)` with JSON observation.
|
||||||
|
- Dry-run performs **no** Gitea mutation and **no** DB write.
|
||||||
|
- Apply reuses existing links or creates one Gitea issue; never invents `work_items.kind=incident`.
|
||||||
|
- Config: `GITEA_OBSERVABILITY_PROJECTS_JSON` or `GITEA_OBSERVABILITY_PROJECTS_FILE`.
|
||||||
|
- Provider tokens never appear in issue bodies, links, or tool results.
|
||||||
|
- Allocator sees bridge work only after a Gitea issue exists.
|
||||||
|
|
||||||
|
## Non-goals (intentionally deferred)
|
||||||
|
|
||||||
|
- Full unsupervised watchdog auto-filing (prefer explicit reconcile first)
|
||||||
|
- Assuming GlitchTip writeback API equals Sentry without verification
|
||||||
|
- Gitea comment/label mirror writers for every assignment (optional later)
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pytest tests/test_control_plane_db.py -q
|
||||||
|
```
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
# ADR: MCP allocator, control-plane DB, and Sentry/GlitchTip incident bridge architecture
|
||||||
|
|
||||||
|
- **Status:** Accepted (implementation pending; blocks code for #600 / #612 / #613)
|
||||||
|
- **Date:** 2026-07-09
|
||||||
|
- **Tracking issues:**
|
||||||
|
- [#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613) — control-plane DB (first)
|
||||||
|
- [#600](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/600) — allocator API (second)
|
||||||
|
- [#612](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/612) — Sentry/GlitchTip incident bridge (third)
|
||||||
|
- **Related:** existing GlitchTip contracts (`glitchtip-to-gitea-workflow-design.md`, `glitchtip-gitea-deduplication-linking-design.md`), trust boundaries (`tool-boundaries.md`, `safety-model.md`), current leases (`pr_work_lease.py`, `issue_lock_store.py`)
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
Multiple LLM sessions can independently inspect the Gitea queue and start the same issue or PR. Existing coordination (Gitea comment leases, local issue-lock files, labels) often detects collisions **after** work has started. Parallel issues #600, #612, and #613 each describe part of a fix; without one ADR they risk overlapping stores, wrong dependency order, and trust-boundary violations.
|
||||||
|
|
||||||
|
This ADR is the canonical architecture decision **before** implementing those issues.
|
||||||
|
|
||||||
|
## 2. Decision summary (core)
|
||||||
|
|
||||||
|
| Layer | Owns | Must not |
|
||||||
|
|-------|------|----------|
|
||||||
|
| **Control-plane DB** | Sessions, atomic assignment, leases, heartbeats, terminal-lock index, events, incident links | Bypass Gitea workflow gates or replace issue/PR history |
|
||||||
|
| **Gitea** | Durable work record: issues, PRs, comments, labels, reviews, merges | Be the only concurrency lock under multi-session load |
|
||||||
|
| **Sentry / GlitchTip** | Incidents, events, provider UI | Assign work, approve/merge/close, or mutate Gitea outside the bridge |
|
||||||
|
| **Incident bridge** | Provider adapters, reconcile/create Gitea issues, link storage upsert, optional provider writeback | Hand raw provider incidents to the allocator as work items |
|
||||||
|
|
||||||
|
**One-liner:** **DB coordinates. Gitea records. Sentry/GlitchTip observe. The bridge is the only path that turns observations into Gitea work. The allocator assigns only Gitea issues/PRs, never raw monitoring incidents.**
|
||||||
|
|
||||||
|
## 3. Dependency order
|
||||||
|
|
||||||
|
Implementation **must** follow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
#613 control-plane DB → #600 allocator API → #612 incident bridge
|
||||||
|
(atomic substrate) (routing policy) (feeds Gitea work)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Issue | Role | Depends on |
|
||||||
|
|-------|------|------------|
|
||||||
|
| **#613** | Durable coordination DB + lease/assignment transactions | — |
|
||||||
|
| **#600** | `gitea_allocate_next_work` policy and tool surface | **#613** (hard) |
|
||||||
|
| **#612** | Multi-project Sentry/GlitchTip → Gitea bridge | **#613** for `incident_links` index; **#600** before treating bridge-created issues as allocator feed in multi-worker prod |
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
|
||||||
|
1. Do **not** implement #600 on file locks / comment-only leases and call it done.
|
||||||
|
2. Do **not** ship #612 as a second assignment system for raw incidents.
|
||||||
|
3. Partial previews (read-only list tools, schema stubs) may land earlier if they do not claim “allocator complete” or “bridge complete.”
|
||||||
|
|
||||||
|
## 4. Authority boundaries
|
||||||
|
|
||||||
|
### 4.1 Gitea (durable source of truth for work)
|
||||||
|
|
||||||
|
Gitea remains authoritative for:
|
||||||
|
|
||||||
|
- Issue and PR identity, titles, bodies, state (open/closed/merged)
|
||||||
|
- Comments, reviews, approvals, REQUEST_CHANGES
|
||||||
|
- Labels and workflow status labels (`status:ready`, `status:in-progress`, …)
|
||||||
|
- Merges, closes, branch refs as recorded by Gitea/git hosting
|
||||||
|
|
||||||
|
Operators and auditors read **history** from Gitea.
|
||||||
|
|
||||||
|
### 4.2 Control-plane DB (coordination / index)
|
||||||
|
|
||||||
|
The control-plane DB is authoritative for **live multi-session coordination**:
|
||||||
|
|
||||||
|
- Which session holds which assignment/lease
|
||||||
|
- Heartbeat freshness and expiry
|
||||||
|
- Terminal-lock index for routing
|
||||||
|
- Event log of allocation/lease transitions
|
||||||
|
- `incident_links` index (see §8)
|
||||||
|
|
||||||
|
It is **not** a substitute for Gitea history. When DB and Gitea disagree on durable work state (e.g. PR already merged), **Gitea wins**; the DB is reconciled.
|
||||||
|
|
||||||
|
### 4.3 Sentry / GlitchTip (observe)
|
||||||
|
|
||||||
|
Providers own incident lifecycle and raw event data. They:
|
||||||
|
|
||||||
|
- Must **not** bypass Gitea gates
|
||||||
|
- Must **not** assign LLM work
|
||||||
|
- May receive **writeback** of resolution status only through the bridge, when configured and supported
|
||||||
|
|
||||||
|
### 4.4 Trust boundaries for credentials
|
||||||
|
|
||||||
|
Aligned with `docs/tool-boundaries.md` and `docs/safety-model.md`:
|
||||||
|
|
||||||
|
- **One MCP server process per trust boundary** remains the default.
|
||||||
|
- Monitor API tokens (Sentry/GlitchTip) live in the **bridge/orchestrator boundary** (or a dedicated observability write/read profile), **not** blindly inside every Gitea MCP author/reviewer/merger process.
|
||||||
|
- Gitea tokens stay on Gitea MCP profiles only.
|
||||||
|
- Orchestrators compose services; they must not become a single credential pool that silently mixes Gitea write + monitor tokens into every worker.
|
||||||
|
|
||||||
|
Tool names such as `gitea_observability_*` may exist as a **namespace façade** only if the runtime still enforces separate credential scopes (e.g. bridge process vs pure Gitea mutation process).
|
||||||
|
|
||||||
|
## 5. Allocator rules (#600 on top of #613)
|
||||||
|
|
||||||
|
### 5.1 Worker contract
|
||||||
|
|
||||||
|
- Workers **do not self-select** work under the standard multi-LLM workflow.
|
||||||
|
- Workers call **`gitea_allocate_next_work`** (with `apply=true` only when taking work).
|
||||||
|
- Mutations that claim exclusive work require a **valid assignment and lease** from the control-plane DB.
|
||||||
|
- Return values include at least: role, issue/PR number, expected head SHA (when applicable), lease ID, expiry, allowed actions, forbidden actions — or a non-assignment outcome (`WAIT`, terminal-path block, no safe work, needs controller, etc.).
|
||||||
|
|
||||||
|
### 5.2 Atomic reserve
|
||||||
|
|
||||||
|
- **Assignment creation and lease creation happen in one DB transaction.**
|
||||||
|
- Two concurrent sessions **must not** receive the same issue/PR as assigned work.
|
||||||
|
- On contention: second session gets **WAIT** or **owner-resume**, never a duplicate lease.
|
||||||
|
|
||||||
|
### 5.3 Routing policy
|
||||||
|
|
||||||
|
| Condition | Route |
|
||||||
|
|-----------|--------|
|
||||||
|
| Current-head **REQUEST_CHANGES** | **Author** (not reviewer/merger) |
|
||||||
|
| **Stale** approval (approval not on current head) | **Reviewer** |
|
||||||
|
| **Clean** approval on current head, mergeable | **Merger** |
|
||||||
|
| Contested / contaminated approval | **Diagnosis / reconciler** (controller path) |
|
||||||
|
| Active **foreign** lease | **WAIT** or **owner-resume** |
|
||||||
|
| **Terminal-review** lock present | **Terminal-path resolution first** before downstream review work |
|
||||||
|
| Merged / closed PR | **Never assign** |
|
||||||
|
| Issue locked by sanctioned lease | **Never assign** to another worker unless lease cleanup/adoption is sanctioned |
|
||||||
|
|
||||||
|
### 5.4 Relationship to Gitea mirrors
|
||||||
|
|
||||||
|
After a successful assignment, the allocator (or sanctioned tooling) may update Gitea comments/labels so humans and audits see state. Those mirrors **are not** the sole lock source.
|
||||||
|
|
||||||
|
## 6. Control-plane DB topology (#613)
|
||||||
|
|
||||||
|
### 6.1 Preferred architecture (multi-daemon / Proxmox)
|
||||||
|
|
||||||
|
For true multi-session concurrency (multiple MCP daemons, multiple hosts, or Proxmox VMs):
|
||||||
|
|
||||||
|
**Prefer either:**
|
||||||
|
|
||||||
|
1. **Shared Postgres** used by all Gitea MCP profiles / allocator callers, **or**
|
||||||
|
2. A **single allocator daemon** (sole writer to the coordination store) that all workers call over MCP/RPC.
|
||||||
|
|
||||||
|
Both satisfy: one transactional authority for “who has this work item.”
|
||||||
|
|
||||||
|
### 6.2 SQLite MVP
|
||||||
|
|
||||||
|
SQLite is acceptable **only** for a **single-writer MVP**:
|
||||||
|
|
||||||
|
- One process owns writes (allocator daemon or single co-located MCP server), **or**
|
||||||
|
- Documented single-host single-daemon experiments with file locking and no multi-host claims.
|
||||||
|
|
||||||
|
SQLite is **not** sufficient to declare multi-session production readiness when four independent LLM sessions talk to four MCP processes without a shared writer.
|
||||||
|
|
||||||
|
### 6.3 Suggested entities (normative shape)
|
||||||
|
|
||||||
|
Minimum logical entities (names may vary; semantics must not):
|
||||||
|
|
||||||
|
1. **sessions** — session_id, role/profile, namespace, pid, started_at, last_heartbeat_at, status
|
||||||
|
2. **work_items** — remote/org/repo, kind ∈ {`issue`, `pr`}, number, state, priority, current_head_sha, updated_at
|
||||||
|
3. **leases** — lease_id, work_item_id, session_id, role, phase, expires_at, heartbeat_at, status
|
||||||
|
4. **assignments** — assignment_id, work_item_id, session_id, allowed_action(s), expected_head_sha, status
|
||||||
|
5. **terminal_locks** — repo/org, terminal_pr, review_id, decision, status, cleanup_state
|
||||||
|
6. **events** — event_id, work_item_id, type, message, created_at
|
||||||
|
7. **incident_links** — provider-neutral link model (see §8)
|
||||||
|
|
||||||
|
**Explicit non-kind:** do **not** use `work_items.kind = sentry_incident` (or glitchtip_incident) as an assignable work unit. Incidents become work only after the bridge creates/links a **Gitea issue**.
|
||||||
|
|
||||||
|
## 7. Lease migration (avoid split-brain)
|
||||||
|
|
||||||
|
### 7.1 Target state
|
||||||
|
|
||||||
|
- **Primary** for live coordination: **control-plane DB** leases and assignments.
|
||||||
|
- **Gitea comment leases** (e.g. `<!-- mcp-review-lease:v1 -->`) and **local issue-lock files** become:
|
||||||
|
- **mirrors** of DB state for human visibility / recovery, and/or
|
||||||
|
- written **only** through the allocator (or sanctioned lease tools that update DB + mirror in one workflow).
|
||||||
|
|
||||||
|
### 7.2 Migration rules
|
||||||
|
|
||||||
|
1. New exclusive claims go through DB assignment+lease first.
|
||||||
|
2. Comment lease / file lock writers that skip the DB are **deprecated** once #613+#600 land.
|
||||||
|
3. Reconciler tooling heals: expired DB lease, orphan Gitea comment, orphan local file — fail closed when ambiguous.
|
||||||
|
4. **Split-brain is a defect:** “DB free + Gitea comment leased” or “DB leased + Gitea free” must be detectable and reconcilable; production paths must not rely on two independent writers.
|
||||||
|
|
||||||
|
### 7.3 Heartbeats
|
||||||
|
|
||||||
|
- Heartbeats update the **DB**.
|
||||||
|
- Optional Gitea summaries must avoid comment spam (periodic summary or edit-in-place policy).
|
||||||
|
|
||||||
|
## 8. Observability bridge (#612)
|
||||||
|
|
||||||
|
### 8.1 Role
|
||||||
|
|
||||||
|
The bridge:
|
||||||
|
|
||||||
|
1. Scans configured Sentry and/or GlitchTip projects (multi-project mappings).
|
||||||
|
2. Deduplicates against existing links / Gitea issues.
|
||||||
|
3. Creates or updates **normal Gitea issues** (labels, sanitized body, provider URL).
|
||||||
|
4. Upserts **one** canonical `incident_links` row.
|
||||||
|
5. Optionally writes resolution status back to the provider when supported.
|
||||||
|
6. Never approves, merges, closes, or otherwise bypasses Gitea workflow gates.
|
||||||
|
|
||||||
|
### 8.2 Allocator interaction
|
||||||
|
|
||||||
|
- The allocator sees observability work **only after** a Gitea issue exists (typically `status:ready` + observability labels).
|
||||||
|
- **Do not assign raw Sentry/GlitchTip incidents** as work items.
|
||||||
|
- Bridge AC “allocator can see linked issues” means: **as ordinary Gitea issues**, not a special incident queue.
|
||||||
|
|
||||||
|
### 8.3 Provider adapters and trust
|
||||||
|
|
||||||
|
- Separate **Sentry** and **GlitchTip** adapters; document API differences; do not assume writeback parity.
|
||||||
|
- Self-hosted base URLs supported (e.g. `https://sentry.prgs.cc`).
|
||||||
|
- Tokens from environment / secret store only.
|
||||||
|
- Prefer phase-1 **explicit reconcile / dry-run** before unsupervised watchdog auto-filing, consistent with existing GlitchTip filing safety contracts.
|
||||||
|
|
||||||
|
### 8.4 Home of implementation
|
||||||
|
|
||||||
|
Filing/orchestration may live in:
|
||||||
|
|
||||||
|
- a control-plane / bridge package or profile, and/or
|
||||||
|
- Gitea-Tools as operator-facing tools that **delegate** to that boundary,
|
||||||
|
|
||||||
|
but must not violate §4.4 credential isolation.
|
||||||
|
|
||||||
|
## 9. Incident link storage (single source of truth)
|
||||||
|
|
||||||
|
### 9.1 Canonical model: `incident_links` (provider-neutral)
|
||||||
|
|
||||||
|
Prefer a **provider-neutral** model over Sentry-only `sentry_links`:
|
||||||
|
|
||||||
|
| Field (logical) | Purpose |
|
||||||
|
|-----------------|---------|
|
||||||
|
| provider | `sentry` \| `glitchtip` \| … |
|
||||||
|
| provider_base_url | Self-hosted base |
|
||||||
|
| provider_org / provider_project | Mapping key |
|
||||||
|
| provider_issue_id / provider_short_id | Provider identity |
|
||||||
|
| provider_permalink | Human URL |
|
||||||
|
| fingerprint | Stable dedupe key when available |
|
||||||
|
| gitea_org / gitea_repo / gitea_issue_number | Linked work |
|
||||||
|
| linked_pr_numbers | Optional PR association |
|
||||||
|
| first_seen / last_seen / event_count | Summary metrics (safe) |
|
||||||
|
| status | link lifecycle |
|
||||||
|
| release_resolved_at / last_sync_at | Sync bookkeeping |
|
||||||
|
|
||||||
|
### 9.2 Gitea as human mirror
|
||||||
|
|
||||||
|
- Issue body markers / structured comments (e.g. HTML comment metadata) remain the **human- and audit-visible mirror**.
|
||||||
|
- They must stay consistent with `incident_links` but are **not** a second independent write path for inventing links without the bridge.
|
||||||
|
|
||||||
|
### 9.3 One truth
|
||||||
|
|
||||||
|
- **Do not** maintain two independent systems of record (e.g. full mapping only in #612 storage **and** a separate #613 `sentry_links` with different semantics).
|
||||||
|
- #612 implementation **must** use the same `incident_links` model introduced under #613 (or a single agreed store both reference).
|
||||||
|
|
||||||
|
## 10. Security and redaction
|
||||||
|
|
||||||
|
Mandatory for bridge payloads, Gitea issue bodies/comments, DB-stored event summaries, and logs:
|
||||||
|
|
||||||
|
- No API tokens, DSNs, passwords, cookies, auth headers
|
||||||
|
- No keychain IDs, private config contents, raw session-state files, full prompt bodies
|
||||||
|
- Sanitize stack locals and request data; store only safe tags/context
|
||||||
|
- Logs must never print provider tokens or DSNs
|
||||||
|
- Redaction tests are required for #612
|
||||||
|
|
||||||
|
## 11. Non-goals
|
||||||
|
|
||||||
|
- Replace Gitea as the durable workflow record
|
||||||
|
- Let Sentry/GlitchTip assign work or mutate Gitea workflow state outside sanctioned tools
|
||||||
|
- Let the bridge approve, merge, close, release, or bypass Gitea gates
|
||||||
|
- Implement #600 on file locks / comments alone and call allocator complete
|
||||||
|
- Treat raw monitoring incidents as `work_items` for the allocator
|
||||||
|
- Put monitor tokens into every Gitea MCP worker process by default
|
||||||
|
|
||||||
|
## 12. Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Clear implementation order and ownership
|
||||||
|
- Multi-session safety becomes testable at the DB layer
|
||||||
|
- Observability becomes assignable work without special-casing the allocator
|
||||||
|
- Trust boundaries stay compatible with existing control-plane docs
|
||||||
|
|
||||||
|
### Costs / risks
|
||||||
|
|
||||||
|
- Migration from comment/file leases requires reconciler work
|
||||||
|
- Shared Postgres or single allocator daemon is operational cost
|
||||||
|
- Bridge must be careful not to spam Gitea issues (dedupe, caps, policy modes)
|
||||||
|
|
||||||
|
### Follow-ups (documentation only until implemented)
|
||||||
|
|
||||||
|
1. Update #600, #612, and #613 bodies to **link this ADR** and restate hard dependencies.
|
||||||
|
2. Implement #613 schema + atomic assign/lease.
|
||||||
|
3. Implement #600 against the DB.
|
||||||
|
4. Implement #612 against `incident_links` + Gitea create/update.
|
||||||
|
5. Deprecate dual writers for leases.
|
||||||
|
|
||||||
|
## 13. Acceptance criteria for *this* ADR
|
||||||
|
|
||||||
|
This document is accepted when:
|
||||||
|
|
||||||
|
1. It is merged into `docs/architecture/` on the default branch.
|
||||||
|
2. #600 / #612 / #613 (or their PRs) reference this path as the architecture source of truth.
|
||||||
|
3. No implementation PR for those issues claims completion without conforming to §§2–11.
|
||||||
|
|
||||||
|
## 14. Document history
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-09 | Initial ADR: dependency order, authority model, topology, lease migration, bridge, `incident_links`, security, non-goals |
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# ADR: Stable control runtime vs dev runtime (Gitea MCP)
|
||||||
|
|
||||||
|
- **Status:** Accepted (policy effective immediately for LLM sessions; tooling may lag)
|
||||||
|
- **Date:** 2026-07-09
|
||||||
|
- **Tracking issue:** [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615)
|
||||||
|
- **Related:**
|
||||||
|
- [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) / `docs/mcp-namespace-health.md` — client-namespace health
|
||||||
|
- `docs/mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery (no PID kill)
|
||||||
|
- [#558](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/558) / `docs/mcp-daemon-import-guard.md` — sanctioned daemon
|
||||||
|
- [#557](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/557) / `docs/bootstrap-review-path.md` — controller bootstrap for self-hosted fixes
|
||||||
|
- Allocator / control-plane ADR: `docs/architecture/mcp-allocator-control-plane-observability-adr.md` (#613 / PR #614)
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
The Gitea MCP server is the **control plane** for real issue/PR mutations (create, comment, lock, review, merge, etc.). When author/reviewer/merger/reconciler sessions kill or restart that process, relaunch it from a feature worktree, or edit the checkout that process loads, operators observe:
|
||||||
|
|
||||||
|
- Mid-session identity/preflight resets
|
||||||
|
- Stale-runtime vs master parity failures
|
||||||
|
- IDE transport EOF / “tool not found” while code on disk has changed
|
||||||
|
- Accidental production mutations from experimental code
|
||||||
|
|
||||||
|
This ADR separates **stable control runtime** from **dev/test runtime** and defines promotion proof.
|
||||||
|
|
||||||
|
## 2. Decision
|
||||||
|
|
||||||
|
### 2.1 Stable control runtime
|
||||||
|
|
||||||
|
The Gitea MCP server used for **real workflow mutations** is the **stable control runtime**.
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- Loads a known, promoted revision of Gitea-Tools (or the packaged release layout operators designate)
|
||||||
|
- Registered in the IDE/client as the production namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, etc.)
|
||||||
|
- Holds production profile credentials via sanctioned keychain/env paths only
|
||||||
|
|
||||||
|
### 2.2 Dev / test runtime
|
||||||
|
|
||||||
|
MCP **server code** development and testing:
|
||||||
|
|
||||||
|
- Happens in isolated **`branches/`** worktrees (or other non-stable checkouts)
|
||||||
|
- May use a **separate** dev/test MCP runtime/process when process-level testing is required
|
||||||
|
- **Must not** be used for real Gitea mutations on production issues/PRs
|
||||||
|
|
||||||
|
### 2.3 Forbidden actions (normal sessions)
|
||||||
|
|
||||||
|
Normal **author, reviewer, merger, and reconciler** LLM sessions **must not**:
|
||||||
|
|
||||||
|
| Forbidden | Why |
|
||||||
|
|-----------|-----|
|
||||||
|
| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state |
|
||||||
|
| Restart / relaunch the MCP server process | Same as kill; causes stale/identity churn mid-workflow |
|
||||||
|
| Relaunch MCP from a development worktree | Runs unpromoted code against production mutations |
|
||||||
|
| Edit files in the stable runtime checkout | Hot-mutates control plane under concurrent users |
|
||||||
|
| Use experimental/dev MCP for real Gitea mutations | Bypasses promotion proof and audit expectations |
|
||||||
|
| Bypass or self-reset a stale master-parity gate | The gate is fail-closed; only an operator reload restores parity |
|
||||||
|
|
||||||
|
**LLM-allowed vs operator-owned (authoritative split):**
|
||||||
|
|
||||||
|
| Actor | May do | Must not do |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| **LLM session** | Call tools on the already-running stable namespaces; **client reconnect** after transport EOF (no process kill); pass `worktree_path` / role worktree args; report blockers and stop mutations when unhealthy/stale | Kill, restart, or relaunch any MCP process; bump config mtimes to force reload; edit the stable checkout; switch to a dev MCP for production mutations |
|
||||||
|
| **Operator / release-manager** | Supervised restart/reload of the **stable** control runtime; dual-namespace client configuration; §2.4 promotions; incident recovery | — |
|
||||||
|
|
||||||
|
EOF / transport recovery for LLM sessions: **client reconnect only** (see `docs/mcp-namespace-eof-recovery.md`). Do not “fix” health by killing PIDs or bumping MCP config mtimes as a normal session procedure.
|
||||||
|
|
||||||
|
This ADR **supersedes** any older runbook wording that told the LLM to relaunch or restart the client/MCP as a self-service step. Where `docs/llm-workflow-runbooks.md` (or wiki runbooks) discuss dual-namespace setup or workspace rebind, **process restart/relaunch is operator-owned**; the LLM stops, reports, and waits.
|
||||||
|
|
||||||
|
### 2.4 Promotion (operator / release-manager only)
|
||||||
|
|
||||||
|
Promotion of a new revision into the stable control runtime is an **explicit operator/release-manager action**, not an LLM self-service step.
|
||||||
|
|
||||||
|
A promotion **must record** (issue comment, release note, or promotion ledger):
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **previous runtime SHA** | Commit previously loaded by stable runtime |
|
||||||
|
| **promoted runtime SHA** | Commit after promotion |
|
||||||
|
| **source branch/PR** | Where the change was reviewed |
|
||||||
|
| **restart/reload method** | How the process was cycled (e.g. supervised restart, client reload) |
|
||||||
|
| **health check proof** | Client-namespace probe success (`gitea_whoami` / namespace health) |
|
||||||
|
| **identity/profile proof** | Expected profile(s) and username(s) after reload |
|
||||||
|
| **workspace/root proof** | Stable checkout path / root matches intended layout |
|
||||||
|
| **mutation capability proof** | Required permissions for the target role present; forbidden ops still forbidden |
|
||||||
|
| **rollback instructions** | How to restore previous SHA and re-verify health |
|
||||||
|
|
||||||
|
Suggested durable marker:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## MCP STABLE RUNTIME PROMOTION (#615)
|
||||||
|
|
||||||
|
Status: COMPLETED | ROLLED_BACK | ABORTED
|
||||||
|
Previous-SHA: <full sha>
|
||||||
|
Promoted-SHA: <full sha>
|
||||||
|
Source-PR: <number>
|
||||||
|
Source-Branch: <name>
|
||||||
|
Reload-Method: <text>
|
||||||
|
Health-Proof: client_namespace whoami OK / assess_mcp_namespace_health OK
|
||||||
|
Identity-Proof: profile=<name> user=<name>
|
||||||
|
Workspace-Proof: root=<path>
|
||||||
|
Mutation-Proof: allowed_ops include <…>; forbidden include <…>
|
||||||
|
Rollback: checkout <previous sha>; reload method <…>; re-run health/identity proofs
|
||||||
|
Operator: <username>
|
||||||
|
Timestamp: <ISO-8601>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Unhealthy stable runtime → stop work
|
||||||
|
|
||||||
|
If the stable MCP runtime is **unhealthy**, including any of:
|
||||||
|
|
||||||
|
- client-namespace probes fail
|
||||||
|
- wrong identity / wrong profile
|
||||||
|
- wrong workspace root
|
||||||
|
- missing mutation capability for the intended role
|
||||||
|
- persistent EOF after **client reconnect**
|
||||||
|
- **master parity is stale** (`startup_head` behind on-disk `master` / `restart_required` from the parity gate)
|
||||||
|
|
||||||
|
then:
|
||||||
|
|
||||||
|
1. **Normal PR / review / merge / issue-mutation work must stop immediately.**
|
||||||
|
2. The session **reports** the unhealthy/stale state (tool error, CTH, or operator handoff) with startup vs current head when known.
|
||||||
|
3. Do **not** improvise: no LLM process kill/restart, no dev-worktree MCP for production mutations, no env escape hatches, no manual gate bypass.
|
||||||
|
4. Resume only after:
|
||||||
|
- an **operator** restores the runtime (see §2.6 for routine post-merge parity reload), **or**
|
||||||
|
- a **controlled** promotion/rollback completes with the §2.4 promotion record, **or**
|
||||||
|
- a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented,
|
||||||
|
- **and** the session re-verifies health (and master parity when applicable) before the next mutation.
|
||||||
|
|
||||||
|
### 2.6 Routine post-merge master-parity staleness (operator reload, not promotion)
|
||||||
|
|
||||||
|
**Symptom:** After merges land on `master`, a long-lived stable MCP process still runs the pre-merge `startup_head`. The master-parity gate marks the server **stale** / `restart_required` and **blocks mutations**.
|
||||||
|
|
||||||
|
**Sanctioned response (authoritative):**
|
||||||
|
|
||||||
|
| Step | Actor | Action |
|
||||||
|
|------|-------|--------|
|
||||||
|
| 1 | LLM session | Mutations stop immediately when the gate reports stale. |
|
||||||
|
| 2 | LLM session | Report the stale state (startup head, current master head, that operator reload is required). Do not retry mutations. |
|
||||||
|
| 3 | **Operator** | Reload/restart the **stable** control MCP so it loads current `master` (supervised client/daemon reload). |
|
||||||
|
| 4 | LLM session | Resume only after startup/current-head parity is verified (e.g. `gitea_get_runtime_context` / parity assessment shows in parity). |
|
||||||
|
|
||||||
|
**Not a §2.4 promotion:** Catching the already-designated stable control checkout up to a newly advanced `master` is a **routine operator reload** of the stable runtime. It does **not** require the nine-field promotion ledger. Use §2.4 only when **changing which unpromoted/dev revision** becomes the stable control runtime (new source branch/PR into the stable designation).
|
||||||
|
|
||||||
|
**Code note:** `master_parity_gate.py` may still say “restart the server” in machine-facing reason strings. That string names the **operator recovery action**, not an LLM self-service instruction. This ADR and the runbooks define the actor split.
|
||||||
|
|
||||||
|
## 3. Relationship to other controls
|
||||||
|
|
||||||
|
| Doc / mechanism | Interaction |
|
||||||
|
|-----------------|-------------|
|
||||||
|
| Namespace health (#543) | Proves IDE client can call tools; does not authorize restart |
|
||||||
|
| EOF recovery | Reconnect only; no process kill |
|
||||||
|
| Daemon import guard (#558) | Mutations require sanctioned daemon; not a bare shell import |
|
||||||
|
| Bootstrap path (#557) | Only controller-authorized exception when live runtime cannot review its own fix |
|
||||||
|
| Allocator / control-plane ADR | Coordination DB is separate; still depends on a healthy MCP surface for Gitea writes |
|
||||||
|
|
||||||
|
## 4. Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Predictable control plane for concurrent LLMs
|
||||||
|
- Clear operator-only promotion gate with rollback
|
||||||
|
- Aligns session behavior with health/EOF docs already landed
|
||||||
|
|
||||||
|
### Costs
|
||||||
|
|
||||||
|
- LLM sessions must wait when runtime is sick (no DIY restart)
|
||||||
|
- Operators must maintain promotion discipline and dual-runtime config if they use a dev MCP
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Does not ban operator-supervised restarts during incidents
|
||||||
|
- Does not replace CI or code review for MCP changes
|
||||||
|
- Does not authorize editing stable checkout “because tests need a quick fix”
|
||||||
|
|
||||||
|
## 5. Implementation follow-ups
|
||||||
|
|
||||||
|
The **policy binds sessions now**. The enforcement layer landed with issue #615
|
||||||
|
acceptance criteria 6–11 in `stable_control_runtime.py`:
|
||||||
|
|
||||||
|
1. ~~Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”~~ **Landed.** `_runtime_mode_block()` refuses every mutating operation from a `dev-test`, dev-worktree-launched, dirty-stable, misaligned, or `unknown` runtime; `gitea.read` is never blocked, so an operator can still diagnose a sick runtime.
|
||||||
|
2. ~~Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.~~ **Landed** as `runtime_mode` (`stable-control` | `dev-test` | `unknown`), reported by `gitea_get_runtime_context` under `stable_control_runtime` together with the runtime git SHA, branch, checkout path, process root, active workspace, alignment, dirty files, and `real_mutations_allowed`. Operators running a packaged layout with no git checkout declare the mode explicitly with `GITEA_MCP_RUNTIME_MODE`.
|
||||||
|
3. ~~Promotion checklist script that emits the durable promotion marker fields.~~ **Landed** as `scripts/promote-stable-runtime` (read-only; emits and validates the record) plus [`../stable-runtime-promotion-runbook.md`](../stable-runtime-promotion-runbook.md).
|
||||||
|
|
||||||
|
Post-transport-flap proof is enforced per namespace: a flap invalidates every
|
||||||
|
`gitea-*` namespace at once, and author proof never transfers to reviewer,
|
||||||
|
merger, or reconciler (`namespace_not_reproven_after_flap`).
|
||||||
|
|
||||||
|
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
||||||
|
|
||||||
|
## 6. Acceptance for this ADR
|
||||||
|
|
||||||
|
1. Document merged under `docs/architecture/mcp-stable-control-runtime-policy-adr.md`.
|
||||||
|
2. **Operator guide / runbooks cross-link this ADR** (`docs/wiki/Operator-Guide.md`, `docs/wiki/Runbooks.md`, `docs/llm-workflow-runbooks.md`).
|
||||||
|
3. Issue #615 references this path.
|
||||||
|
4. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **LLM violations**; process restart is **operator-owned**.
|
||||||
|
5. Unhealthy runtime (including **stale master parity**) stops normal mutation work until operator restore/reload, promotion/rollback, or #557 bootstrap — then re-verify parity before mutating.
|
||||||
|
6. Routine post-merge parity reload is documented as operator reload (§2.6), not an LLM self-restart and not a full §2.4 promotion.
|
||||||
|
|
||||||
|
## 7. Document history
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule |
|
||||||
|
| 2026-07-16 | Review 443 remediation (#615 / PR #616): mandatory cross-links; LLM vs operator restart split; routine post-merge parity staleness (§2.6); stale parity in §2.5 unhealthy triggers |
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Installation root vs canonical target repository root
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document (tracked as issue #741, building on #706 and #739/#740) explains
|
||||||
|
the two distinct filesystem roots the Gitea-Tools MCP server reasons about, why
|
||||||
|
conflating them silently targets the wrong repository, and which rule applies
|
||||||
|
when you add a new consumer.
|
||||||
|
|
||||||
|
It is the repository-scope companion to
|
||||||
|
[`gitea-execution-profiles.md`](gitea-execution-profiles.md) (the profile model)
|
||||||
|
and [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md)
|
||||||
|
(the per-role namespace model).
|
||||||
|
|
||||||
|
## The two roots
|
||||||
|
|
||||||
|
| | Installation root | Canonical target repository root |
|
||||||
|
|---|---|---|
|
||||||
|
| What it is | The checkout the server *code* lives in | The working root of the repository whose issues/PRs/branches the namespace *mutates* |
|
||||||
|
| How it is derived | `PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))` | Configured per namespace, then pinned immutably into the session |
|
||||||
|
| Configured by | Nothing — it follows the script | `canonical_repository_root` profile field, or the `GITEA_CANONICAL_REPOSITORY_ROOT` environment variable |
|
||||||
|
| Changes at runtime? | No | No — first bind wins for the life of the process |
|
||||||
|
| Accessor | `PROJECT_ROOT` | `_canonical_local_git_root()` (filesystem) / `_canonical_repository_slug()` (identity) |
|
||||||
|
|
||||||
|
For a **single-repository** namespace — every Gitea-Tools namespace today — the
|
||||||
|
two roots are the same path, and nothing about the existing behaviour changes.
|
||||||
|
The distinction only becomes observable once a namespace is pointed at a
|
||||||
|
different repository.
|
||||||
|
|
||||||
|
## Which root does my code need?
|
||||||
|
|
||||||
|
Ask what the operation is *about*, not where the file happens to sit.
|
||||||
|
|
||||||
|
**Use the installation root (`PROJECT_ROOT`)** when the operation concerns the
|
||||||
|
Gitea-Tools software itself:
|
||||||
|
|
||||||
|
- server implementation / version parity (`master_parity_gate`, the
|
||||||
|
`startup_head` vs `current_head` staleness gate);
|
||||||
|
- loading the server's own workflow, schema and skill files;
|
||||||
|
- self-code hashing and stale-runtime detection;
|
||||||
|
- locating installed scripts such as `mirror_refs.sh`.
|
||||||
|
|
||||||
|
These are intentionally install-scoped. Do not "fix" them.
|
||||||
|
|
||||||
|
**Use the canonical target root (`_canonical_local_git_root()`)** when the
|
||||||
|
operation concerns the repository being worked on:
|
||||||
|
|
||||||
|
- `git remote get-url` for repository identity;
|
||||||
|
- branch creation, push, and commit;
|
||||||
|
- ancestry and merge-base proofs;
|
||||||
|
- worktree inventory, cleanup, and branch deletion;
|
||||||
|
- any local git subprocess whose result feeds a mutation guard.
|
||||||
|
|
||||||
|
**If you cannot tell, fail closed.** An ambiguous consumer that guesses the
|
||||||
|
install root is the exact defect class #741 exists to eliminate.
|
||||||
|
|
||||||
|
## Why conflating them inverts the guards
|
||||||
|
|
||||||
|
Before #741, `_local_git_remote_url()` ran `git remote get-url` with
|
||||||
|
`cwd=PROJECT_ROOT` unconditionally. Every consumer of repository *identity* —
|
||||||
|
`_resolve`, the #530 remote/repo guard, the anti-stomp org/repo fill,
|
||||||
|
`_workspace_repository_slug` — therefore read the Gitea-Tools remote and called
|
||||||
|
it "the workspace", no matter which repository the namespace was bound to.
|
||||||
|
|
||||||
|
For a namespace whose canonical root points elsewhere, this **inverts** the
|
||||||
|
guard rather than merely weakening it:
|
||||||
|
|
||||||
|
- an operation naming the genuinely bound target repository is **rejected**,
|
||||||
|
because that slug does not appear in the Gitea-Tools remote URL;
|
||||||
|
- an operation naming Gitea-Tools is **accepted**.
|
||||||
|
|
||||||
|
The filesystem guards (#274 branches-only and worktree membership) had already
|
||||||
|
been migrated to the canonical root by #706, so the two halves of a single
|
||||||
|
assessment described two different repositories.
|
||||||
|
|
||||||
|
A related subtlety: repository identity must not be derived by looking a remote
|
||||||
|
up by *name*. A target checkout commonly names its remote `origin` rather than
|
||||||
|
`prgs`, so a name-keyed lookup returns nothing and the omitted coordinates fall
|
||||||
|
through to the remote-wide default *target* — an unrelated repository.
|
||||||
|
`_canonical_repository_slug()` probes candidate remote names against the
|
||||||
|
canonical root instead.
|
||||||
|
|
||||||
|
`_canonical_local_git_root()` is now the one place a target root is resolved.
|
||||||
|
Do not re-derive it; new code that needs a target root calls that helper.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Declare the binding on the profile, alongside `allowed_repositories`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"example-author": {
|
||||||
|
"role": "author",
|
||||||
|
"canonical_repository_root": "/absolute/path/to/target-repo",
|
||||||
|
"allowed_repositories": ["Example-Org/target-repo"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The namespace-scoped environment variable
|
||||||
|
`GITEA_CANONICAL_REPOSITORY_ROOT` overrides the profile field, and is normally
|
||||||
|
exported next to the server `cwd` in the MCP client configuration.
|
||||||
|
|
||||||
|
Validation is layered, and each layer fails closed:
|
||||||
|
|
||||||
|
1. **Config load.** The path must be a non-empty absolute string. All supported
|
||||||
|
loaders — v1, v2-`environments`, and v2-`contexts` — validate it identically.
|
||||||
|
(Before #741 only the v2-`contexts` loader validated it, and
|
||||||
|
v2-`environments` silently *dropped* the field during flattening, so the
|
||||||
|
namespace fell back to the install root — a fail-open.)
|
||||||
|
2. **Bind time.** The path must exist, be a git repository, and resolve to a
|
||||||
|
repository identity matching the session's authorized slug. A configured but
|
||||||
|
unresolvable root is never replaced by the install identity.
|
||||||
|
3. **Every mutation.** The pinned root is compared against the live configured
|
||||||
|
value; a mismatch is treated as a forged or conflicting binding.
|
||||||
|
|
||||||
|
`allowed_repositories` remains a separate authorization boundary (#714): the
|
||||||
|
canonical root determines *which* repository is derived, and
|
||||||
|
`allowed_repositories` determines whether the session may act on it. A root that
|
||||||
|
resolves to a repository outside that list fails closed.
|
||||||
|
|
||||||
|
## Explicit coordinates confirm, never override
|
||||||
|
|
||||||
|
Explicit `org`/`repo` arguments may **confirm** an existing canonical binding.
|
||||||
|
They can never establish, complete, or replace one. A request naming a
|
||||||
|
repository that contradicts the binding fails closed, in both directions:
|
||||||
|
|
||||||
|
- a Gitea-Tools-rooted namespace cannot mutate another repository;
|
||||||
|
- a namespace rooted at another repository cannot mutate Gitea-Tools.
|
||||||
|
|
||||||
|
This matters because both-explicit coordinates short-circuit the #530
|
||||||
|
remote/repo match check, so without this rule a caller could name any repository
|
||||||
|
and skip validation entirely.
|
||||||
|
|
||||||
|
No request-supplied workspace, remote, owner, repository, or worktree can
|
||||||
|
replace the immutable root.
|
||||||
|
|
||||||
|
## Parity is reported per dimension
|
||||||
|
|
||||||
|
`gitea_assess_master_parity` reports two separately labelled dimensions:
|
||||||
|
|
||||||
|
- `server_implementation` — the Gitea-Tools installation checkout. Its
|
||||||
|
`startup_head` / `current_head` / `stale` / `restart_required` fields keep
|
||||||
|
their original meaning, and **only this dimension gates mutations**: the
|
||||||
|
running process executes the code it started with, so a merged fix is not live
|
||||||
|
until the daemon restarts.
|
||||||
|
- `target_repository` — the configured canonical target checkout and its
|
||||||
|
last-known remote master.
|
||||||
|
|
||||||
|
"In parity" is a statement about one dimension, never about the whole system.
|
||||||
|
Read the dimension you actually care about.
|
||||||
@@ -45,6 +45,7 @@ authenticated capability set. Each profile defines the following fields:
|
|||||||
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
||||||
| `allowed_operations` | list | Operation categories this profile may perform. |
|
| `allowed_operations` | list | Operation categories this profile may perform. |
|
||||||
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
||||||
|
| `allowed_repositories` | list | Optional. Canonical `owner/repository` slugs this profile may bind to. An authorization boundary, not the binding itself — see [Repository scope](#repository-scope-714). |
|
||||||
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
||||||
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
||||||
| `can_approve_prs` | bool | May submit an approving PR review. |
|
| `can_approve_prs` | bool | May submit an approving PR review. |
|
||||||
@@ -57,6 +58,47 @@ authenticated capability set. Each profile defines the following fields:
|
|||||||
name), never the token itself. Token values are never part of a profile object,
|
name), never the token itself. Token values are never part of a profile object,
|
||||||
never logged, never returned by a tool, and never committed.
|
never logged, never returned by a tool, and never committed.
|
||||||
|
|
||||||
|
## Repository scope (#714)
|
||||||
|
|
||||||
|
`allowed_repositories` declares the canonical `owner/repository` slugs a profile
|
||||||
|
may operate on:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"prgs-author": {
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is an **authorization boundary, not the session binding**. The binding is
|
||||||
|
derived and enforced like this:
|
||||||
|
|
||||||
|
1. The session repository is derived from the **verified, workspace-aligned git
|
||||||
|
remote** — never from a caller-supplied `org`/`repo` argument, and never from
|
||||||
|
the `REMOTES` table (whose entries are default *targets*: `prgs` defaults to
|
||||||
|
`Timesheet`, which is not this project).
|
||||||
|
2. That workspace-derived slug is validated against `allowed_repositories`.
|
||||||
|
3. The session binds immutably to that one canonical `owner/repository`. The
|
||||||
|
organization is derived from the slug; there is no independent,
|
||||||
|
caller-controlled organization value.
|
||||||
|
4. If a profile authorizes several repositories, the verified workspace still
|
||||||
|
selects exactly one. A session never binds to the whole list and never
|
||||||
|
switches between entries.
|
||||||
|
|
||||||
|
Fail-closed rules:
|
||||||
|
|
||||||
|
- Activation is rejected when the workspace repository is absent from the
|
||||||
|
allowlist.
|
||||||
|
- A mutation is rejected when no verified workspace repository can be
|
||||||
|
established.
|
||||||
|
- A tool-level `org`/`repo` override that disagrees with the binding is rejected
|
||||||
|
before the mutation runs.
|
||||||
|
- A mutation request can never establish, complete, or replace the binding.
|
||||||
|
|
||||||
|
The field is **config-only**: no environment variable can widen or forge it. It
|
||||||
|
is optional — a profile that omits it keeps the previous behaviour, so existing
|
||||||
|
static-profile namespaces stay functional until an operator provisions the
|
||||||
|
scope. Provisioning it is what activates enforcement for that profile.
|
||||||
|
|
||||||
## Example profiles
|
## Example profiles
|
||||||
|
|
||||||
The following are the reference profiles. Booleans express intended capability
|
The following are the reference profiles. Booleans express intended capability
|
||||||
@@ -238,11 +280,81 @@ narrow operation set:
|
|||||||
- `gitea.issue.comment`
|
- `gitea.issue.comment`
|
||||||
- `gitea.issue.close`
|
- `gitea.issue.close`
|
||||||
- `gitea.pr.close`
|
- `gitea.pr.close`
|
||||||
|
- `gitea.branch.delete` (merged-branch cleanup only — see below)
|
||||||
|
|
||||||
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
||||||
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
||||||
`gitea.repo.commit`.
|
`gitea.repo.commit`.
|
||||||
|
|
||||||
|
### Merged-branch cleanup ownership (`gitea.branch.delete`)
|
||||||
|
|
||||||
|
The reconciler is the repository-supported owner of merged-PR source-branch
|
||||||
|
cleanup: `task_capability_map` maps `cleanup_merged_pr_branch` (and
|
||||||
|
`reconciliation_cleanup`) to role `reconciler` with permission
|
||||||
|
`gitea.branch.delete`. Post-merge branch lifecycle is reconciliation work —
|
||||||
|
it happens after the author, reviewer, and merger roles have completed, and
|
||||||
|
it must not be reachable from those roles.
|
||||||
|
|
||||||
|
Least-privilege constraints:
|
||||||
|
|
||||||
|
- `gitea.branch.delete` is granted **only** to reconciler profiles. Author,
|
||||||
|
reviewer, and merger profiles must never hold it; `gitea_delete_branch`
|
||||||
|
and `gitea_cleanup_merged_pr_branch` fail closed on any profile without
|
||||||
|
the permission.
|
||||||
|
- Even with the permission, reconciler deletion is only supported through the
|
||||||
|
guarded `gitea_cleanup_merged_pr_branch` path (#514 / #687): the PR must be
|
||||||
|
merged, the head an ancestor of the target, the branch not protected
|
||||||
|
(`master`/`main`/`dev`), the branch not a preservation/evidence ref (e.g.
|
||||||
|
`chore/issue-681-preserve-review-session-wip`), no open PR may still use the
|
||||||
|
head, and an explicit `CLEANUP MERGED PR <n> BRANCH <branch>` confirmation is
|
||||||
|
required. Raw `gitea_delete_branch` is **denied** to reconciler even when
|
||||||
|
`gitea.branch.delete` is present.
|
||||||
|
- Raw `git branch -d` / `git push --delete` cleanup remains blocked by
|
||||||
|
`branch_cleanup_guard` and the final-report validator regardless of
|
||||||
|
profile permissions.
|
||||||
|
- `gitea.branch.delete` has no short alias in `GITEA_OPERATION_ALIASES`;
|
||||||
|
write it fully qualified in `allowed_operations`. Migration must emit
|
||||||
|
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
||||||
|
`issue.close`, which the production normalizer rejects or drops).
|
||||||
|
|
||||||
|
### Post-merge moot-lease cleanup ownership (`gitea.pr.comment`)
|
||||||
|
|
||||||
|
Neutralising a reviewer lease left behind on an already-merged/closed PR is
|
||||||
|
reconciliation work too. `task_capability_map` maps
|
||||||
|
`cleanup_post_merge_moot_lease` — and its tool-name alias
|
||||||
|
`gitea_cleanup_post_merge_moot_lease` — to role `reconciler` with permission
|
||||||
|
`gitea.pr.comment` (#745). Both names carry the **same** contract.
|
||||||
|
|
||||||
|
The permission alone is deliberately not sufficient: author, reviewer and
|
||||||
|
merger profiles all hold `gitea.pr.comment` for ordinary PR discussion, so the
|
||||||
|
role gate — not the permission gate — is what keeps the terminal lease marker
|
||||||
|
reconciler-owned.
|
||||||
|
|
||||||
|
`gitea_cleanup_post_merge_moot_lease` splits its two modes on purpose:
|
||||||
|
|
||||||
|
- **`apply=false` (assessment) requires only `gitea.read`, with no role gate.**
|
||||||
|
This matches `gitea_cleanup_stale_review_decision_lock` and
|
||||||
|
`gitea_cleanup_obsolete_reviewer_comment_lease`, whose assessment paths are
|
||||||
|
likewise read-gated, so an operator can diagnose a stuck lease from whichever
|
||||||
|
namespace happens to be attached without switching roles. The dry run
|
||||||
|
performs no mutation and records append-only evidence in-session.
|
||||||
|
- **`apply=true` (mutation) requires all of the following**, in order: the
|
||||||
|
session must have resolved exactly `cleanup_post_merge_moot_lease` (resolving
|
||||||
|
any other task — including a sibling reconciler task — does not authorize
|
||||||
|
it); the active role must be `reconciler`; the profile must hold
|
||||||
|
`gitea.pr.comment`; the explicit `org`/`repo` must agree with the canonical
|
||||||
|
repository identity, which is derived from the session binding and can never
|
||||||
|
be overridden by request parameters; and matching dry-run evidence must show
|
||||||
|
`lease_moot`, `cleanup_allowed`, and the same PR, lease session, candidate
|
||||||
|
head and lease marker id that are live at apply time.
|
||||||
|
|
||||||
|
Everything else fails closed: a live lease on an open PR, an already-terminal
|
||||||
|
(idempotent) lease, a lease superseded between the dry run and the apply, a
|
||||||
|
malformed lease missing session/head/marker, and any foreign-repository target.
|
||||||
|
The cleanup only ever appends a terminal `phase: released` marker
|
||||||
|
(`blocker: post-merge-moot`) — it never edits or deletes another session's
|
||||||
|
comment, and it never merges or adopts a lease.
|
||||||
|
|
||||||
Launch a static `gitea-reconciler` MCP namespace with
|
Launch a static `gitea-reconciler` MCP namespace with
|
||||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||||
@@ -251,6 +363,159 @@ Launch a static `gitea-reconciler` MCP namespace with
|
|||||||
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
||||||
heads are not already landed cannot be closed through this path.
|
heads are not already landed cannot be closed through this path.
|
||||||
|
|
||||||
|
### Operational runbook: grant reconciler `gitea.branch.delete` (#687)
|
||||||
|
|
||||||
|
Merging a code PR that updates `migrate_profiles.py` / `reconciler_profile.py`
|
||||||
|
**does not** change the live operator profile on disk. Apply the profile
|
||||||
|
change deliberately, then reconnect the client-managed namespace.
|
||||||
|
|
||||||
|
1. **Approved migration / profile-update command** (from the repo root, using
|
||||||
|
the project venv if present):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry-run first (default): validates v2 output, writes nothing
|
||||||
|
python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json
|
||||||
|
|
||||||
|
# Apply: creates backup then writes migrated v2 config
|
||||||
|
python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json -w
|
||||||
|
# Optional explicit paths:
|
||||||
|
# python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json \
|
||||||
|
# -o ~/.config/gitea-tools/profiles.json \
|
||||||
|
# --backup ~/.config/gitea-tools/profiles.json.bak -w
|
||||||
|
```
|
||||||
|
|
||||||
|
If the live file is already v2, edit the reconciler identity’s
|
||||||
|
`allowed_operations` / `forbidden_operations` under
|
||||||
|
`environments.<env>.services.gitea.identities.reconciler` (or the
|
||||||
|
`prgs-reconciler` alias target) so allowed includes the canonical set
|
||||||
|
below — then re-validate with a load of the config (see step 3).
|
||||||
|
|
||||||
|
2. **Inspect the generated (or edited) profile** — confirm the reconciler
|
||||||
|
identity, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
cfg = json.loads(Path.home().joinpath(".config/gitea-tools/profiles.json").read_text())
|
||||||
|
# v2 environments shape:
|
||||||
|
ident = cfg["environments"]["prgs"]["services"]["gitea"]["identities"]["reconciler"]
|
||||||
|
print("role:", ident.get("role"))
|
||||||
|
print("allowed:", ident.get("allowed_operations"))
|
||||||
|
print("forbidden:", ident.get("forbidden_operations"))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Validate canonical operation names and least privilege**
|
||||||
|
|
||||||
|
Expected canonical **allowed** (defaults after migration):
|
||||||
|
|
||||||
|
- `gitea.read`
|
||||||
|
- `gitea.pr.close` (required)
|
||||||
|
- `gitea.pr.comment`
|
||||||
|
- `gitea.issue.comment`
|
||||||
|
- `gitea.issue.close`
|
||||||
|
- `gitea.branch.delete` (recommended; cleanup only)
|
||||||
|
|
||||||
|
Expected **forbidden** includes at least: `gitea.pr.approve`,
|
||||||
|
`gitea.pr.merge`, `gitea.pr.review`, `gitea.pr.create`,
|
||||||
|
`gitea.branch.push`, `gitea.repo.commit`.
|
||||||
|
|
||||||
|
No shorthand (`pr.close`, `issue.close`, `pr.comment`) may remain.
|
||||||
|
Validate with the production loader:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import gitea_config, reconciler_profile
|
||||||
|
from pathlib import Path
|
||||||
|
path = str(Path.home() / ".config/gitea-tools/profiles.json")
|
||||||
|
gitea_config.load_config(path) # fails closed on invalid config
|
||||||
|
# Or assess the reconciler lists directly after extracting them:
|
||||||
|
# print(reconciler_profile.assess_reconciler_profile(allowed, forbidden))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Merging PR #688 (or any code PR) does not update the live profile.**
|
||||||
|
Code changes only the migration helper, schema, docs, and tests. The
|
||||||
|
operator must still run `migrate_profiles.py -w` or an equivalent
|
||||||
|
authorized edit of `~/.config/gitea-tools/profiles.json`.
|
||||||
|
|
||||||
|
5. **Supported apply method:** `python3 migrate_profiles.py … -w` (backup
|
||||||
|
created automatically) **or** operator-authorized edit of the live
|
||||||
|
profiles file after backup. Unsupported: silent mtime tricks, manual
|
||||||
|
process kill to “reload”, or undocumented env overrides.
|
||||||
|
|
||||||
|
6. **Backup and validation:** `-w` copies the input to
|
||||||
|
`<input_path>.bak` (or `--backup PATH`) before writing. Re-run
|
||||||
|
`load_config` / `assess_reconciler_profile` after write. Keep the
|
||||||
|
`.bak` until live whoami/capability checks pass.
|
||||||
|
|
||||||
|
7. **Client-managed namespace reconnect/reload:** reconnect or reload the
|
||||||
|
IDE MCP client so `gitea-reconciler` restarts from current `master` and
|
||||||
|
the updated `GITEA_MCP_PROFILE=prgs-reconciler` config. Do not hand-launch
|
||||||
|
`mcp_server.py` / `gitea_mcp_server.py` with ad hoc `GITEA_*` env
|
||||||
|
(see #686 / #630).
|
||||||
|
|
||||||
|
8. **Live reverification** (through the client-managed `gitea-reconciler`
|
||||||
|
namespace only):
|
||||||
|
|
||||||
|
- `gitea_whoami` → identity + profile `prgs-reconciler`
|
||||||
|
- `gitea_assess_master_parity` → `stale=false`, `restart_required=false`
|
||||||
|
- `gitea_resolve_task_capability(task="cleanup_merged_pr_branch")` →
|
||||||
|
`allowed_in_current_session=true` only when permission and role match
|
||||||
|
- `gitea_resolve_task_capability(task="delete_branch")` →
|
||||||
|
**not** allowed for reconciler (role denial must be enforced)
|
||||||
|
|
||||||
|
9. **Guarded cleanup usage** (example for a merged PR whose source branch
|
||||||
|
remains on the remote):
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=<N>,
|
||||||
|
branch=<exact PR head branch>,
|
||||||
|
confirmation="CLEANUP MERGED PR <N> BRANCH <exact PR head branch>",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="<path under branches/>",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool refuses unmerged PRs, protected branches, preservation/evidence
|
||||||
|
branches, open-PR heads, mismatched branch names, and wrong confirmation.
|
||||||
|
|
||||||
|
10. **Prohibitions**
|
||||||
|
|
||||||
|
- No raw `git push --delete`, `git branch -d` / `-D`, or delete refspecs
|
||||||
|
- No arbitrary `gitea_delete_branch` from reconciler
|
||||||
|
- No unsupported profile switching mid-run without full re-preflight
|
||||||
|
- No ad hoc hand-edits of live profiles **unless** operator-authorized,
|
||||||
|
backed up, and revalidated as above
|
||||||
|
|
||||||
|
Canonical migrated reconciler example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.close",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.branch.delete"
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Identity and fail-closed rules
|
## Identity and fail-closed rules
|
||||||
|
|
||||||
Before **any** mutating action, a workflow must know both:
|
Before **any** mutating action, a workflow must know both:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ one.
|
|||||||
| `status:blocked` | Issue is blocked |
|
| `status:blocked` | Issue is blocked |
|
||||||
| `status:needs-review` | Issue work needs review |
|
| `status:needs-review` | Issue work needs review |
|
||||||
| `status:pr-open` | A linked PR is open |
|
| `status:pr-open` | A linked PR is open |
|
||||||
|
| `status:changes-requested` | Reviewer requested changes on the linked PR |
|
||||||
| `status:approved` | Linked PR is approved |
|
| `status:approved` | Linked PR is approved |
|
||||||
| `status:merged` | Linked PR is merged |
|
| `status:merged` | Linked PR is merged |
|
||||||
| `status:reconcile` | Issue needs reconciliation |
|
| `status:reconcile` | Issue needs reconciliation |
|
||||||
@@ -46,6 +47,72 @@ one.
|
|||||||
| `status:duplicate` | Issue is a duplicate |
|
| `status:duplicate` | Issue is a duplicate |
|
||||||
| `status:wontfix` | Issue will not be fixed |
|
| `status:wontfix` | Issue will not be fixed |
|
||||||
|
|
||||||
|
## Role Ownership Labels (#603)
|
||||||
|
|
||||||
|
A single `role:*` label shows which workflow role currently owns the item. It is
|
||||||
|
advisory visibility only — the control-plane lease (#601) is the source of truth
|
||||||
|
for mutation authority. Only one `role:*` label is active at a time; tooling
|
||||||
|
replaces it on handoff via `transition_role_labels`.
|
||||||
|
|
||||||
|
| Label | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| `role:author` | Author currently owns the item |
|
||||||
|
| `role:reviewer` | Reviewer currently owns the item |
|
||||||
|
| `role:merger` | Merger currently owns the item |
|
||||||
|
|
||||||
|
## Hazard Labels (#603)
|
||||||
|
|
||||||
|
Hazard labels are orthogonal warning flags. Unlike `status:*` and `role:*`,
|
||||||
|
**more than one hazard may be active at once**, and a hazard never substitutes
|
||||||
|
for a live lease / PR-state check. Add/remove with `add_hazard_label` /
|
||||||
|
`clear_hazard_label`.
|
||||||
|
|
||||||
|
| Label | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| `hazard:stale-lease` | A stale or expired lease references this item |
|
||||||
|
| `hazard:workflow-contaminated` | Session/workflow state is contaminated; do not mutate |
|
||||||
|
| `hazard:conflicted` | Linked PR has merge conflicts |
|
||||||
|
| `hazard:root-mutation` | Work was mutated in the project root checkout |
|
||||||
|
| `hazard:manual-state` | Session or lease state was edited manually |
|
||||||
|
| `hazard:terminal-blocker` | A terminal review/merge lock blocks progress (#332/#602) |
|
||||||
|
|
||||||
|
Any item that carries `status:blocked` or any `hazard:*` flag must also have a
|
||||||
|
blocking-reason / next-action comment (`requires_blocking_reason`).
|
||||||
|
|
||||||
|
## `state:*` → canonical mapping (#603 migration)
|
||||||
|
|
||||||
|
Issue #603 proposed a parallel `state:*` vocabulary. To avoid a conflicting
|
||||||
|
second lifecycle prefix, those requested states are folded into the existing
|
||||||
|
canonical labels rather than introduced as `state:*`. `state:*` is **not** a
|
||||||
|
supported prefix; use the canonical label on the right.
|
||||||
|
|
||||||
|
| Requested `state:*` | Canonical label |
|
||||||
|
| --- | --- |
|
||||||
|
| `state:needs-triage` | `status:triage` |
|
||||||
|
| `state:claimed` | `status:claimed` |
|
||||||
|
| `state:authoring` | `status:in-progress` |
|
||||||
|
| `state:needs-review` | `status:needs-review` |
|
||||||
|
| `state:reviewing` | `status:needs-review` |
|
||||||
|
| `state:changes-requested` | `status:changes-requested` |
|
||||||
|
| `state:approved` | `status:approved` |
|
||||||
|
| `state:merge-ready` | `status:approved` |
|
||||||
|
| `state:merged` | `status:merged` |
|
||||||
|
| `state:blocked` | `status:blocked` |
|
||||||
|
| `state:terminal-blocker` | `hazard:terminal-blocker` |
|
||||||
|
| `state:abandoned` | `status:wontfix` |
|
||||||
|
|
||||||
|
The transition helpers accept these names as synonyms (e.g.
|
||||||
|
`canonical_status_label("authoring")` → `status:in-progress`), so callers may use
|
||||||
|
the #603 wording while a single canonical status stays active.
|
||||||
|
|
||||||
|
## Allocator Cross-Check (#603)
|
||||||
|
|
||||||
|
Labels are advisory queue hints. The work allocator (#600/#613) uses labels as
|
||||||
|
one signal but **cross-checks live leases and PR state** and never trusts labels
|
||||||
|
alone. Discussion issues (`type:discussion`) are excluded from implementation
|
||||||
|
queues (`is_implementation_candidate`) unless a controller explicitly selects
|
||||||
|
them.
|
||||||
|
|
||||||
## Transition Rules
|
## Transition Rules
|
||||||
|
|
||||||
Suggested lifecycle:
|
Suggested lifecycle:
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ It extracts the issue-first, isolated-worktree, no-self-review, profile-safety,
|
|||||||
merge-cleanup, fail-closed, and recovery rules into a reusable package that can
|
merge-cleanup, fail-closed, and recovery rules into a reusable package that can
|
||||||
be adapted to other repositories.
|
be adapted to other repositories.
|
||||||
|
|
||||||
|
### Sanctioned first mutation: `create_issue` from clean control (#749)
|
||||||
|
|
||||||
|
Creating a tracking issue has no issue number yet, so no `branches/issue-<N>-*`
|
||||||
|
worktree can exist. The sanctioned path is: clean canonical control checkout
|
||||||
|
(accepted base branch, base-equivalent to live master, no tracked dirt) →
|
||||||
|
resolve exact `create_issue` → `gitea_create_issue`. After the issue exists,
|
||||||
|
all further author mutations require a registered issue-backed worktree and
|
||||||
|
lock. Do not improvise with dummy directories, borrowed worktrees, or pre-issue
|
||||||
|
worktrees. See `skills/llm-project-workflow/workflows/create-issue.md` §18a.
|
||||||
|
|
||||||
## Principle: the profile is the role, not the LLM
|
## Principle: the profile is the role, not the LLM
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -195,7 +205,7 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
|
|||||||
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
||||||
review or author workflows.
|
review or author workflows.
|
||||||
|
|
||||||
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
* **Fallback (operator-owned):** If the dual-profile MCP launcher pattern is not supported or configured in the client, **do not** have the LLM relaunch or restart the client/MCP. The LLM **stops** role-switching work, reports that the correct static namespace is missing, and waits for an **operator** to configure dual namespaces or reload the client with the correct `GITEA_MCP_PROFILE` for that role. Process restart/relaunch is operator-owned under the [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||||
|
|
||||||
## Setup runbook — interactive menu
|
## Setup runbook — interactive menu
|
||||||
|
|
||||||
@@ -811,6 +821,32 @@ author work. Do not switch from `prgs-reconciler` to `prgs-author` only to run
|
|||||||
- **Reports:** label cleanup actions as reconciler cleanup (not author mutation).
|
- **Reports:** label cleanup actions as reconciler cleanup (not author mutation).
|
||||||
- **Prompt:** `As prgs-reconciler, dry-run then execute gitea_reconcile_merged_cleanups for recently merged PRs without switching to prgs-author.`
|
- **Prompt:** `As prgs-reconciler, dry-run then execute gitea_reconcile_merged_cleanups for recently merged PRs without switching to prgs-author.`
|
||||||
|
|
||||||
|
### Superseded PR / satisfied issue reconciliation (#525)
|
||||||
|
|
||||||
|
Closing duplicate or superseded PRs after a canonical PR has merged is
|
||||||
|
**reconciler** work. Do not switch to `prgs-author` only to close the duplicate
|
||||||
|
PR, close the satisfied issue, or file a narrow follow-up discovered during
|
||||||
|
reconciliation.
|
||||||
|
|
||||||
|
- **Profile:** `prgs-reconciler`.
|
||||||
|
- **Tasks:** `reconcile_close_superseded_pr`,
|
||||||
|
`reconcile_close_satisfied_issue`, and
|
||||||
|
`reconcile_create_followup_issue`.
|
||||||
|
- **Tool:** `gitea_reconcile_superseded_by_merged_pr`.
|
||||||
|
- **Required proof:**
|
||||||
|
1. Live target PR state is open, but it is not independently required and no
|
||||||
|
mergeable work remains.
|
||||||
|
2. Live superseding PR state is closed and merged.
|
||||||
|
3. Superseding PR head is an ancestor of freshly fetched `master`.
|
||||||
|
4. Merge commit SHA is recorded.
|
||||||
|
5. Canonical close comment cites the superseding PR and merge commit.
|
||||||
|
6. Linked issue closure is attempted only when the issue is open and
|
||||||
|
explicitly satisfied by the superseding PR.
|
||||||
|
- **Fail closed:** missing ancestry proof, missing canonical comment,
|
||||||
|
mergeable target PR, same target/superseding PR, or missing
|
||||||
|
`gitea.pr.close` / `gitea.issue.close` capability.
|
||||||
|
- **Prompt:** `As prgs-reconciler, reconcile PR #N as superseded by merged PR #M; post the canonical close comment, close the superseded PR, and close issue #K only if the merged PR fully satisfies it.`
|
||||||
|
|
||||||
### Stop on blocker
|
### Stop on blocker
|
||||||
|
|
||||||
- **Any profile.** If a required gate cannot be satisfied — identity
|
- **Any profile.** If a required gate cannot be satisfied — identity
|
||||||
@@ -1023,6 +1059,72 @@ Special states:
|
|||||||
Final reports must not claim a comment was posted when
|
Final reports must not claim a comment was posted when
|
||||||
`canonical_comment_validation.allowed` is false (#496 AC14).
|
`canonical_comment_validation.allowed` is false (#496 AC14).
|
||||||
|
|
||||||
|
## Stale #332 review-decision lock cleanup (#594)
|
||||||
|
|
||||||
|
#332 hard-stops a reviewer session after a terminal live review mutation
|
||||||
|
(`approve` / `request_changes`). After #559 those locks are **durable** under
|
||||||
|
`~/.cache/gitea-tools/session-state/review_decision_lock-<profile>.json`, so they
|
||||||
|
can outlive the PR they protected.
|
||||||
|
|
||||||
|
### When cleanup is **allowed**
|
||||||
|
|
||||||
|
Use `gitea_cleanup_stale_review_decision_lock` from a **reviewer** profile when:
|
||||||
|
|
||||||
|
1. A durable review-decision lock has a terminal live mutation, **and**
|
||||||
|
2. Live Gitea state shows that terminal PR is already **merged** or **closed**
|
||||||
|
(so no same-PR merge sequence remains), **and**
|
||||||
|
3. The active profile identity matches the lock, **and**
|
||||||
|
4. Authenticated identity can be verified.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 1) Assess only (default)
|
||||||
|
gitea_cleanup_stale_review_decision_lock(apply=false, remote=prgs, ...)
|
||||||
|
|
||||||
|
# 2) Apply after is_moot=true and cleanup_allowed=true
|
||||||
|
gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=true,
|
||||||
|
expected_terminal_pr=<merged-or-closed-pr>,
|
||||||
|
remote=prgs,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful apply:
|
||||||
|
|
||||||
|
* clears in-memory + durable decision lock for that profile
|
||||||
|
* returns a structured `audit` payload
|
||||||
|
* posts an audit comment on the mooted PR when `gitea.pr.comment` is allowed
|
||||||
|
(`post_audit_comment=true` default)
|
||||||
|
|
||||||
|
Same-profile auto-expire: if `gitea_merge_pr` succeeds on a PR that this same
|
||||||
|
profile just approved, the decision lock is cleared after merge. Cross-profile
|
||||||
|
locks (reviewer vs merger) still require the cleanup tool.
|
||||||
|
|
||||||
|
### When cleanup is **forbidden**
|
||||||
|
|
||||||
|
Do **not** clear when:
|
||||||
|
|
||||||
|
* the last terminal PR is still **open** (active #332 hard-stop — continue merge
|
||||||
|
for that approve, or stop after request_changes)
|
||||||
|
* live PR state cannot be fetched (fail closed)
|
||||||
|
* profile identity does not match the lock
|
||||||
|
* identity cannot be verified
|
||||||
|
* `expected_terminal_pr` does not match the last terminal PR
|
||||||
|
|
||||||
|
**Never** use manual deletion of session-state files as the normal workflow.
|
||||||
|
**Never** use cleanup to skip review of an open PR or to bypass eligibility /
|
||||||
|
mark-ready / submit gates on active work.
|
||||||
|
|
||||||
|
### Related tools
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `gitea_cleanup_stale_review_decision_lock` | Moot decision-lock cleanup (#594) |
|
||||||
|
| `gitea_authorize_review_correction` | Operator-approved correction after a **mistaken** live review on still-active work (#211) |
|
||||||
|
| `gitea_cleanup_post_merge_moot_lease` | Moot **lease** cleanup after merge (#515) — different object |
|
||||||
|
|
||||||
## Fail-closed behavior
|
## Fail-closed behavior
|
||||||
|
|
||||||
Before any mutating action the workflow verifies identity, active profile,
|
Before any mutating action the workflow verifies identity, active profile,
|
||||||
@@ -1108,10 +1210,13 @@ When a mutation blocks on workspace binding:
|
|||||||
|
|
||||||
1. Read the error — it names the **resolved workspace path**, **role
|
1. Read the error — it names the **resolved workspace path**, **role
|
||||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
2. **LLM-allowed:** pass `worktree_path` on reviewer/merger mutation tools when
|
||||||
workspace (or set the role-specific env var before launch).
|
the active `branches/` worktree differs from the MCP process root; **client
|
||||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
reconnect** after transport EOF only (no process kill).
|
||||||
branches/ worktree differs from the MCP process root.
|
3. **Operator-owned:** if the wrong namespace process was launched, or a role-
|
||||||
|
specific `GITEA_*_WORKTREE` must be set at process start, an **operator**
|
||||||
|
reloads/relaunches the correct static namespace MCP. LLM sessions must not
|
||||||
|
kill or restart MCP processes (see [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||||
own namespace — that destroys another agent's WIP.
|
own namespace — that destroys another agent's WIP.
|
||||||
|
|
||||||
@@ -1121,9 +1226,10 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||||
- Quote the resolved workspace path and binding source from the error.
|
- Quote the resolved workspace path and binding source from the error.
|
||||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
- Name the safe next action: pass `worktree_path` if that unblocks the tool, or
|
||||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
request an **operator** reload of the correct namespace MCP / env binding.
|
||||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
- Explicitly note that foreign worktrees must not be cleaned to unblock, and
|
||||||
|
that the LLM must not self-restart the MCP process.
|
||||||
|
|
||||||
## Safety notes
|
## Safety notes
|
||||||
|
|
||||||
@@ -1134,6 +1240,8 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
## Related documents
|
## Related documents
|
||||||
|
|
||||||
|
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
||||||
|
- [`stable-runtime-promotion-runbook.md`](stable-runtime-promotion-runbook.md) — operator promotion procedure, required promotion-record fields, per-namespace post-flap re-proving, and rollback for the stable control runtime (#615).
|
||||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||||
|
|||||||
@@ -1,23 +1,92 @@
|
|||||||
# MCP daemon import and keychain guard (#558)
|
# MCP daemon import and native-transport guard (#558 / #695)
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
During deadlock debugging, agents imported `gitea_mcp_server` / ran credential
|
During deadlock debugging and the PR #694 incident (#695), agents imported
|
||||||
helpers from a raw shell, bypassing preflight purity and role gates.
|
`gitea_mcp_server` or ran credential helpers from a raw shell / offline helper,
|
||||||
|
bypassing native MCP transport, preflight purity, and role gates. Contaminated
|
||||||
|
formal reviews then looked identical to native approvals.
|
||||||
|
|
||||||
## Rule
|
## Rule
|
||||||
|
|
||||||
Mutation auth and keychain fill require a **sanctioned MCP daemon** process.
|
Mutation auth, keychain fill, and controller quarantine require a **production
|
||||||
|
native MCP transport runtime** established only by:
|
||||||
|
|
||||||
|
1. the **resolved absolute path** of the canonical entrypoint
|
||||||
|
(`mcp_server.py` / `gitea_mcp_server.py` next to `mcp_daemon_guard.py`), and
|
||||||
|
2. a live **transport bind** (`bind_native_mcp_transport(transport="stdio")`)
|
||||||
|
immediately before `mcp.run`.
|
||||||
|
|
||||||
|
Basename-only trust (a renamed file called `mcp_server.py`), caller-controlled
|
||||||
|
flags (there is **no** `allow_test_bootstrap`), environment variables, stack
|
||||||
|
frame spoofing, or import-only launch are insufficient.
|
||||||
|
|
||||||
| Context | Allowed |
|
| Context | Allowed |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| Official MCP entrypoint (`mcp_server.py` / `gitea_mcp_server` `__main__`) sets `GITEA_MCP_SANCTIONED_DAEMON=1` | yes |
|
| Official IDE-native MCP: resolved canonical entrypoint marks + binds stdio, holds process-local runtime token | yes |
|
||||||
| pytest | yes |
|
| pytest (hermetic unit tests) via `is_pytest_runtime()` | yes for unit gates |
|
||||||
| `GITEA_ALLOW_DIRECT_MCP_IMPORT=1` (operator/tests only) | yes |
|
| `install_test_native_runtime()` under pytest (test-mode record) | unit-test transport gates only — **never** production Gitea mutations |
|
||||||
| bare `python -c 'import gitea_auth; get_auth_header(...)'` | **no** |
|
| `allow_test_bootstrap=True` (removed; must not exist) | **no** |
|
||||||
| keychain fill without daemon | **no** unless `GITEA_ALLOW_KEYCHAIN_CLI=1` |
|
| Renamed runner basename `mcp_server.py` outside package root | **no** |
|
||||||
|
| Import/launch of real entrypoint without transport bind | **no** |
|
||||||
|
| `GITEA_MCP_SANCTIONED_DAEMON=1` alone (no process-local native runtime) | **no** (#695) |
|
||||||
|
| `GITEA_ALLOW_DIRECT_MCP_IMPORT=1` in LLM sessions | **no** — never set; never authorizes mutations (#695 AC1 / PR #701) |
|
||||||
|
| Override `GITEA_MCP_SESSION_STATE_DIR` mid-session | **no** — production bind pins state root; redirect cannot forge independent decision locks (#695 AC2 / PR #701) |
|
||||||
|
| `GITEA_ALLOW_KEYCHAIN_CLI=1` in LLM sessions | **no** — human operator only |
|
||||||
|
| bare `python -c 'import gitea_mcp_server; …'` or offline runners | **no** |
|
||||||
|
| keychain fill outside native/pytest | **no** |
|
||||||
|
|
||||||
|
Native runtime is **process-local**: a random token bound to the daemon PID and
|
||||||
|
transport phase. It is never reconstructed from environment variables,
|
||||||
|
session-state files, caller-controlled flags, or importing internals in a
|
||||||
|
fresh Python process.
|
||||||
|
|
||||||
|
## Contaminated review quarantine (#695 AC8)
|
||||||
|
|
||||||
|
Controller/reconciler/merger profiles may call
|
||||||
|
`gitea_quarantine_contaminated_review` with explicit confirmation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QUARANTINE CONTAMINATED REVIEW <review_id> PR <pr_number>
|
||||||
|
```
|
||||||
|
|
||||||
|
Quarantine records are durable under the MCP session-state root and are
|
||||||
|
**honored** by:
|
||||||
|
|
||||||
|
- `gitea_get_pr_review_feedback` (quarantined approvals do not authorize merge)
|
||||||
|
- `gitea_check_pr_eligibility` action=`merge`
|
||||||
|
- `gitea_merge_pr` (mutation)
|
||||||
|
- merger lease adoption paths that read `approval_at_current_head`
|
||||||
|
|
||||||
|
Forensic Gitea reviews and historical comments are **never deleted**.
|
||||||
|
|
||||||
|
## STOP after native MCP failure (AC10)
|
||||||
|
|
||||||
|
If the native MCP namespace dies (EOF, capability disconnect, session death):
|
||||||
|
|
||||||
|
1. **STOP.** State BLOCKED + DIAGNOSE.
|
||||||
|
2. Do **not** import `gitea_mcp_server` from a standalone process.
|
||||||
|
3. Do **not** run `offline_mcp_helper.py`, `offline_mcp_runner.py`,
|
||||||
|
`run_quarantine.py`, or any offline mutation helper.
|
||||||
|
4. Do **not** set direct-import, keychain-bypass, or raw-token environment
|
||||||
|
variables.
|
||||||
|
5. Reconnect / restart the official MCP daemon; resume only via native tools.
|
||||||
|
|
||||||
|
Any further native MCP failure is a hard stop. Do not construct another fallback.
|
||||||
|
|
||||||
|
## Canonical approval claims (AC7)
|
||||||
|
|
||||||
|
Comments that claim `approved` / `ready-to-merge` / `WHO_IS_NEXT: merger` /
|
||||||
|
`MERGE_READY: true` must include:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NATIVE_REVIEW_PROOF: transport=native_mcp; …
|
||||||
|
```
|
||||||
|
|
||||||
|
Claims that cite offline/import helpers are rejected even if a proof line is
|
||||||
|
present.
|
||||||
|
|
||||||
## Operator note
|
## Operator note
|
||||||
|
|
||||||
LLM sessions must never set the allow-direct-import or allow-keychain-cli
|
LLM sessions must never set allow-direct-import, allow-keychain-cli, or raw
|
||||||
overrides. Those are human-only escape hatches.
|
token overrides. Those are human-only escape hatches outside agent workflows.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
|||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
|
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
|
||||||
|
| Workflow dashboard (queue, leases, next safe action) | Documents the read-only `gitea_workflow_dashboard` MCP tool (#605): live PR/issue queues, leases by role, terminal review lock, blocked items, and exact next-safe prompts. **Does not assign work** — assignment still uses `gitea_allocate_next_work`. Never presents blocked/terminal-locked items as safe. The shell entry is documentation only (no Gitea mutation). |
|
||||||
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
||||||
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
|
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
|
||||||
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
|
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
|
||||||
@@ -50,6 +51,22 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
|||||||
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
|
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
|
||||||
| Exit | Quit the menu. |
|
| Exit | Quit the menu. |
|
||||||
|
|
||||||
|
### Workflow dashboard MCP tool (#605)
|
||||||
|
|
||||||
|
From any healthy Gitea MCP namespace with `gitea.read`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_workflow_dashboard(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Response includes `human_summary` plus structured queues, `active_leases_by_role`,
|
||||||
|
`terminal_review_lock`, `blocked_items`, `next_safe_by_role`, and
|
||||||
|
`primary_next_safe_action`. Incomplete inventory fails closed.
|
||||||
|
|
||||||
## Placeholder-only entries
|
## Placeholder-only entries
|
||||||
|
|
||||||
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
|
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Recovering from `client is closing: EOF` on a Gitea MCP namespace (#543)
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
A tool call through a Gitea MCP namespace — `gitea-author`, `gitea-reviewer`,
|
||||||
|
`gitea-merger`, or the shared `gitea-tools` namespace — fails immediately with:
|
||||||
|
|
||||||
|
```
|
||||||
|
client is closing: EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Every subsequent call to that same namespace returns the same error, including
|
||||||
|
cheap read tools such as `gitea_whoami` and `gitea_list_profiles`. Other MCP
|
||||||
|
servers registered with the same client (for example `context7`) keep working,
|
||||||
|
so this is **not** a global MCP-client outage.
|
||||||
|
|
||||||
|
## Why this is not a code defect
|
||||||
|
|
||||||
|
This failure is a **transport-level** condition in the IDE / MCP client manager,
|
||||||
|
not a missing or broken tool:
|
||||||
|
|
||||||
|
- The tool can be present and registered in the Python `FastMCP` tool manager.
|
||||||
|
- Direct Python inspection of the server confirms the tool exists.
|
||||||
|
- Running the server manually and sending JSON-RPC over stdio works fine
|
||||||
|
(offline spawn) — that path does **not** prove the IDE namespace is healthy.
|
||||||
|
|
||||||
|
The client manager entered a closed state after the backing subprocess for that
|
||||||
|
namespace terminated (or was killed) behind its back. Once closed, the client
|
||||||
|
does **not** re-spawn the child on the next tool call — it just replays
|
||||||
|
`client is closing: EOF`. The OS process may even still be alive if a parent
|
||||||
|
language-server process is holding the stdio pipes open.
|
||||||
|
|
||||||
|
This is the canonical "registered in FastMCP ≠ callable through the namespace"
|
||||||
|
false-ready state. It is distinct from the **stale-runtime** family in #531 /
|
||||||
|
#544, where the process is reachable but running behind `master`; that case is
|
||||||
|
detected by the `ps`-based `_check_mcp_runtimes_diagnostics` in
|
||||||
|
`gitea_mcp_server.py`. The EOF case is a dead/closed transport, not a stale one,
|
||||||
|
so the `ps` check alone will not surface it.
|
||||||
|
|
||||||
|
## Recovery path (canonical — client reconnect only)
|
||||||
|
|
||||||
|
Do the steps in order. Stop as soon as a live **client-namespace** call succeeds.
|
||||||
|
|
||||||
|
1. **Confirm the blast radius.** Call a cheap read tool on the failing namespace
|
||||||
|
(`gitea_whoami` or `gitea_list_profiles`). Then call the same tool on a
|
||||||
|
different MCP server (e.g. `context7`).
|
||||||
|
- Only the Gitea namespace fails → single-namespace transport close. Continue.
|
||||||
|
- Every server fails → restart the whole MCP client, not just one namespace.
|
||||||
|
|
||||||
|
2. **Reconnect the namespace through the client, not the shell.** Use the IDE /
|
||||||
|
client MCP-reconnect action for that server entry (in Claude Code:
|
||||||
|
`/mcp` → reconnect the affected `gitea-*` server). Reconnecting forces the
|
||||||
|
client to spawn a fresh subprocess and re-open the pipe. This clears the
|
||||||
|
closed-client state that a bare `kill`/respawn from a terminal does **not**.
|
||||||
|
|
||||||
|
3. **Do not "fix" it by importing the server or poking the process.** Reaching
|
||||||
|
for `python -c 'import gitea_mcp_server ...'`, raw JSON-RPC from a shell,
|
||||||
|
killing PIDs to force a respawn, or touching MCP config mtimes does **not**
|
||||||
|
restore the *client's* view of the namespace and violates the daemon-import
|
||||||
|
guard (#558, `docs/mcp-daemon-import-guard.md`). The only sanctioned repair
|
||||||
|
is a **client reconnect / relaunch**.
|
||||||
|
|
||||||
|
4. **Verify through the same path the workflow will use.** After reconnect, call
|
||||||
|
the specific tool the blocked workflow needs — not just any tool — through
|
||||||
|
the target namespace. For a merge that means calling the merger-authorized
|
||||||
|
adoption/merge tool through `gitea-merger`. A green `gitea_whoami` on one
|
||||||
|
namespace does **not** prove another namespace or another tool is callable.
|
||||||
|
Record success with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_assess_mcp_namespace_health(..., probe_source="client_namespace")
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **If reconnect does not clear it,** relaunch the client entirely, then repeat
|
||||||
|
step 4. If EOF persists after a full relaunch, the backing subprocess is
|
||||||
|
failing to start — inspect its stderr / launch config (command path, venv,
|
||||||
|
`*_MCP_CONFIG`, `*_MCP_PROFILE` env) rather than retrying the call. Still
|
||||||
|
do not use PID kill or config-touch as the primary recovery.
|
||||||
|
|
||||||
|
## Diagnostics to capture when reporting EOF
|
||||||
|
|
||||||
|
Include all of these so the failure is actionable and reproducible:
|
||||||
|
|
||||||
|
- **Namespace name** that returned EOF (`gitea-author` / `gitea-reviewer` /
|
||||||
|
`gitea-merger` / `gitea-tools`).
|
||||||
|
- **Tool** that was called and the **exact** error string.
|
||||||
|
- **PID** of the backing process (if any) and whether it was still alive
|
||||||
|
(informational only — not a recovery action).
|
||||||
|
- **Profile / env** for that namespace (execution profile, `*_MCP_PROFILE`,
|
||||||
|
worktree binding such as `GITEA_AUTHOR_WORKTREE`).
|
||||||
|
- **Config path** the client launched the server from.
|
||||||
|
- Result of the **cross-server control** call (did `context7` succeed?).
|
||||||
|
|
||||||
|
## Offline spawn probe (non-authoritative)
|
||||||
|
|
||||||
|
`test_mcp_conn.py` performs a full JSON-RPC handshake against a **fresh
|
||||||
|
subprocess** (`initialize` → `initialized` → `tools/list` → `tools/call`) and
|
||||||
|
classifies with `probe_source=offline_spawn`. That is useful for offline
|
||||||
|
launch/registration debugging. It is **not** proof the IDE-managed namespace is
|
||||||
|
healthy. See `docs/mcp-namespace-health.md`.
|
||||||
|
|
||||||
|
## Do-not list during EOF recovery
|
||||||
|
|
||||||
|
- Do **not** retry a blocked merge/adoption until the required tool is confirmed
|
||||||
|
callable through the merger-authorized **client** namespace (see #543).
|
||||||
|
- Do **not** clean, reset, or rebind a **foreign** worktree to work around the
|
||||||
|
error.
|
||||||
|
- Do **not** bypass the namespace with direct imports, raw API/curl, or
|
||||||
|
in-memory state restoration.
|
||||||
|
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
|
||||||
|
reconnect.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
|
||||||
|
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
|
||||||
|
- `docs/mcp-client-registration.md` — per-server registration contract.
|
||||||
|
- `docs/mcp-namespace-health.md` — probe sources and mutation enforcement.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# MCP namespace health diagnostics (#543)
|
||||||
|
|
||||||
|
Gitea MCP tools can be registered in the Python FastMCP server while the IDE's
|
||||||
|
live MCP namespace is still unusable. The failure usually appears as
|
||||||
|
`client is closing: EOF`, `transport closed`, or an empty response when calling
|
||||||
|
a tool such as `gitea_whoami`.
|
||||||
|
|
||||||
|
Do not treat static tool registration as proof that review or merge workflows
|
||||||
|
can proceed. A reviewer or merger flow must have **client-namespace** evidence
|
||||||
|
that the required tool is callable through the configured IDE MCP namespace.
|
||||||
|
|
||||||
|
## Probe sources (do not confuse them)
|
||||||
|
|
||||||
|
| Source | How obtained | Proves IDE namespace? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `client_namespace` | Tool call through the IDE-managed MCP client | **Yes** |
|
||||||
|
| `offline_spawn` | `test_mcp_conn.py` subprocess JSON-RPC handshake | **No** (offline only) |
|
||||||
|
|
||||||
|
`gitea_assess_mcp_namespace_health` accepts `probe_source` and only sets
|
||||||
|
`ide_namespace_proven=true` for `client_namespace` success. Offline spawn
|
||||||
|
success never clears review/merge mutation gates.
|
||||||
|
|
||||||
|
## Client-namespace health check (canonical)
|
||||||
|
|
||||||
|
1. Through the IDE client, call a cheap tool on the target namespace
|
||||||
|
(`gitea_whoami` or `gitea_list_profiles`).
|
||||||
|
2. Feed the live result into:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_assess_mcp_namespace_health(
|
||||||
|
namespace="gitea-merger", # or reviewer / author / tools
|
||||||
|
registered_tools=[...], # optional static list
|
||||||
|
probe_result={"success": true, "result": {...}},
|
||||||
|
probe_source="client_namespace",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. A healthy client-namespace assessment is recorded in the MCP session and
|
||||||
|
consulted by **live** `gitea_submit_pr_review` / `gitea_merge_pr` gates.
|
||||||
|
4. If the probe fails with EOF, recover via **client reconnect only** — see
|
||||||
|
`docs/mcp-namespace-eof-recovery.md`. Do **not** kill PIDs or touch MCP
|
||||||
|
config mtimes as a recovery procedure.
|
||||||
|
|
||||||
|
## Offline spawn probe (non-authoritative)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 test_mcp_conn.py --config ~/.gemini/config/mcp_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This script spawns a **separate** server process from config, performs
|
||||||
|
JSON-RPC `initialize` → `tools/list` → `tools/call`, and classifies the
|
||||||
|
result with `probe_source=offline_spawn`. Use it for offline debugging of
|
||||||
|
launch command / registration. It does **not** prove the IDE-managed
|
||||||
|
namespace is healthy.
|
||||||
|
|
||||||
|
By default the script checks:
|
||||||
|
|
||||||
|
| Namespace | Required tool |
|
||||||
|
| --- | --- |
|
||||||
|
| `gitea-author` | `gitea_whoami` |
|
||||||
|
| `gitea-reviewer` | `gitea_whoami` |
|
||||||
|
| `gitea-merger` | `gitea_whoami` |
|
||||||
|
| `gitea-tools` | `gitea_list_profiles` |
|
||||||
|
|
||||||
|
## Recovery (canonical)
|
||||||
|
|
||||||
|
When a namespace returns EOF, follow
|
||||||
|
`docs/mcp-namespace-eof-recovery.md` in order:
|
||||||
|
|
||||||
|
1. Confirm blast radius (Gitea namespace vs all MCP servers).
|
||||||
|
2. **Reconnect the namespace through the client** (IDE reconnect / relaunch).
|
||||||
|
3. Do not repair via shell imports, raw JSON-RPC, PID kills, or config mtime
|
||||||
|
touches — those do not restore the client's closed transport.
|
||||||
|
4. Re-verify the **specific** required tool through the target namespace.
|
||||||
|
5. Resume review/merge only after a successful `client_namespace` assessment.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
1. **State machine (read-only):** feed `blocks_merge_workflow` from a
|
||||||
|
`client_namespace` assessment into
|
||||||
|
`gitea_assess_review_merge_state_machine(live_namespace_broken=...)`.
|
||||||
|
2. **Live mutations:** `gitea_submit_pr_review` and `gitea_merge_pr` call
|
||||||
|
`_live_namespace_health_gate` and fail closed when the session has a
|
||||||
|
recorded unhealthy or non-client probe for the required namespace
|
||||||
|
(`gitea-reviewer` for review, `gitea-merger` for merge).
|
||||||
|
|
||||||
|
When blocked, repair the IDE namespace and re-record a healthy
|
||||||
|
`client_namespace` assessment before retrying the mutation.
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Self-hosted Sentry observability for the Gitea MCP server (#606)
|
||||||
|
|
||||||
|
Optional, **off-by-default** instrumentation that reports MCP runtime errors,
|
||||||
|
fail-closed workflow blockers, lease / terminal-lock / stale-runtime
|
||||||
|
collisions, and recurring watchdog check-ins to a **self-hosted** Sentry at
|
||||||
|
`https://sentry.prgs.cc/`.
|
||||||
|
|
||||||
|
> **Gitea remains the source of truth.** Sentry is observe-only. It never
|
||||||
|
> approves, merges, closes, or otherwise mutates Gitea workflow state, and it
|
||||||
|
> never bypasses leases, #332, workflow roles, or the MCP gates. Sentry alerts
|
||||||
|
> may only feed the *sanctioned* Gitea issue/comment path via the #612 incident
|
||||||
|
> bridge — never a direct write.
|
||||||
|
|
||||||
|
Implemented by [`sentry_observability.py`](../../sentry_observability.py).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Create the Sentry project
|
||||||
|
|
||||||
|
1. Sign in to the self-hosted Sentry at **`https://sentry.prgs.cc/`** (this is
|
||||||
|
**not** Sentry Cloud — do not use `*.ingest.sentry.io`).
|
||||||
|
2. Create a new **Python** project named **`gitea-tools-mcp`**.
|
||||||
|
3. Open **Settings → Projects → gitea-tools-mcp → Client Keys (DSN)** and copy
|
||||||
|
the DSN. It looks like `https://<publickey>@sentry.prgs.cc/<project-id>`.
|
||||||
|
4. **Never commit the DSN.** It is a runtime secret supplied via env var only.
|
||||||
|
|
||||||
|
## 2. Configure the environment
|
||||||
|
|
||||||
|
All configuration is env-var driven (see [`.env.example`](../../.env.example)):
|
||||||
|
|
||||||
|
| Variable | Purpose | Default |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `MCP_SENTRY_ENABLED` | Master gate (`1/true/yes/on`). Required. | off |
|
||||||
|
| `SENTRY_DSN` | Self-hosted DSN. Required. | *(empty)* |
|
||||||
|
| `SENTRY_ENVIRONMENT` | `local` / `dev` / `prod` tag. | `development` |
|
||||||
|
| `SENTRY_RELEASE` | Release id (git SHA or version). | *(none)* |
|
||||||
|
| `MCP_SENTRY_TRACES_SAMPLE_RATE` | Perf-trace sample rate `0.0–1.0` (clamped). | `0.0` |
|
||||||
|
| `MCP_SENTRY_ENABLE_LOGS` | Forward Python logs as structured logs. | off |
|
||||||
|
|
||||||
|
**The feature stays completely off unless `MCP_SENTRY_ENABLED` is truthy *and*
|
||||||
|
`SENTRY_DSN` is non-empty.** With either missing, `init_sentry()` is a no-op,
|
||||||
|
the SDK is never initialised, and no events are sent — existing tool behaviour
|
||||||
|
and API-call patterns are unchanged.
|
||||||
|
|
||||||
|
### Per-environment examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# local (quiet: capture errors/blockers, no traces)
|
||||||
|
export MCP_SENTRY_ENABLED=1
|
||||||
|
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||||
|
export SENTRY_ENVIRONMENT=local
|
||||||
|
|
||||||
|
# dev (light tracing + logs)
|
||||||
|
export MCP_SENTRY_ENABLED=1
|
||||||
|
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||||
|
export SENTRY_ENVIRONMENT=dev
|
||||||
|
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.2
|
||||||
|
export MCP_SENTRY_ENABLE_LOGS=1
|
||||||
|
|
||||||
|
# prod (errors/blockers + low-rate tracing, release-tagged)
|
||||||
|
export MCP_SENTRY_ENABLED=1
|
||||||
|
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||||
|
export SENTRY_ENVIRONMENT=prod
|
||||||
|
export SENTRY_RELEASE="$(git rev-parse --short HEAD)"
|
||||||
|
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.05
|
||||||
|
```
|
||||||
|
|
||||||
|
The optional SDK is pinned in [`requirements.txt`](../../requirements.txt)
|
||||||
|
(`sentry-sdk==2.20.0`). It is imported lazily: if the package is absent, the
|
||||||
|
module still imports and every entry point is a safe no-op.
|
||||||
|
|
||||||
|
## 3. What is instrumented
|
||||||
|
|
||||||
|
| Signal | Where | Notes |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| Startup init | `gitea_mcp_server.py` `__main__`, before `mcp.run` | Prints a redaction-safe status line to stderr. |
|
||||||
|
| Failing mutations (exceptions) | `_audited(...)` context manager | `capture_exception` with scrubbed tags. |
|
||||||
|
| Fail-closed blockers / failed mutations | `_audit_pr_result(...)` (BLOCKED/FAILED) | Structured `capture_workflow_blocker` event incl. the canonical next action when available (criterion 7). |
|
||||||
|
| Allocator watchdog check-ins | `gitea_allocate_next_work` tool | `allocator_health`, `stale_lease_scan`, `terminal_lock_scan`. |
|
||||||
|
| Namespace-health check-in | `gitea_assess_mcp_namespace_health` tool | `namespace_health`. |
|
||||||
|
|
||||||
|
All capture paths are **best-effort / fail open**: a Sentry outage or capture
|
||||||
|
error never breaks an MCP tool success path.
|
||||||
|
|
||||||
|
## 4. Cron / watchdog monitors
|
||||||
|
|
||||||
|
`sentry_observability.MONITOR_SLUGS` defines stable check-in slugs:
|
||||||
|
|
||||||
|
| Registry key | Sentry monitor slug | Wired at |
|
||||||
|
|--------------|--------------------|----------|
|
||||||
|
| `stale_lease_scan` | `gitea-mcp-stale-lease-scan` | allocator run (global lease expiry) |
|
||||||
|
| `terminal_lock_scan` | `gitea-mcp-terminal-lock-scan` | allocator run (terminal-lock lookup) |
|
||||||
|
| `allocator_health` | `gitea-mcp-allocator-health` | allocator run |
|
||||||
|
| `namespace_health` | `gitea-mcp-namespace-health` | namespace-health probe |
|
||||||
|
| `dashboard_freshness` | `gitea-mcp-dashboard-freshness` | call `monitor_checkin("dashboard_freshness", ...)` from the dashboard refresh job (#605) |
|
||||||
|
| `reconciler_cleanup` | `gitea-mcp-reconciler-cleanup` | call `monitor_checkin("reconciler_cleanup", ...)` from the reconciler cleanup entrypoint |
|
||||||
|
|
||||||
|
Create matching Cron monitors in Sentry with those slugs. Emit an
|
||||||
|
`in_progress` check-in at job start and `ok`/`error` at completion via
|
||||||
|
`sentry_observability.monitor_checkin(slug_key, status)`.
|
||||||
|
|
||||||
|
## 5. Redaction guarantees (fail closed)
|
||||||
|
|
||||||
|
Redaction fails *closed*: if a field cannot be proven safe it is dropped rather
|
||||||
|
than sent. The `before_send` (and `before_send_log`) hook `scrub_event`
|
||||||
|
recursively redacts every outgoing event; on any error it drops the event
|
||||||
|
entirely. Guarantees, proven by `tests/test_sentry_observability.py`:
|
||||||
|
|
||||||
|
- **No** tokens, passwords, keychain IDs, DSNs, cookies, or `user:pass@host`.
|
||||||
|
- **No** raw session-state or full prompt/comment bodies — `session_id` is only
|
||||||
|
ever surfaced as a 12-char `session_id_hash`.
|
||||||
|
- **No** private config contents or raw credential headers.
|
||||||
|
- **No** full local filesystem paths — a worktree path collapses to a coarse
|
||||||
|
`worktree_category` (`author` / `reviewer` / `merger` / `reconciler` /
|
||||||
|
`branches` / `root` / `other`).
|
||||||
|
- Only the allowlisted tag keys in `ALLOWED_TAG_KEYS` are ever attached.
|
||||||
|
|
||||||
|
## 6. Coexistence with GlitchTip / the #612 incident bridge
|
||||||
|
|
||||||
|
This is the **outbound** path (MCP → Sentry SDK). It complements — it does not
|
||||||
|
replace — the **inbound** [`incident_bridge.py`](../../incident_bridge.py)
|
||||||
|
(#612), which turns Sentry/GlitchTip *observations* into durable Gitea issues
|
||||||
|
and `incident_links` rows.
|
||||||
|
|
||||||
|
- Prefer **one** observability path per environment. Point the MCP server's
|
||||||
|
`SENTRY_DSN` at the same self-hosted `gitea-tools-mcp` project that the #612
|
||||||
|
bridge reconciles from, so an MCP-reported error and its Gitea issue line up.
|
||||||
|
- GlitchTip is Sentry-protocol compatible; if an existing GlitchTip DSN is in
|
||||||
|
use, either migrate it to `https://sentry.prgs.cc/` or document the split
|
||||||
|
(MCP → Sentry, legacy → GlitchTip) explicitly for operators.
|
||||||
|
- The bridge remains the **only** sanctioned route from an alert back into
|
||||||
|
Gitea workflow state.
|
||||||
|
|
||||||
|
## 7. Reading Sentry back into Gitea (#607)
|
||||||
|
|
||||||
|
[`sentry_incident_bridge.py`](../../sentry_incident_bridge.py) supplies the
|
||||||
|
**read** half of the inbound path: it pulls unresolved issues/events from the
|
||||||
|
self-hosted Sentry API, normalizes them into #612 observations, and hands them
|
||||||
|
to `incident_bridge.reconcile_incident`. It never adds a second linking store —
|
||||||
|
`incident_links` on the #613 control-plane DB stays canonical, which is what
|
||||||
|
makes the mapping survive restarts.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Variable | Purpose | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SENTRY_BASE_URL` | Self-hosted Sentry root | `https://sentry.prgs.cc` |
|
||||||
|
| `SENTRY_AUTH_TOKEN` | API token — **env only**, never logged or returned | _(unset)_ |
|
||||||
|
| `SENTRY_ORG` | Sentry organization slug | _(unset)_ |
|
||||||
|
| `SENTRY_PROJECT` | Sentry project slug | _(unset)_ |
|
||||||
|
| `MCP_SENTRY_ISSUE_BRIDGE_ENABLED` | Required for `apply=true` | `false` |
|
||||||
|
| `MCP_SENTRY_MIN_EVENTS_FOR_ISSUE` | Recurrence threshold before an issue is worth filing | `2` |
|
||||||
|
| `MCP_SENTRY_LOOKBACK` | Scan window (`statsPeriod`, e.g. `24h`) | `24h` |
|
||||||
|
|
||||||
|
Missing org/project fails closed as `not_configured`; a missing token fails
|
||||||
|
closed as `missing_token` **before** any HTTP call is made.
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
| Tool | Mode | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gitea_sentry_list_issues` | read-only | Unresolved issues, `Link`-header pagination |
|
||||||
|
| `gitea_sentry_get_issue_events` | read-only | Sanitized recent + latest event for one issue |
|
||||||
|
| `gitea_sentry_reconcile_issue` | dry-run default | One Sentry issue → durable Gitea issue |
|
||||||
|
| `gitea_sentry_link_gitea_issue` | dry-run default | Link a Sentry issue to an existing Gitea issue |
|
||||||
|
| `gitea_sentry_watchdog` | dry-run default | Scan + create/update issues for active incidents |
|
||||||
|
|
||||||
|
### Policy
|
||||||
|
|
||||||
|
- **Dedupe:** one Sentry issue maps to exactly one Gitea issue, keyed by
|
||||||
|
provider + base URL + org + project + issue id. Recurrence updates the link
|
||||||
|
(and its `event_count`) instead of filing a duplicate.
|
||||||
|
- **No reopen:** a Sentry issue that is no longer `unresolved` is skipped; the
|
||||||
|
bridge never reopens or re-files a closed Gitea issue.
|
||||||
|
- **Threshold:** issues below `MCP_SENTRY_MIN_EVENTS_FOR_ISSUE` are skipped, so
|
||||||
|
one-off noise does not become durable work.
|
||||||
|
- **Apply is explicit:** `apply=true` requires both
|
||||||
|
`MCP_SENTRY_ISSUE_BRIDGE_ENABLED` and issue-create permission on the profile.
|
||||||
|
- **Outages fail closed:** an unreachable Sentry returns `sentry_unavailable`
|
||||||
|
and creates nothing.
|
||||||
|
- **Redaction:** secrets are scrubbed and absolute local paths are reduced to a
|
||||||
|
category token (`[path:author]`, `[path:root]`, …) before any value reaches a
|
||||||
|
Gitea issue body. Sensitive tag keys (`authorization`, `cookie`, …) are
|
||||||
|
dropped, and permalinks carrying embedded credentials are discarded entirely.
|
||||||
|
|
||||||
|
## 8. Non-goals
|
||||||
|
|
||||||
|
- Sentry must **not** become the workflow source of truth.
|
||||||
|
- Sentry must **not** approve, merge, close, or mutate Gitea workflow state.
|
||||||
|
- Sentry must **not** bypass leases, #332, workflow roles, or the MCP gates.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Stable control runtime — promotion runbook (#615)
|
||||||
|
|
||||||
|
Operator / release-manager procedure for promoting a revision into the **stable
|
||||||
|
control runtime**: the Gitea MCP server that performs real issue/PR mutations.
|
||||||
|
|
||||||
|
Policy source: [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md).
|
||||||
|
Enforcement: `stable_control_runtime.py` (runtime mode classification, mutation
|
||||||
|
gates, per-namespace post-flap re-proving, promotion-record validation).
|
||||||
|
|
||||||
|
**Promotion is operator-owned.** Normal author / reviewer / merger / reconciler
|
||||||
|
sessions must never kill, restart, or relaunch the MCP server, and must never
|
||||||
|
edit the stable runtime checkout. A session that needs newer server code stops
|
||||||
|
with `BLOCKED + DIAGNOSE` and hands off to the operator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. When a promotion is required
|
||||||
|
|
||||||
|
- A merged PR changes MCP server code the control plane must now enforce.
|
||||||
|
- `gitea_assess_master_parity` reports `stale: true` / `restart_required: true`.
|
||||||
|
- `gitea_get_runtime_context` reports a `runtime_mode` other than
|
||||||
|
`stable-control`, or `real_mutations_allowed: false`.
|
||||||
|
|
||||||
|
## 2. Pre-promotion checks
|
||||||
|
|
||||||
|
Run these **before** advancing the stable checkout:
|
||||||
|
|
||||||
|
1. The target revision is on remote `master` and was merged through
|
||||||
|
`gitea_merge_pr` (never a direct stable-branch push — see #671).
|
||||||
|
2. The stable control checkout is clean (`git status --porcelain` empty) and on
|
||||||
|
`master`. A dirty stable runtime is itself a mutation blocker.
|
||||||
|
3. The advance is strictly fast-forwardable: local `master` is an ancestor of
|
||||||
|
`prgs/master`.
|
||||||
|
4. No active workflow lease is mid-mutation (`gitea_list_workflow_leases`).
|
||||||
|
|
||||||
|
## 3. Promotion steps
|
||||||
|
|
||||||
|
1. Record the **previous** runtime SHA (`gitea_assess_master_parity` →
|
||||||
|
`startup_head`).
|
||||||
|
2. `git fetch --prune prgs` in the stable control checkout.
|
||||||
|
3. `git merge --ff-only prgs/master` — never rebase, reset, or force.
|
||||||
|
4. Record the **promoted** runtime SHA (`git rev-parse HEAD`).
|
||||||
|
5. Reload the runtime using the sanctioned client path (IDE/client reconnect or
|
||||||
|
the operator's supervised service reload). Never `pkill` the daemon from a
|
||||||
|
workflow session.
|
||||||
|
6. Re-prove **each** namespace independently (see §5).
|
||||||
|
7. Record the promotion (see §4) and post it as a durable comment on the
|
||||||
|
tracking issue.
|
||||||
|
|
||||||
|
## 4. Promotion record (required fields)
|
||||||
|
|
||||||
|
Every promotion must record all of the following. `assess_promotion_record()`
|
||||||
|
validates them and fails closed on any missing field, or when
|
||||||
|
`previous_runtime_sha` equals `promoted_runtime_sha` (nothing was promoted).
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `previous_runtime_sha` | SHA the stable runtime was serving before promotion |
|
||||||
|
| `promoted_runtime_sha` | SHA the stable runtime serves after promotion |
|
||||||
|
| `source_branch` | Branch the promoted revision came from |
|
||||||
|
| `source_pr` | PR number that merged it |
|
||||||
|
| `restart_method` | Exact reload/restart mechanism the operator used |
|
||||||
|
| `health_check_proof` | `gitea_assess_mcp_namespace_health` result per namespace |
|
||||||
|
| `identity_proof` | `gitea_whoami` username + profile per namespace |
|
||||||
|
| `profile_proof` | `gitea_get_runtime_context` active profile per namespace |
|
||||||
|
| `workspace_proof` | Process root, canonical root, alignment, clean state |
|
||||||
|
| `mutation_capability_proof` | `gitea_resolve_task_capability` for the intended task |
|
||||||
|
| `rollback_instructions` | Exact steps to return to `previous_runtime_sha` |
|
||||||
|
|
||||||
|
Helper: `scripts/promote-stable-runtime` emits and validates the record. It
|
||||||
|
never restarts anything — it reads state and prints the record for the operator
|
||||||
|
to act on and archive.
|
||||||
|
|
||||||
|
## 5. Post-promotion namespace re-proving
|
||||||
|
|
||||||
|
A restart or transport flap drops every `gitea-*` namespace together. Author
|
||||||
|
proof is **not** global proof. For each of `author`, `reviewer`, `merger`,
|
||||||
|
`reconciler`, in that namespace:
|
||||||
|
|
||||||
|
1. `gitea_whoami`
|
||||||
|
2. `gitea_get_runtime_context`
|
||||||
|
3. `gitea_resolve_task_capability` immediately before the intended mutation
|
||||||
|
4. Mutate only when no reconnect / restart / stale-runtime gate is reported
|
||||||
|
|
||||||
|
Until a namespace passes all four, its mutations stay blocked with
|
||||||
|
`namespace_not_reproven_after_flap`.
|
||||||
|
|
||||||
|
## 6. Rollback
|
||||||
|
|
||||||
|
If the promoted runtime is unhealthy — namespace EOF that does not recover,
|
||||||
|
identity or profile mismatch, capability resolution failure, or an unexpected
|
||||||
|
`runtime_mode`:
|
||||||
|
|
||||||
|
1. **Stop all PR/review/merge work.** An unhealthy stable runtime fails closed;
|
||||||
|
do not route around it.
|
||||||
|
2. Fast-forward or check out `previous_runtime_sha` in the stable checkout.
|
||||||
|
3. Reload the runtime by the same sanctioned method.
|
||||||
|
4. Re-prove every namespace (§5).
|
||||||
|
5. Record the rollback as a promotion record whose `promoted_runtime_sha` is the
|
||||||
|
restored SHA, with the failure evidence in `health_check_proof`.
|
||||||
|
|
||||||
|
## 7. Runtime modes seen in reports
|
||||||
|
|
||||||
|
| Mode | Meaning | Real mutations |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| `stable-control` | Promoted revision, stable branch, clean checkout | Allowed |
|
||||||
|
| `dev-test` | Launched from a `branches/` worktree or a feature branch | Blocked against production |
|
||||||
|
| `unknown` | Root unresolvable, not a git checkout, or detached HEAD with no declaration | Blocked |
|
||||||
|
|
||||||
|
A packaged deployment with no git checkout must declare itself explicitly with
|
||||||
|
`GITEA_MCP_RUNTIME_MODE=stable-control`; an unset or misspelled value falls back
|
||||||
|
to inference and, failing that, to `unknown`.
|
||||||
|
|
||||||
|
## 8. Related
|
||||||
|
|
||||||
|
- `architecture/mcp-stable-control-runtime-policy-adr.md` — the policy (#615)
|
||||||
|
- `mcp-namespace-health.md` — client-namespace health (#543)
|
||||||
|
- `mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery
|
||||||
|
- `mcp-daemon-import-guard.md` — sanctioned daemon only (#558)
|
||||||
|
- `bootstrap-review-path.md` — controller bootstrap when the live runtime cannot
|
||||||
|
review its own fix (#557)
|
||||||
@@ -14,6 +14,7 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server
|
|||||||
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
||||||
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
||||||
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
||||||
|
7. **Stable control runtime** — Real Gitea mutations use only the **stable** MCP control runtime. LLM sessions must not kill, restart, or relaunch MCP processes, edit the stable checkout, or use a dev MCP for production mutations. See the policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||||
|
|
||||||
## Supported Gitea instances
|
## Supported Gitea instances
|
||||||
|
|
||||||
@@ -29,4 +30,5 @@ Always pass `remote` explicitly on tool calls. The server default is `dadeschool
|
|||||||
1. `gitea_whoami` — confirm authenticated user and profile.
|
1. `gitea_whoami` — confirm authenticated user and profile.
|
||||||
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
||||||
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
||||||
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
||||||
|
5. Confirm master parity / runtime health when tools report stale or unhealthy control runtime — stop mutations and request an **operator** reload of the stable MCP (see [stable control runtime ADR](../architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||||
@@ -12,6 +12,17 @@
|
|||||||
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
||||||
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
||||||
|
|
||||||
|
## Stable MCP control runtime (#615)
|
||||||
|
|
||||||
|
Policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md).
|
||||||
|
|
||||||
|
| Situation | Who acts | What to do |
|
||||||
|
|-----------|----------|------------|
|
||||||
|
| Transport EOF / missing tools | LLM | **Client reconnect only** — do not kill PIDs |
|
||||||
|
| Wrong profile / dual-namespace missing | Operator | Configure or reload the correct static namespace(s) |
|
||||||
|
| Master parity stale after merge | LLM stops + reports; **operator** reloads stable MCP | Resume only after parity re-verified |
|
||||||
|
| Promote unpromoted MCP code to stable | Operator / release-manager only | Full §2.4 promotion record |
|
||||||
|
|
||||||
## Gitea Wiki sync
|
## Gitea Wiki sync
|
||||||
|
|
||||||
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
||||||
|
|||||||
@@ -15,5 +15,7 @@
|
|||||||
5. **Explicit merge confirmation** — `gitea_merge_pr` requires `confirmation="MERGE PR <n>"`.
|
5. **Explicit merge confirmation** — `gitea_merge_pr` requires `confirmation="MERGE PR <n>"`.
|
||||||
6. **No self-review / self-merge** — Authenticated user must differ from PR author for approve/merge.
|
6. **No self-review / self-merge** — Authenticated user must differ from PR author for approve/merge.
|
||||||
7. **Review decision lock** — Live review mutations require validation-phase dry-run and `gitea_mark_final_review_decision`.
|
7. **Review decision lock** — Live review mutations require validation-phase dry-run and `gitea_mark_final_review_decision`.
|
||||||
8. **Redaction** — Tokens, passwords, and keychain material never appear in tool output.
|
8. **Terminal review hard-stop (#332)** — After a terminal live review mutation, only same-PR merge after `approve` may continue. Durable locks (#559) must not be deleted by hand.
|
||||||
9. **Wiki publication (#224)** — `docs/wiki/` and the sync helper are prerequisites only. Closing a wiki issue requires live Gitea Wiki proof on the repo Wiki tab. See [Runbooks](Runbooks.md#wiki-publication-readiness-gate-224).
|
9. **Stale decision-lock cleanup (#594)** — `gitea_cleanup_stale_review_decision_lock` may clear a durable #332 lock **only** when the last terminal mutation's PR is live-state **merged or closed**, identity/profile gates pass, and apply uses a reviewer-capable profile. Open/ambiguous locks stay fail-closed. Successful cleanup records a durable audit trail.
|
||||||
|
10. **Redaction** — Tokens, passwords, and keychain material never appear in tool output.
|
||||||
|
11. **Wiki publication (#224)** — `docs/wiki/` and the sync helper are prerequisites only. Closing a wiki issue requires live Gitea Wiki proof on the repo Wiki tab. See [Runbooks](Runbooks.md#wiki-publication-readiness-gate-224).
|
||||||
+148
-9
@@ -14,6 +14,7 @@ from typing import Any, Callable
|
|||||||
import branch_cleanup_guard
|
import branch_cleanup_guard
|
||||||
import issue_acceptance_gate
|
import issue_acceptance_gate
|
||||||
import issue_lock_provenance
|
import issue_lock_provenance
|
||||||
|
import merger_lease_adoption
|
||||||
import reviewer_handoff_consistency
|
import reviewer_handoff_consistency
|
||||||
import thread_state_ledger_validator
|
import thread_state_ledger_validator
|
||||||
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
||||||
@@ -39,6 +40,7 @@ FINAL_REPORT_TASK_KINDS = frozenset({
|
|||||||
"issue_filing",
|
"issue_filing",
|
||||||
"issue_selection",
|
"issue_selection",
|
||||||
"inventory",
|
"inventory",
|
||||||
|
"controller_close",
|
||||||
})
|
})
|
||||||
|
|
||||||
_TASK_KIND_ALIASES = {
|
_TASK_KIND_ALIASES = {
|
||||||
@@ -50,6 +52,9 @@ _TASK_KIND_ALIASES = {
|
|||||||
"reconcile_already_landed": "reconcile_already_landed",
|
"reconcile_already_landed": "reconcile_already_landed",
|
||||||
"work-issue": "work_issue",
|
"work-issue": "work_issue",
|
||||||
"create_issue": "issue_filing",
|
"create_issue": "issue_filing",
|
||||||
|
"close_issue": "controller_close",
|
||||||
|
"close-issue": "controller_close",
|
||||||
|
"controller-close": "controller_close",
|
||||||
}
|
}
|
||||||
|
|
||||||
_HANDOFF_ROLE_BY_TASK = {
|
_HANDOFF_ROLE_BY_TASK = {
|
||||||
@@ -61,6 +66,7 @@ _HANDOFF_ROLE_BY_TASK = {
|
|||||||
"issue_filing": "issue_filing",
|
"issue_filing": "issue_filing",
|
||||||
"issue_selection": None,
|
"issue_selection": None,
|
||||||
"inventory": "inventory",
|
"inventory": "inventory",
|
||||||
|
"controller_close": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
_LEGACY_WORKSPACE_MUTATIONS_RE = re.compile(
|
_LEGACY_WORKSPACE_MUTATIONS_RE = re.compile(
|
||||||
@@ -128,16 +134,22 @@ _TARGET_BRANCH_SHA_RE = re.compile(
|
|||||||
r"target branch sha\s*:\s*[0-9a-f]{40}",
|
r"target branch sha\s*:\s*[0-9a-f]{40}",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
# #698: structured proof is rendered in several equivalent shapes —
|
||||||
|
# `workflow_hash: abc...`, `workflow_hash=abc...`, or JSON
|
||||||
|
# `"workflow_hash": "abc..."`. Recognize all of them; demanding one exact
|
||||||
|
# punctuation style rejects legitimate structured workflow-load proof.
|
||||||
_WORKFLOW_LOAD_HELPER_RE = re.compile(
|
_WORKFLOW_LOAD_HELPER_RE = re.compile(
|
||||||
r"workflow[- ]load helper result\s*:",
|
r"workflow[-_ ]load[-_ ]helper[-_ ]result\s*[:=]",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_WORKFLOW_LOAD_HASH_RE = re.compile(
|
_WORKFLOW_LOAD_HASH_RE = re.compile(
|
||||||
r"workflow[- ]load helper result[\s\S]{0,400}?workflow[_ ]hash\s*:\s*[0-9a-f]{12}",
|
r"workflow[-_ ]load[-_ ]helper[-_ ]result[\s\S]{0,400}?"
|
||||||
|
r"workflow[_ ]hash\"?\s*[:=]\s*\"?[0-9a-f]{12}",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_WORKFLOW_LOAD_BOUNDARY_RE = re.compile(
|
_WORKFLOW_LOAD_BOUNDARY_RE = re.compile(
|
||||||
r"workflow[- ]load helper result[\s\S]{0,400}?boundary[_ ]status\s*:\s*(?:clean|violation)",
|
r"workflow[-_ ]load[-_ ]helper[-_ ]result[\s\S]{0,400}?"
|
||||||
|
r"boundary[_ ]status\"?\s*[:=]\s*\"?(?:clean|violation)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_WORKFLOW_FILE_VIEW_NARRATIVE_RE = re.compile(
|
_WORKFLOW_FILE_VIEW_NARRATIVE_RE = re.compile(
|
||||||
@@ -238,6 +250,59 @@ def _normalize_task_kind(task_kind: str | None) -> str:
|
|||||||
return _TASK_KIND_ALIASES.get(raw, raw)
|
return _TASK_KIND_ALIASES.get(raw, raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_action_entries(action_log: list | None) -> list[dict]:
|
||||||
|
"""Yield only structured (dict) action-log entries (#698).
|
||||||
|
|
||||||
|
Callers must never crash on malformed entries (strings, numbers, null)
|
||||||
|
that reach the validator from LLM-composed or partially parsed logs;
|
||||||
|
:func:`sanitize_action_log` reports them separately.
|
||||||
|
"""
|
||||||
|
return [e for e in (action_log or []) if isinstance(e, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_action_log(
|
||||||
|
action_log: list | None,
|
||||||
|
) -> tuple[list[dict], list[dict[str, str]]]:
|
||||||
|
"""Split an action log into structured entries and sanitized findings (#698).
|
||||||
|
|
||||||
|
Malformed entries become clear, sanitized ``warning`` findings — the
|
||||||
|
offending value's content is never echoed back (only its position and
|
||||||
|
type), so secrets or garbage in a broken log cannot leak into validation
|
||||||
|
errors, and validation itself proceeds without secondary exceptions.
|
||||||
|
"""
|
||||||
|
if action_log is None:
|
||||||
|
return [], []
|
||||||
|
if not isinstance(action_log, (list, tuple)):
|
||||||
|
return [], [
|
||||||
|
validator_finding(
|
||||||
|
"shared.action_log_malformed",
|
||||||
|
"downgrade",
|
||||||
|
"Action log",
|
||||||
|
"action_log is not a list of structured entries "
|
||||||
|
f"(got {type(action_log).__name__}); it was ignored",
|
||||||
|
"pass action_log as a list of dict entries",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
entries: list[dict] = []
|
||||||
|
findings: list[dict[str, str]] = []
|
||||||
|
for index, entry in enumerate(action_log):
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
entries.append(entry)
|
||||||
|
continue
|
||||||
|
findings.append(
|
||||||
|
validator_finding(
|
||||||
|
"shared.action_log_malformed",
|
||||||
|
"downgrade",
|
||||||
|
"Action log",
|
||||||
|
f"action_log entry {index} is not a structured mapping "
|
||||||
|
f"(got {type(entry).__name__}); the entry was ignored",
|
||||||
|
"repair the malformed action_log entry or drop it before "
|
||||||
|
"revalidating",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries, findings
|
||||||
|
|
||||||
|
|
||||||
def validator_finding(
|
def validator_finding(
|
||||||
rule_id: str,
|
rule_id: str,
|
||||||
severity: str,
|
severity: str,
|
||||||
@@ -415,7 +480,7 @@ def _rule_shared_canonical_comment_post_claim(
|
|||||||
rejected_in_report = bool(_CANONICAL_VALIDATION_REJECTED_RE.search(text))
|
rejected_in_report = bool(_CANONICAL_VALIDATION_REJECTED_RE.search(text))
|
||||||
rejected_in_log = False
|
rejected_in_log = False
|
||||||
if action_log:
|
if action_log:
|
||||||
for entry in action_log:
|
for entry in _iter_action_entries(action_log):
|
||||||
validation = entry.get("canonical_comment_validation") or {}
|
validation = entry.get("canonical_comment_validation") or {}
|
||||||
if validation.get("allowed") is False:
|
if validation.get("allowed") is False:
|
||||||
rejected_in_log = True
|
rejected_in_log = True
|
||||||
@@ -469,9 +534,13 @@ def _rule_reviewer_vague_mutations_none(
|
|||||||
action_log: list[dict] | None = None,
|
action_log: list[dict] | None = None,
|
||||||
mutations_observed: bool = False,
|
mutations_observed: bool = False,
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
|
# #698: infer review mutations only from authoritative evidence — an
|
||||||
|
# entry proves a mutation only when it affirmatively records
|
||||||
|
# performed=true and was not gated. Read-only diagnostics and pre-API
|
||||||
|
# rejections (entries without a performed flag) are not mutations.
|
||||||
performed = any(
|
performed = any(
|
||||||
e.get("performed") is not False and not e.get("gated_rejected")
|
e.get("performed") is True and not e.get("gated_rejected")
|
||||||
for e in (action_log or [])
|
for e in _iter_action_entries(action_log)
|
||||||
)
|
)
|
||||||
if not (mutations_observed or performed):
|
if not (mutations_observed or performed):
|
||||||
return []
|
return []
|
||||||
@@ -530,7 +599,7 @@ def _rule_reviewer_git_fetch_readonly(
|
|||||||
text = report_text or ""
|
text = report_text or ""
|
||||||
fetch_observed = any(
|
fetch_observed = any(
|
||||||
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
|
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
|
||||||
for e in (action_log or [])
|
for e in _iter_action_entries(action_log)
|
||||||
) or _GIT_FETCH_RE.search(text)
|
) or _GIT_FETCH_RE.search(text)
|
||||||
if not fetch_observed:
|
if not fetch_observed:
|
||||||
return []
|
return []
|
||||||
@@ -849,6 +918,27 @@ def _rule_reviewer_premerge_baseline_proof(report_text: str) -> list[dict[str, s
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_reviewer_post_merge_validation(report_text: str) -> list[dict[str, str]]:
|
||||||
|
"""Block active approval on an already-merged/closed PR, and post-merge moot
|
||||||
|
validation claimed without merged-state + merge-commit proof (#529)."""
|
||||||
|
from post_merge_validation import assess_post_merge_validation
|
||||||
|
|
||||||
|
result = assess_post_merge_validation(report_text)
|
||||||
|
if not result.get("block"):
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"reviewer.post_merge_validation",
|
||||||
|
result.get("reasons") or [],
|
||||||
|
field="Validation status",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=result.get("safe_next_action")
|
||||||
|
or (
|
||||||
|
"record post-merge moot validation instead of an active approval; "
|
||||||
|
"cite PR state merged/closed and the merge commit SHA"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_validation_status_vocabulary(
|
def _rule_reviewer_validation_status_vocabulary(
|
||||||
report_text: str,
|
report_text: str,
|
||||||
*,
|
*,
|
||||||
@@ -950,7 +1040,7 @@ def _rule_reviewer_target_branch_freshness(
|
|||||||
fields = _handoff_fields(text)
|
fields = _handoff_fields(text)
|
||||||
fetch_reported = bool(_GIT_FETCH_RE.search(text)) or any(
|
fetch_reported = bool(_GIT_FETCH_RE.search(text)) or any(
|
||||||
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
|
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
|
||||||
for e in (action_log or [])
|
for e in _iter_action_entries(action_log)
|
||||||
)
|
)
|
||||||
target_sha_reported = bool(_TARGET_BRANCH_SHA_RE.search(text)) or any(
|
target_sha_reported = bool(_TARGET_BRANCH_SHA_RE.search(text)) or any(
|
||||||
"target branch" in key and "sha" in key and _FULL_SHA_RE.search(value)
|
"target branch" in key and "sha" in key and _FULL_SHA_RE.search(value)
|
||||||
@@ -1263,6 +1353,24 @@ def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_shared_manual_lease_proof_handoff(report_text: str) -> list[dict[str, str]]:
|
||||||
|
"""Block merge/review handoffs that claim unsafe lease seeding (#535)."""
|
||||||
|
result = merger_lease_adoption.assess_manual_lease_proof_handoff(report_text)
|
||||||
|
if result.get("proven"):
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"shared.manual_lease_proof_handoff",
|
||||||
|
result.get("reasons") or [],
|
||||||
|
field="Merge mutations",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=(
|
||||||
|
"use gitea_adopt_merger_pr_lease or gitea_acquire_reviewer_pr_lease; "
|
||||||
|
"cite lease_proof_source / adoption_comment_id; never claim manual "
|
||||||
|
"seeded/injected lease proof"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule_shared_issue_acceptance_gate(report_text: str) -> list[dict[str, str]]:
|
def _rule_shared_issue_acceptance_gate(report_text: str) -> list[dict[str, str]]:
|
||||||
result = issue_acceptance_gate.validate_final_report_issue_acceptance(report_text)
|
result = issue_acceptance_gate.validate_final_report_issue_acceptance(report_text)
|
||||||
if not result.get("applicable") or result.get("valid"):
|
if not result.get("applicable") or result.get("valid"):
|
||||||
@@ -1459,6 +1567,7 @@ def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, st
|
|||||||
_SHARED_ISSUE_LOCK_RULES = (
|
_SHARED_ISSUE_LOCK_RULES = (
|
||||||
_rule_shared_issue_lock_external_state,
|
_rule_shared_issue_lock_external_state,
|
||||||
_rule_shared_manual_lock_pr_override,
|
_rule_shared_manual_lock_pr_override,
|
||||||
|
_rule_shared_manual_lease_proof_handoff,
|
||||||
_rule_shared_author_reviewer_same_run,
|
_rule_shared_author_reviewer_same_run,
|
||||||
_rule_shared_raw_branch_delete_bypass,
|
_rule_shared_raw_branch_delete_bypass,
|
||||||
_rule_shared_canonical_state_update,
|
_rule_shared_canonical_state_update,
|
||||||
@@ -1494,6 +1603,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_reviewer_linked_issue,
|
_rule_reviewer_linked_issue,
|
||||||
_rule_reviewer_baseline_on_failure,
|
_rule_reviewer_baseline_on_failure,
|
||||||
_rule_reviewer_premerge_baseline_proof,
|
_rule_reviewer_premerge_baseline_proof,
|
||||||
|
_rule_reviewer_post_merge_validation,
|
||||||
_rule_reviewer_validation_status_vocabulary,
|
_rule_reviewer_validation_status_vocabulary,
|
||||||
_rule_reviewer_main_checkout_baseline,
|
_rule_reviewer_main_checkout_baseline,
|
||||||
_rule_reviewer_main_checkout_path,
|
_rule_reviewer_main_checkout_path,
|
||||||
@@ -1586,6 +1696,13 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
],
|
],
|
||||||
|
# Controller issue closure (#529): a closure report must not bury an
|
||||||
|
# unproven non-zero suite exit as an "expected pre-existing failure".
|
||||||
|
# Kept intentionally narrow so a closure pre-check does not demand the
|
||||||
|
# full reviewer/author handoff schema.
|
||||||
|
"controller_close": [
|
||||||
|
_rule_reviewer_premerge_baseline_proof,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1681,6 +1798,12 @@ def assess_final_report_validator(
|
|||||||
checks: dict[str, Any] = {}
|
checks: dict[str, Any] = {}
|
||||||
findings: list[dict[str, str]] = []
|
findings: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
# #698: malformed action_log data must never crash validation with a
|
||||||
|
# secondary exception; malformed entries surface as sanitized findings.
|
||||||
|
sanitized_action_log, action_log_findings = sanitize_action_log(action_log)
|
||||||
|
action_log = sanitized_action_log
|
||||||
|
findings.extend(action_log_findings)
|
||||||
|
|
||||||
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
|
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
|
||||||
checks["issue_filing"] = assess_issue_filing_final_report(
|
checks["issue_filing"] = assess_issue_filing_final_report(
|
||||||
report_text,
|
report_text,
|
||||||
@@ -1709,7 +1832,23 @@ def assess_final_report_validator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||||
findings.extend(_call_rule(rule, report_text, normalized_kind, rule_kwargs))
|
try:
|
||||||
|
findings.extend(
|
||||||
|
_call_rule(rule, report_text, normalized_kind, rule_kwargs)
|
||||||
|
)
|
||||||
|
except Exception as exc: # #698: fail closed with a sanitized error
|
||||||
|
findings.append(
|
||||||
|
validator_finding(
|
||||||
|
"shared.validator_rule_error",
|
||||||
|
"block",
|
||||||
|
"Validator",
|
||||||
|
f"validator rule '{getattr(rule, '__name__', 'unknown')}' "
|
||||||
|
f"failed with {type(exc).__name__} (details withheld; "
|
||||||
|
"sanitized)",
|
||||||
|
"file a validator defect with the rule name; do not "
|
||||||
|
"bypass final-report validation",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
grade, blocked, downgraded = _aggregate_grade(findings)
|
grade, blocked, downgraded = _aggregate_grade(findings)
|
||||||
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
|
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"execution_profile": "example-author",
|
"execution_profile": "example-author",
|
||||||
"audit_label": "example-author",
|
"audit_label": "example-author",
|
||||||
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
|
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
|
||||||
"forbidden_operations": ["approve", "request_changes", "merge"]
|
"forbidden_operations": ["approve", "request_changes", "merge"]
|
||||||
},
|
},
|
||||||
|
|||||||
+261
-24
@@ -20,14 +20,15 @@ from dotenv import dotenv_values, load_dotenv
|
|||||||
|
|
||||||
import gitea_config
|
import gitea_config
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
# Load standard .env if present
|
# Load standard .env if present
|
||||||
load_dotenv()
|
load_dotenv(os.path.join(PROJECT_ROOT, ".env"))
|
||||||
|
|
||||||
# Dictionary to store configurations parsed dynamically from .env.* files
|
# Dictionary to store configurations parsed dynamically from .env.* files
|
||||||
DYNAMIC_CONFIGS = {}
|
DYNAMIC_CONFIGS = {}
|
||||||
|
|
||||||
# Scan all files starting with .env in the project root to load multiple configurations
|
# Scan all files starting with .env in the project root to load multiple configurations
|
||||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
for env_path in glob.glob(os.path.join(PROJECT_ROOT, ".env*")):
|
for env_path in glob.glob(os.path.join(PROJECT_ROOT, ".env*")):
|
||||||
# Skip directories and the example template
|
# Skip directories and the example template
|
||||||
if os.path.basename(env_path) == ".env.example":
|
if os.path.basename(env_path) == ".env.example":
|
||||||
@@ -181,11 +182,51 @@ def get_auth_header(host):
|
|||||||
|
|
||||||
def resolve_remote(args):
|
def resolve_remote(args):
|
||||||
"""Given parsed argparse args with --remote/--host/--org/--repo,
|
"""Given parsed argparse args with --remote/--host/--org/--repo,
|
||||||
return (host, org, repo) with overrides applied."""
|
return (host, org, repo) with overrides applied.
|
||||||
|
|
||||||
|
#714 / #530: when the caller omits org and/or repo, prefer the
|
||||||
|
workspace-aligned git remote over REMOTES defaults (e.g. bare
|
||||||
|
``--remote prgs`` must not force Timesheet when the checkout is
|
||||||
|
Gitea-Tools). Explicit --org/--repo always win.
|
||||||
|
"""
|
||||||
profile = REMOTES[args.remote]
|
profile = REMOTES[args.remote]
|
||||||
host = args.host or profile["host"]
|
host = args.host or profile["host"]
|
||||||
org = args.org or profile["org"]
|
org_explicit = getattr(args, "org", None) is not None
|
||||||
repo = args.repo or profile["repo"]
|
repo_explicit = getattr(args, "repo", None) is not None
|
||||||
|
org = args.org if org_explicit else profile["org"]
|
||||||
|
repo = args.repo if repo_explicit else profile["repo"]
|
||||||
|
if not org_explicit or not repo_explicit:
|
||||||
|
try:
|
||||||
|
import remote_repo_guard
|
||||||
|
import subprocess
|
||||||
|
# Prefer the named remote URL when present; fall back to origin.
|
||||||
|
url = None
|
||||||
|
for remote_name in (args.remote, "origin"):
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", "remote", "get-url", remote_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0 and (proc.stdout or "").strip():
|
||||||
|
url = proc.stdout.strip()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# Allow tests to inject a deterministic remote URL without Git.
|
||||||
|
env_url = os.environ.get("GITEA_TEST_WORKSPACE_REMOTE_URL")
|
||||||
|
if env_url:
|
||||||
|
url = env_url
|
||||||
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||||
|
if parsed:
|
||||||
|
if not org_explicit:
|
||||||
|
org = parsed[0]
|
||||||
|
if not repo_explicit:
|
||||||
|
repo = parsed[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return host, org, repo
|
return host, org, repo
|
||||||
|
|
||||||
|
|
||||||
@@ -242,6 +283,192 @@ def _redact(text):
|
|||||||
return str(text)
|
return str(text)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Classified client failures (#699) ─────────────────────────────────────────
|
||||||
|
# Subclasses of RuntimeError preserve existing ``except RuntimeError`` call
|
||||||
|
# sites. Exception *messages* are fixed constants only — HTTP response bodies,
|
||||||
|
# Keychain material, and arbitrary exception text are never stored on the
|
||||||
|
# exception or re-emitted to tool results / daemon logs.
|
||||||
|
|
||||||
|
|
||||||
|
# Fixed messages (must match mcp_tool_error_boundary.FIXED_MESSAGES keys used here).
|
||||||
|
_MSG_AUTH_INVALID = "Gitea authentication failed: invalid or revoked credentials"
|
||||||
|
_MSG_AUTH_FAILED = "Gitea authentication failed"
|
||||||
|
_MSG_AUTHZ_SCOPE = "Gitea authorization failed: insufficient token scope"
|
||||||
|
_MSG_AUTHZ_DENIED = "Gitea authorization failed: access denied"
|
||||||
|
_MSG_NETWORK = "Network error contacting Gitea"
|
||||||
|
_MSG_CONFIG = "Gitea configuration or credential resolution failed"
|
||||||
|
_MSG_UPSTREAM = "Gitea upstream unavailable"
|
||||||
|
_MSG_HTTP = "Gitea HTTP request failed"
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClientError(RuntimeError):
|
||||||
|
"""Base for known Gitea client failures with stable reason_code metadata."""
|
||||||
|
|
||||||
|
reason_code = "client_error"
|
||||||
|
error_class = "client"
|
||||||
|
http_status = None
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
if reason_code is not None:
|
||||||
|
self.reason_code = reason_code
|
||||||
|
if http_status is not None:
|
||||||
|
self.http_status = http_status
|
||||||
|
# Message is always a fixed constant; callers cannot inject bodies.
|
||||||
|
fixed = message if message is not None else _MSG_HTTP
|
||||||
|
super().__init__(fixed)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaAuthError(GiteaClientError):
|
||||||
|
"""Authentication failure (invalid/revoked credentials → typically HTTP 401)."""
|
||||||
|
|
||||||
|
reason_code = "auth_invalid_token"
|
||||||
|
error_class = "authentication"
|
||||||
|
http_status = 401
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
super().__init__(
|
||||||
|
message if message is not None else _MSG_AUTH_INVALID,
|
||||||
|
reason_code=reason_code or "auth_invalid_token",
|
||||||
|
http_status=http_status if http_status is not None else 401,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaAuthzError(GiteaClientError):
|
||||||
|
"""Authorization failure (HTTP 403 — scope deficiency or access denied)."""
|
||||||
|
|
||||||
|
reason_code = "authz_denied"
|
||||||
|
error_class = "authorization"
|
||||||
|
http_status = 403
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
code = reason_code or "authz_denied"
|
||||||
|
if code == "authz_insufficient_scope":
|
||||||
|
fixed = _MSG_AUTHZ_SCOPE
|
||||||
|
else:
|
||||||
|
fixed = _MSG_AUTHZ_DENIED
|
||||||
|
code = "authz_denied"
|
||||||
|
super().__init__(
|
||||||
|
message if message is not None else fixed,
|
||||||
|
reason_code=code,
|
||||||
|
http_status=http_status if http_status is not None else 403,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaNetworkError(GiteaClientError):
|
||||||
|
"""Transport / DNS / timeout failure contacting Gitea."""
|
||||||
|
|
||||||
|
reason_code = "network_error"
|
||||||
|
error_class = "network"
|
||||||
|
http_status = None
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
super().__init__(
|
||||||
|
message if message is not None else _MSG_NETWORK,
|
||||||
|
reason_code=reason_code or "network_error",
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaConfigError(GiteaClientError):
|
||||||
|
"""Local configuration / credential resolution failure (not HTTP auth)."""
|
||||||
|
|
||||||
|
reason_code = "config_error"
|
||||||
|
error_class = "configuration"
|
||||||
|
http_status = None
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
super().__init__(
|
||||||
|
message if message is not None else _MSG_CONFIG,
|
||||||
|
reason_code=reason_code or "config_error",
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaHttpError(GiteaClientError):
|
||||||
|
"""Non-auth HTTP failure with fixed message (no response body)."""
|
||||||
|
|
||||||
|
reason_code = "http_error"
|
||||||
|
error_class = "client"
|
||||||
|
http_status = None
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
code = reason_code or "http_error"
|
||||||
|
if code == "upstream_unavailable":
|
||||||
|
fixed = _MSG_UPSTREAM
|
||||||
|
else:
|
||||||
|
fixed = _MSG_HTTP
|
||||||
|
code = "http_error"
|
||||||
|
super().__init__(
|
||||||
|
message if message is not None else fixed,
|
||||||
|
reason_code=code,
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_insufficient_scope(detail: str) -> bool:
|
||||||
|
"""Internal: inspect redacted body *only* to refine 403 reason_code.
|
||||||
|
|
||||||
|
The body is never stored on the exception or returned to callers.
|
||||||
|
"""
|
||||||
|
lower = (detail or "").lower()
|
||||||
|
markers = (
|
||||||
|
"insufficient scope",
|
||||||
|
"required scope",
|
||||||
|
"does not have at least one of required scope",
|
||||||
|
"token does not have",
|
||||||
|
"missing scope",
|
||||||
|
"scope(s)",
|
||||||
|
)
|
||||||
|
return any(m in lower for m in markers)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_http_status(code: int, *, body_hint: str = "") -> tuple[type, str, int]:
|
||||||
|
"""Central HTTP status → (exception_class, reason_code, http_status).
|
||||||
|
|
||||||
|
Every HTTP 403 becomes authorization-class. Body text is used only as a
|
||||||
|
local hint for scope vs denied reason_code and is never returned.
|
||||||
|
"""
|
||||||
|
if code == 401:
|
||||||
|
return (GiteaAuthError, "auth_invalid_token", 401)
|
||||||
|
if code == 403:
|
||||||
|
if _looks_like_insufficient_scope(body_hint or ""):
|
||||||
|
return (GiteaAuthzError, "authz_insufficient_scope", 403)
|
||||||
|
return (GiteaAuthzError, "authz_denied", 403)
|
||||||
|
if code in (502, 503, 504):
|
||||||
|
return (GiteaHttpError, "upstream_unavailable", code)
|
||||||
|
return (GiteaHttpError, "http_error", code)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_for_http_status(code: int, body: str = "") -> None:
|
||||||
|
"""Raise a typed client error for *code* without embedding *body*.
|
||||||
|
|
||||||
|
*body* may be inspected only to choose scope vs denied for 403; it is
|
||||||
|
never placed on the exception message.
|
||||||
|
"""
|
||||||
|
# Redact before any inspection; discard after classification.
|
||||||
|
try:
|
||||||
|
hint = _redact(body or "").strip()
|
||||||
|
except Exception:
|
||||||
|
hint = ""
|
||||||
|
exc_cls, reason, status = classify_http_status(code, body_hint=hint)
|
||||||
|
# Explicitly construct without passing body/hint into message.
|
||||||
|
if exc_cls is GiteaAuthError:
|
||||||
|
raise GiteaAuthError(reason_code=reason, http_status=status)
|
||||||
|
if exc_cls is GiteaAuthzError:
|
||||||
|
raise GiteaAuthzError(reason_code=reason, http_status=status)
|
||||||
|
if reason == "upstream_unavailable":
|
||||||
|
raise GiteaHttpError(
|
||||||
|
reason_code="upstream_unavailable",
|
||||||
|
http_status=status,
|
||||||
|
)
|
||||||
|
raise GiteaHttpError(reason_code="http_error", http_status=status)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_http_error(code: int, detail: str = "") -> None:
|
||||||
|
"""Backward-compatible alias — *detail* is never embedded in the error."""
|
||||||
|
raise_for_http_status(code, detail)
|
||||||
|
|
||||||
|
|
||||||
def _add_query(url, **params):
|
def _add_query(url, **params):
|
||||||
"""Return *url* with the given query parameters added or overridden.
|
"""Return *url* with the given query parameters added or overridden.
|
||||||
|
|
||||||
@@ -314,23 +541,24 @@ def api_request(method, url, auth_header, payload=None, *,
|
|||||||
"""Make an authenticated JSON request to the Gitea API.
|
"""Make an authenticated JSON request to the Gitea API.
|
||||||
|
|
||||||
Returns parsed JSON on success (or ``None`` for an empty body), and raises
|
Returns parsed JSON on success (or ``None`` for an empty body), and raises
|
||||||
``RuntimeError`` on failure.
|
a classified client error on failure.
|
||||||
|
|
||||||
On HTTP 429 the request is retried up to *max_retries* times: honoring a
|
On HTTP 429 the request is retried up to *max_retries* times: honoring a
|
||||||
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
|
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
|
||||||
using capped jittered exponential backoff. Successful responses are
|
using capped jittered exponential backoff. Successful responses are
|
||||||
unchanged.
|
unchanged.
|
||||||
|
|
||||||
All failures are converted to a ``RuntimeError`` with a clear, secret
|
Failures raise typed exceptions with **fixed messages only** (#699). HTTP
|
||||||
-redacted message (no raw stack traces or credential material):
|
response bodies are read solely for local 403 reason refinement and are
|
||||||
|
never stored on exceptions or returned to callers:
|
||||||
|
|
||||||
- Non-429 HTTP errors surface the status code and a redacted response body.
|
- HTTP 401 → :class:`GiteaAuthError` (``auth_invalid_token``)
|
||||||
502/503/504 upstream errors get an explicit "Gitea upstream unavailable"
|
- HTTP 403 → :class:`GiteaAuthzError` (scope or denied)
|
||||||
message.
|
- 502/503/504 → :class:`GiteaHttpError` (``upstream_unavailable``)
|
||||||
- Timeouts and network/DNS failures (``URLError`` / ``TimeoutError``) surface
|
- Other non-429 HTTP → :class:`GiteaHttpError` (``http_error``)
|
||||||
a generic "network error contacting Gitea" message.
|
- Timeouts / DNS / ``URLError`` → :class:`GiteaNetworkError`
|
||||||
- A malformed (non-JSON) success body surfaces a "malformed JSON response"
|
- Malformed success JSON → plain ``RuntimeError`` (programming/protocol;
|
||||||
message rather than a raw decode error.
|
not reclassified as authentication)
|
||||||
|
|
||||||
The ``*_func`` parameters and ``timeout`` are injection points for
|
The ``*_func`` parameters and ``timeout`` are injection points for
|
||||||
deterministic testing.
|
deterministic testing.
|
||||||
@@ -368,22 +596,23 @@ def api_request(method, url, auth_header, payload=None, *,
|
|||||||
error_body = e.read().decode("utf-8", errors="replace")
|
error_body = e.read().decode("utf-8", errors="replace")
|
||||||
except Exception:
|
except Exception:
|
||||||
error_body = ""
|
error_body = ""
|
||||||
detail = _redact(error_body).strip()
|
# Classify from status (+ local body hint). Body is not embedded.
|
||||||
if e.code in (502, 503, 504):
|
try:
|
||||||
msg = f"HTTP {e.code}: Gitea upstream unavailable"
|
raise_for_http_status(e.code, error_body)
|
||||||
raise RuntimeError(f"{msg}: {detail}" if detail else msg) from e
|
except GiteaClientError:
|
||||||
raise RuntimeError(f"HTTP {e.code}: {detail}") from e
|
raise
|
||||||
|
# Defensive: raise_for_http_status always raises.
|
||||||
|
raise GiteaHttpError(http_status=e.code) from e # pragma: no cover
|
||||||
except (urllib.error.URLError, TimeoutError) as e:
|
except (urllib.error.URLError, TimeoutError) as e:
|
||||||
reason = getattr(e, "reason", e)
|
# Fixed message only — do not embed URLError reason (may leak paths).
|
||||||
raise RuntimeError(
|
raise GiteaNetworkError(reason_code="network_error") from e
|
||||||
f"network error contacting Gitea: {_redact(reason)}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
if not body:
|
if not body:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return json.loads(body)
|
return json.loads(body)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
# Programming/protocol failure — not authentication.
|
||||||
raise RuntimeError("malformed JSON response from Gitea") from e
|
raise RuntimeError("malformed JSON response from Gitea") from e
|
||||||
|
|
||||||
|
|
||||||
@@ -569,6 +798,14 @@ def get_profile():
|
|||||||
"profile_name": name,
|
"profile_name": name,
|
||||||
"allowed_operations": ops,
|
"allowed_operations": ops,
|
||||||
"forbidden_operations": forbidden,
|
"forbidden_operations": forbidden,
|
||||||
|
# #714 repository authorization boundary. Config-only on purpose: an
|
||||||
|
# environment variable must never widen or forge the set of
|
||||||
|
# repositories a session may bind to.
|
||||||
|
"allowed_repositories": _json_list("allowed_repositories"),
|
||||||
|
# #706 cross-repository canonical root binding. Config-sourced here (the
|
||||||
|
# namespace-scoped GITEA_CANONICAL_REPOSITORY_ROOT env override is applied
|
||||||
|
# by canonical_repository_root.configured_canonical_root, not widened here).
|
||||||
|
"canonical_repository_root": jp.get("canonical_repository_root") or None,
|
||||||
"audit_label": audit_label,
|
"audit_label": audit_label,
|
||||||
"token_source_name": token_source,
|
"token_source_name": token_source,
|
||||||
"auth_source_type": auth_type,
|
"auth_source_type": auth_type,
|
||||||
|
|||||||
@@ -272,6 +272,15 @@ def load_config(path=None):
|
|||||||
)
|
)
|
||||||
if not isinstance(data.get("profiles"), dict):
|
if not isinstance(data.get("profiles"), dict):
|
||||||
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
|
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
|
||||||
|
# #741: the v1 path returns `data` unflattened, so nothing else validates
|
||||||
|
# the cross-repository binding before gitea_auth.get_profile() reads it.
|
||||||
|
# Validate it here so every supported loader treats the field identically
|
||||||
|
# (a relative or blank path must never reach the runtime guard).
|
||||||
|
for _name, _profile in data["profiles"].items():
|
||||||
|
if isinstance(_profile, dict):
|
||||||
|
_validate_canonical_repository_root(
|
||||||
|
_name, _profile.get("canonical_repository_root")
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -358,6 +367,14 @@ def _flatten_identity(env_name, svc_name, svc, ident_name, ident):
|
|||||||
for key in ("role", "username", "execution_profile", "audit_label"):
|
for key in ("role", "username", "execution_profile", "audit_label"):
|
||||||
if ident.get(key):
|
if ident.get(key):
|
||||||
profile[key] = ident[key]
|
profile[key] = ident[key]
|
||||||
|
# #741: the cross-repository binding must survive flattening. Previously
|
||||||
|
# this key was silently dropped here, so a v2-environments namespace that
|
||||||
|
# declared canonical_repository_root fell back to the *installation* root
|
||||||
|
# and mutated Gitea-Tools instead of its target repository — a fail-open.
|
||||||
|
# Validate it exactly as the v2-contexts loader does before propagating.
|
||||||
|
_validate_canonical_repository_root(addr, ident.get("canonical_repository_root"))
|
||||||
|
if ident.get("canonical_repository_root"):
|
||||||
|
profile["canonical_repository_root"] = ident["canonical_repository_root"]
|
||||||
return addr, profile
|
return addr, profile
|
||||||
|
|
||||||
|
|
||||||
@@ -475,6 +492,57 @@ def _require_enabled(kind, name, obj):
|
|||||||
return enabled
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_SCOPE_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_allowed_repositories(name, raw):
|
||||||
|
"""Validate the optional per-profile repository authorization scope (#714).
|
||||||
|
|
||||||
|
``allowed_repositories`` is an authorization boundary of canonical
|
||||||
|
``owner/repository`` slugs. It is not the session binding: the verified
|
||||||
|
workspace repository selects exactly one entry at bind time. Absent means
|
||||||
|
"no repository scope configured" and is allowed, so existing profiles keep
|
||||||
|
working until an operator provisions the field.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' allowed_repositories must be a list of "
|
||||||
|
"'owner/repository' strings"
|
||||||
|
)
|
||||||
|
for entry in raw:
|
||||||
|
if not isinstance(entry, str) or not _REPO_SCOPE_RE.match(entry.strip()):
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' allowed_repositories entry {entry!r} is not "
|
||||||
|
"a canonical 'owner/repository' slug"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_canonical_repository_root(name, raw):
|
||||||
|
"""Validate the optional per-profile canonical repository root (#706).
|
||||||
|
|
||||||
|
``canonical_repository_root`` binds a cross-repository namespace to the
|
||||||
|
working root of its target repository (separate from the immutable
|
||||||
|
Gitea-Tools install checkout). It is an absolute filesystem path; existence
|
||||||
|
and git identity are validated at bind time by the runtime guard, not here
|
||||||
|
(config validation stays filesystem-independent). Absent means the
|
||||||
|
single-repo default and is allowed.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return
|
||||||
|
if not isinstance(raw, str) or not raw.strip():
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' canonical_repository_root must be a non-empty "
|
||||||
|
"absolute path string to the target repository working root"
|
||||||
|
)
|
||||||
|
if not os.path.isabs(raw.strip()):
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' canonical_repository_root {raw!r} must be an "
|
||||||
|
"absolute path"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reject_inline_secrets(kind, name, obj):
|
def _reject_inline_secrets(kind, name, obj):
|
||||||
for key in _INLINE_SECRET_KEYS:
|
for key in _INLINE_SECRET_KEYS:
|
||||||
if key in obj:
|
if key in obj:
|
||||||
@@ -549,6 +617,10 @@ def _load_v2_contexts(data, path):
|
|||||||
forbidden = raw.get("forbidden_operations") or []
|
forbidden = raw.get("forbidden_operations") or []
|
||||||
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
||||||
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
||||||
|
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
|
||||||
|
_validate_canonical_repository_root(
|
||||||
|
name, raw.get("canonical_repository_root")
|
||||||
|
)
|
||||||
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
||||||
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
||||||
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
||||||
|
|||||||
+10613
-514
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,931 @@
|
|||||||
|
"""Observability incident bridge (#612).
|
||||||
|
|
||||||
|
Converts Sentry/GlitchTip **observations** into durable **Gitea issues** and
|
||||||
|
``incident_links`` rows on the #613 control-plane DB.
|
||||||
|
|
||||||
|
Hard rules (ADR):
|
||||||
|
* Gitea owns work; providers own incidents; the bridge only links them.
|
||||||
|
* Raw monitoring incidents are **never** assignable ``work_items``.
|
||||||
|
* The #600 allocator sees bridge work only as normal Gitea issues.
|
||||||
|
* Phase-1 prefers dry-run / explicit reconcile; apply creates/links issues.
|
||||||
|
* No tokens, DSNs, cookies, or other secrets in bodies, DB, or logs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Sequence
|
||||||
|
|
||||||
|
from control_plane_db import ControlPlaneDB, ControlPlaneError, WORK_KINDS, _norm_scope
|
||||||
|
|
||||||
|
PROVIDERS = frozenset({"sentry", "glitchtip"})
|
||||||
|
|
||||||
|
OUTCOME_PREVIEW = "preview"
|
||||||
|
OUTCOME_LINKED = "linked_existing"
|
||||||
|
OUTCOME_CREATED = "created_issue"
|
||||||
|
OUTCOME_UPDATED = "updated_link"
|
||||||
|
OUTCOME_BLOCKED = "blocked"
|
||||||
|
OUTCOME_NO_ACTION = "no_safe_action"
|
||||||
|
|
||||||
|
# Patterns scrubbed from any bridge-facing text (bodies, titles, tags, logs).
|
||||||
|
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||||
|
re.compile(
|
||||||
|
r"(?i)\b(api[_-]?token|token|secret|password|passwd)\s*[:=]\s*\S+"
|
||||||
|
),
|
||||||
|
re.compile(
|
||||||
|
r"(?i)\bAuthorization\s*[:=]\s*(?:Bearer\s+)?[A-Za-z0-9._\-+=/]{8,}"
|
||||||
|
),
|
||||||
|
re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-]{8,}"),
|
||||||
|
re.compile(r"(?i)\bBasic\s+[A-Za-z0-9+/=]{8,}"),
|
||||||
|
re.compile(r"(?i)\b(dsn|sentry_dsn|glitchtip_dsn)\s*[:=]\s*\S+"),
|
||||||
|
re.compile(r"https?://[^/\s]+:[^@/\s]+@"), # user:pass@host
|
||||||
|
re.compile(r"(?i)\b(keychain[_-]?id|private[_-]?key)\s*[:=]\s*\S+"),
|
||||||
|
re.compile(r"(?i)cookie\s*[:=]\s*[^\s;]+"),
|
||||||
|
re.compile(r"(?i)\bsession[_-]?id\s*[:=]\s*\S+"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_SENSITIVE_TAG_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"set-cookie",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"dsn",
|
||||||
|
"sentry_dsn",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_LABELS = (
|
||||||
|
"type:bug",
|
||||||
|
"observability",
|
||||||
|
"status:ready",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProjectMapping:
|
||||||
|
"""Maps a monitoring project to a Gitea repository."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
provider: str
|
||||||
|
monitor_base_url: str
|
||||||
|
monitor_org: str
|
||||||
|
monitor_project: str
|
||||||
|
gitea_org: str
|
||||||
|
gitea_repo: str
|
||||||
|
default_labels: tuple[str, ...] = DEFAULT_LABELS
|
||||||
|
environment_filters: tuple[str, ...] = ()
|
||||||
|
severity_threshold: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
prov = (self.provider or "").strip().lower()
|
||||||
|
if prov not in PROVIDERS:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"unknown provider '{self.provider}'; expected one of {sorted(PROVIDERS)}"
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "provider", prov)
|
||||||
|
object.__setattr__(self, "monitor_base_url", (self.monitor_base_url or "").rstrip("/"))
|
||||||
|
object.__setattr__(self, "monitor_org", (self.monitor_org or "").strip())
|
||||||
|
object.__setattr__(self, "monitor_project", (self.monitor_project or "").strip())
|
||||||
|
object.__setattr__(self, "gitea_org", (self.gitea_org or "").strip())
|
||||||
|
object.__setattr__(self, "gitea_repo", (self.gitea_repo or "").strip())
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"default_labels",
|
||||||
|
tuple(str(x).strip() for x in (self.default_labels or DEFAULT_LABELS) if str(x).strip()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"provider": self.provider,
|
||||||
|
"monitor_base_url": self.monitor_base_url,
|
||||||
|
"monitor_org": self.monitor_org,
|
||||||
|
"monitor_project": self.monitor_project,
|
||||||
|
"gitea_org": self.gitea_org,
|
||||||
|
"gitea_repo": self.gitea_repo,
|
||||||
|
"default_labels": list(self.default_labels),
|
||||||
|
"environment_filters": list(self.environment_filters),
|
||||||
|
"severity_threshold": self.severity_threshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
def matches_observation(self, obs: dict[str, Any]) -> bool:
|
||||||
|
if (obs.get("provider") or "").strip().lower() != self.provider:
|
||||||
|
return False
|
||||||
|
base = (obs.get("provider_base_url") or obs.get("monitor_base_url") or "").rstrip("/")
|
||||||
|
if base and self.monitor_base_url and base != self.monitor_base_url:
|
||||||
|
return False
|
||||||
|
org = (obs.get("provider_org") or obs.get("monitor_org") or "").strip()
|
||||||
|
if org and self.monitor_org and org != self.monitor_org:
|
||||||
|
return False
|
||||||
|
project = (obs.get("provider_project") or obs.get("monitor_project") or "").strip()
|
||||||
|
if project and self.monitor_project and project != self.monitor_project:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NormalizedIncident:
|
||||||
|
provider: str
|
||||||
|
provider_base_url: str
|
||||||
|
provider_org: str
|
||||||
|
provider_project: str
|
||||||
|
provider_issue_id: str
|
||||||
|
provider_short_id: str | None = None
|
||||||
|
provider_permalink: str | None = None
|
||||||
|
fingerprint: str | None = None
|
||||||
|
first_seen: str | None = None
|
||||||
|
last_seen: str | None = None
|
||||||
|
event_count: int | None = None
|
||||||
|
status: str = "open"
|
||||||
|
environment: str | None = None
|
||||||
|
severity: str | None = None
|
||||||
|
culprit: str | None = None
|
||||||
|
title: str = ""
|
||||||
|
summary: str = ""
|
||||||
|
tags: dict[str, str] = field(default_factory=dict)
|
||||||
|
gitea_org: str = ""
|
||||||
|
gitea_repo: str = ""
|
||||||
|
default_labels: tuple[str, ...] = DEFAULT_LABELS
|
||||||
|
|
||||||
|
def provider_key(self) -> tuple[str, str, str, str, str]:
|
||||||
|
return (
|
||||||
|
self.provider,
|
||||||
|
_norm_scope(self.provider_base_url),
|
||||||
|
_norm_scope(self.provider_org),
|
||||||
|
_norm_scope(self.provider_project),
|
||||||
|
str(self.provider_issue_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"provider": self.provider,
|
||||||
|
"provider_base_url": self.provider_base_url,
|
||||||
|
"provider_org": self.provider_org,
|
||||||
|
"provider_project": self.provider_project,
|
||||||
|
"provider_issue_id": self.provider_issue_id,
|
||||||
|
"provider_short_id": self.provider_short_id,
|
||||||
|
"provider_permalink": self.provider_permalink,
|
||||||
|
"fingerprint": self.fingerprint,
|
||||||
|
"first_seen": self.first_seen,
|
||||||
|
"last_seen": self.last_seen,
|
||||||
|
"event_count": self.event_count,
|
||||||
|
"status": self.status,
|
||||||
|
"environment": self.environment,
|
||||||
|
"severity": self.severity,
|
||||||
|
"culprit": self.culprit,
|
||||||
|
"title": self.title,
|
||||||
|
"summary": self.summary,
|
||||||
|
"tags": dict(self.tags),
|
||||||
|
"gitea_org": self.gitea_org,
|
||||||
|
"gitea_repo": self.gitea_repo,
|
||||||
|
"default_labels": list(self.default_labels),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def redact_text(value: Any) -> str:
|
||||||
|
"""Redact secret-like material from a string (fail closed to empty)."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
text = str(value)
|
||||||
|
for pat in _SECRET_PATTERNS:
|
||||||
|
text = pat.sub("[REDACTED]", text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_tags(tags: Any) -> dict[str, str]:
|
||||||
|
if not isinstance(tags, dict):
|
||||||
|
return {}
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for k, v in tags.items():
|
||||||
|
key = str(k).strip().lower()
|
||||||
|
if not key or key in _SENSITIVE_TAG_KEYS:
|
||||||
|
continue
|
||||||
|
if any(s in key for s in ("token", "secret", "password", "cookie", "auth", "dsn")):
|
||||||
|
continue
|
||||||
|
val = redact_text(v)
|
||||||
|
if "[REDACTED]" in val:
|
||||||
|
continue
|
||||||
|
if len(val) > 200:
|
||||||
|
val = val[:200] + "…"
|
||||||
|
out[key] = val
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def project_mapping_from_dict(data: dict[str, Any]) -> ProjectMapping:
|
||||||
|
labels = data.get("default_labels") or DEFAULT_LABELS
|
||||||
|
envs = data.get("environment_filters") or ()
|
||||||
|
return ProjectMapping(
|
||||||
|
name=str(data.get("name") or data.get("monitor_project") or "unnamed"),
|
||||||
|
provider=str(data.get("provider") or ""),
|
||||||
|
monitor_base_url=str(data.get("monitor_base_url") or data.get("provider_base_url") or ""),
|
||||||
|
monitor_org=str(data.get("monitor_org") or data.get("provider_org") or ""),
|
||||||
|
monitor_project=str(data.get("monitor_project") or data.get("provider_project") or ""),
|
||||||
|
gitea_org=str(data.get("gitea_org") or ""),
|
||||||
|
gitea_repo=str(data.get("gitea_repo") or ""),
|
||||||
|
default_labels=tuple(labels),
|
||||||
|
environment_filters=tuple(envs),
|
||||||
|
severity_threshold=data.get("severity_threshold"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_project_mappings(
|
||||||
|
*,
|
||||||
|
config_path: str | None = None,
|
||||||
|
mappings_json: str | None = None,
|
||||||
|
) -> list[ProjectMapping]:
|
||||||
|
"""Load project mappings from JSON env, file, or explicit JSON string.
|
||||||
|
|
||||||
|
Env: ``GITEA_OBSERVABILITY_PROJECTS_JSON`` or path
|
||||||
|
``GITEA_OBSERVABILITY_PROJECTS_FILE``.
|
||||||
|
"""
|
||||||
|
raw: Any = None
|
||||||
|
if mappings_json:
|
||||||
|
raw = json.loads(mappings_json)
|
||||||
|
else:
|
||||||
|
env_json = (os.environ.get("GITEA_OBSERVABILITY_PROJECTS_JSON") or "").strip()
|
||||||
|
path = (
|
||||||
|
(config_path or "").strip()
|
||||||
|
or (os.environ.get("GITEA_OBSERVABILITY_PROJECTS_FILE") or "").strip()
|
||||||
|
)
|
||||||
|
if env_json:
|
||||||
|
raw = json.loads(env_json)
|
||||||
|
elif path and os.path.isfile(path):
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
raw = json.load(fh)
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, dict) and "projects" in raw:
|
||||||
|
raw = raw["projects"]
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ControlPlaneError("observability project mappings must be a list")
|
||||||
|
return [project_mapping_from_dict(item) for item in raw if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mapping(
|
||||||
|
observation: dict[str, Any],
|
||||||
|
mappings: Sequence[ProjectMapping],
|
||||||
|
*,
|
||||||
|
explicit: ProjectMapping | None = None,
|
||||||
|
) -> ProjectMapping | None:
|
||||||
|
if explicit is not None:
|
||||||
|
return explicit
|
||||||
|
matches = [m for m in mappings if m.matches_observation(observation)]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
"ambiguous project mapping for observation: "
|
||||||
|
+ ", ".join(m.name for m in matches)
|
||||||
|
+ " (fail closed, #612)"
|
||||||
|
)
|
||||||
|
# Fallback: observation itself may carry gitea targets
|
||||||
|
g_org = (observation.get("gitea_org") or "").strip()
|
||||||
|
g_repo = (observation.get("gitea_repo") or "").strip()
|
||||||
|
provider = (observation.get("provider") or "").strip().lower()
|
||||||
|
if g_org and g_repo and provider in PROVIDERS:
|
||||||
|
return ProjectMapping(
|
||||||
|
name=f"inline-{provider}",
|
||||||
|
provider=provider,
|
||||||
|
monitor_base_url=str(
|
||||||
|
observation.get("provider_base_url")
|
||||||
|
or observation.get("monitor_base_url")
|
||||||
|
or ""
|
||||||
|
),
|
||||||
|
monitor_org=str(
|
||||||
|
observation.get("provider_org") or observation.get("monitor_org") or ""
|
||||||
|
),
|
||||||
|
monitor_project=str(
|
||||||
|
observation.get("provider_project")
|
||||||
|
or observation.get("monitor_project")
|
||||||
|
or ""
|
||||||
|
),
|
||||||
|
gitea_org=g_org,
|
||||||
|
gitea_repo=g_repo,
|
||||||
|
default_labels=tuple(observation.get("default_labels") or DEFAULT_LABELS),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_incident(
|
||||||
|
observation: dict[str, Any],
|
||||||
|
mapping: ProjectMapping,
|
||||||
|
) -> NormalizedIncident:
|
||||||
|
"""Normalize + sanitize a raw provider observation (fail closed)."""
|
||||||
|
if not isinstance(observation, dict):
|
||||||
|
raise ControlPlaneError("observation must be a dict (fail closed, #612)")
|
||||||
|
|
||||||
|
provider = (observation.get("provider") or mapping.provider or "").strip().lower()
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"unknown provider '{provider}'; expected {sorted(PROVIDERS)} (fail closed)"
|
||||||
|
)
|
||||||
|
if provider != mapping.provider:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"observation provider '{provider}' does not match mapping "
|
||||||
|
f"'{mapping.provider}' (fail closed, #612)"
|
||||||
|
)
|
||||||
|
|
||||||
|
issue_id = observation.get("provider_issue_id") or observation.get("id")
|
||||||
|
if issue_id is None or str(issue_id).strip() == "":
|
||||||
|
raise ControlPlaneError(
|
||||||
|
"observation missing provider_issue_id (fail closed, #612)"
|
||||||
|
)
|
||||||
|
|
||||||
|
base = (
|
||||||
|
observation.get("provider_base_url")
|
||||||
|
or observation.get("monitor_base_url")
|
||||||
|
or mapping.monitor_base_url
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
org = (
|
||||||
|
observation.get("provider_org")
|
||||||
|
or observation.get("monitor_org")
|
||||||
|
or mapping.monitor_org
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
project = (
|
||||||
|
observation.get("provider_project")
|
||||||
|
or observation.get("monitor_project")
|
||||||
|
or mapping.monitor_project
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
title = redact_text(
|
||||||
|
observation.get("title")
|
||||||
|
or observation.get("culprit")
|
||||||
|
or f"{provider} incident {issue_id}"
|
||||||
|
)
|
||||||
|
meta = observation.get("metadata")
|
||||||
|
meta_value = meta.get("value") if isinstance(meta, dict) else None
|
||||||
|
raw_sum = (
|
||||||
|
observation.get("summary")
|
||||||
|
or observation.get("message")
|
||||||
|
or meta_value
|
||||||
|
or title
|
||||||
|
)
|
||||||
|
summary = redact_text(raw_sum)
|
||||||
|
|
||||||
|
permalink = observation.get("provider_permalink") or observation.get("permalink")
|
||||||
|
if permalink:
|
||||||
|
permalink = redact_text(permalink)
|
||||||
|
if "[REDACTED]" in permalink:
|
||||||
|
permalink = None
|
||||||
|
|
||||||
|
tags = sanitize_tags(observation.get("tags") or {})
|
||||||
|
event_count = observation.get("event_count") or observation.get("count")
|
||||||
|
try:
|
||||||
|
event_count_i = int(event_count) if event_count is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
event_count_i = None
|
||||||
|
|
||||||
|
return NormalizedIncident(
|
||||||
|
provider=provider,
|
||||||
|
provider_base_url=str(base).rstrip("/"),
|
||||||
|
provider_org=str(org).strip(),
|
||||||
|
provider_project=str(project).strip(),
|
||||||
|
provider_issue_id=str(issue_id).strip(),
|
||||||
|
provider_short_id=redact_text(observation.get("provider_short_id") or observation.get("shortId") or "")
|
||||||
|
or None,
|
||||||
|
provider_permalink=permalink,
|
||||||
|
fingerprint=redact_text(observation.get("fingerprint") or "") or None,
|
||||||
|
first_seen=str(observation.get("first_seen") or observation.get("firstSeen") or "")
|
||||||
|
or None,
|
||||||
|
last_seen=str(observation.get("last_seen") or observation.get("lastSeen") or "")
|
||||||
|
or None,
|
||||||
|
event_count=event_count_i,
|
||||||
|
status=str(observation.get("status") or "open").strip().lower() or "open",
|
||||||
|
environment=redact_text(observation.get("environment") or "") or None,
|
||||||
|
severity=redact_text(observation.get("severity") or observation.get("level") or "")
|
||||||
|
or None,
|
||||||
|
culprit=redact_text(observation.get("culprit") or "") or None,
|
||||||
|
title=title[:200],
|
||||||
|
summary=summary[:2000],
|
||||||
|
tags=tags,
|
||||||
|
gitea_org=mapping.gitea_org,
|
||||||
|
gitea_repo=mapping.gitea_repo,
|
||||||
|
default_labels=mapping.default_labels,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_gitea_issue_title(inc: NormalizedIncident) -> str:
|
||||||
|
env = f" [{inc.environment}]" if inc.environment else ""
|
||||||
|
sev = f" ({inc.severity})" if inc.severity else ""
|
||||||
|
return redact_text(f"[obs:{inc.provider}]{env}{sev} {inc.title}")[:180]
|
||||||
|
|
||||||
|
|
||||||
|
def build_gitea_issue_body(inc: NormalizedIncident) -> str:
|
||||||
|
"""Sanitized durable issue body (no secrets)."""
|
||||||
|
lines = [
|
||||||
|
"## Observability incident (bridge #612)",
|
||||||
|
"",
|
||||||
|
"<!-- mcp-incident-bridge:v1 -->",
|
||||||
|
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
|
||||||
|
"",
|
||||||
|
f"- **provider:** `{inc.provider}`",
|
||||||
|
f"- **provider_issue_id:** `{inc.provider_issue_id}`",
|
||||||
|
]
|
||||||
|
if inc.provider_short_id:
|
||||||
|
lines.append(f"- **provider_short_id:** `{inc.provider_short_id}`")
|
||||||
|
if inc.provider_permalink:
|
||||||
|
lines.append(f"- **provider_url:** {inc.provider_permalink}")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"- **provider_org/project:** `{inc.provider_org}` / `{inc.provider_project}`",
|
||||||
|
f"- **base_url:** `{inc.provider_base_url}`",
|
||||||
|
f"- **fingerprint:** `{inc.fingerprint or ''}`",
|
||||||
|
f"- **first_seen:** `{inc.first_seen or ''}`",
|
||||||
|
f"- **last_seen:** `{inc.last_seen or ''}`",
|
||||||
|
f"- **event_count:** `{inc.event_count if inc.event_count is not None else ''}`",
|
||||||
|
f"- **environment:** `{inc.environment or ''}`",
|
||||||
|
f"- **severity:** `{inc.severity or ''}`",
|
||||||
|
f"- **culprit:** `{inc.culprit or ''}`",
|
||||||
|
f"- **status:** `{inc.status}`",
|
||||||
|
"",
|
||||||
|
"### Summary",
|
||||||
|
"",
|
||||||
|
redact_text(inc.summary) or "(no summary)",
|
||||||
|
"",
|
||||||
|
"### Safe tags",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if inc.tags:
|
||||||
|
for k, v in sorted(inc.tags.items()):
|
||||||
|
lines.append(f"- `{k}`: `{v}`")
|
||||||
|
else:
|
||||||
|
lines.append("- (none)")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"### Canonical next action",
|
||||||
|
"",
|
||||||
|
"Author: investigate and fix under normal Gitea workflow. "
|
||||||
|
"Allocator may select this issue as ordinary Gitea work "
|
||||||
|
"(never as a raw provider incident).",
|
||||||
|
"",
|
||||||
|
"### Bridge notes",
|
||||||
|
"",
|
||||||
|
"- Raw Sentry/GlitchTip incidents are **not** assignable work items.",
|
||||||
|
"- Gitea remains the durable work record; DB stores `incident_links` only.",
|
||||||
|
"- Provider tokens must never appear in this issue.",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def incident_recurred(
|
||||||
|
existing: dict[str, Any], inc: NormalizedIncident
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Did new provider events arrive since the existing link was last synced?
|
||||||
|
|
||||||
|
AC4 asks for a recurrence comment when events *continue*, so a scan that
|
||||||
|
observes no new events must stay silent instead of re-posting the same
|
||||||
|
state on every pass.
|
||||||
|
"""
|
||||||
|
old_count = existing.get("event_count")
|
||||||
|
new_count = inc.event_count
|
||||||
|
if (
|
||||||
|
isinstance(old_count, int)
|
||||||
|
and isinstance(new_count, int)
|
||||||
|
and new_count > old_count
|
||||||
|
):
|
||||||
|
return True, f"event_count advanced {old_count} -> {new_count}"
|
||||||
|
old_seen = str(existing.get("last_seen") or "").strip()
|
||||||
|
new_seen = str(inc.last_seen or "").strip()
|
||||||
|
if new_seen and new_seen != old_seen:
|
||||||
|
return True, f"last_seen advanced '{old_seen}' -> '{new_seen}'"
|
||||||
|
return False, "no new provider events since the last sync"
|
||||||
|
|
||||||
|
|
||||||
|
def build_recurrence_comment_body(
|
||||||
|
inc: NormalizedIncident, existing: dict[str, Any], *, reason: str = ""
|
||||||
|
) -> str:
|
||||||
|
"""Sanitized recurrence comment for an already-linked Gitea issue (AC4).
|
||||||
|
|
||||||
|
Uses the same redaction path as :func:`build_gitea_issue_body`; never
|
||||||
|
carries tokens, raw paths, or session state.
|
||||||
|
"""
|
||||||
|
lines = [
|
||||||
|
"## Observability incident recurrence (bridge #612)",
|
||||||
|
"",
|
||||||
|
"<!-- mcp-incident-bridge:recurrence:v1 -->",
|
||||||
|
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
|
||||||
|
"",
|
||||||
|
f"Continued `{inc.provider}` events for this linked incident.",
|
||||||
|
"",
|
||||||
|
f"- **provider_issue_id:** `{inc.provider_issue_id}`",
|
||||||
|
]
|
||||||
|
if inc.provider_short_id:
|
||||||
|
lines.append(f"- **provider_short_id:** `{inc.provider_short_id}`")
|
||||||
|
if inc.provider_permalink:
|
||||||
|
lines.append(f"- **provider_url:** {inc.provider_permalink}")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"- **event_count:** `{existing.get('event_count')}` -> "
|
||||||
|
f"`{inc.event_count if inc.event_count is not None else ''}`",
|
||||||
|
f"- **first_seen:** `{inc.first_seen or ''}`",
|
||||||
|
f"- **last_seen:** `{inc.last_seen or ''}`",
|
||||||
|
f"- **environment:** `{inc.environment or ''}`",
|
||||||
|
f"- **severity:** `{inc.severity or ''}`",
|
||||||
|
f"- **culprit:** `{inc.culprit or ''}`",
|
||||||
|
f"- **status:** `{inc.status}`",
|
||||||
|
f"- **recurrence_basis:** `{reason}`",
|
||||||
|
"",
|
||||||
|
"### Latest summary",
|
||||||
|
"",
|
||||||
|
redact_text(inc.summary) or "(no summary)",
|
||||||
|
"",
|
||||||
|
"### Canonical next action",
|
||||||
|
"",
|
||||||
|
"Author: this incident is still firing — investigate under the "
|
||||||
|
"normal Gitea workflow. This comment records observability "
|
||||||
|
"recurrence only and changes no workflow state.",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | None:
|
||||||
|
"""Fail closed if existing link targets a different Gitea issue/repo."""
|
||||||
|
eg_org = str(existing.get("gitea_org") or "")
|
||||||
|
eg_repo = str(existing.get("gitea_repo") or "")
|
||||||
|
eg_num = existing.get("gitea_issue_number")
|
||||||
|
if eg_org and eg_org != inc.gitea_org:
|
||||||
|
return (
|
||||||
|
f"existing link points to org '{eg_org}', mapping wants "
|
||||||
|
f"'{inc.gitea_org}' (fail closed, #612)"
|
||||||
|
)
|
||||||
|
if eg_repo and eg_repo != inc.gitea_repo:
|
||||||
|
return (
|
||||||
|
f"existing link points to repo '{eg_repo}', mapping wants "
|
||||||
|
f"'{inc.gitea_repo}' (fail closed, #612)"
|
||||||
|
)
|
||||||
|
# fingerprint conflict when both present and disagree
|
||||||
|
ef = (existing.get("fingerprint") or "").strip()
|
||||||
|
nf = (inc.fingerprint or "").strip()
|
||||||
|
if ef and nf and ef != nf:
|
||||||
|
# Same provider issue id with different fingerprints is ambiguous.
|
||||||
|
return (
|
||||||
|
f"fingerprint conflict for provider issue {inc.provider_issue_id}: "
|
||||||
|
f"link has '{ef}', observation has '{nf}' (fail closed, #612)"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]]
|
||||||
|
# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...}
|
||||||
|
|
||||||
|
CommentIssueFn = Callable[[int, str, str, str], dict[str, Any]]
|
||||||
|
# comment_issue_fn(gitea_issue_number, body, gitea_org, gitea_repo) -> {"success": bool, ...}
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_incident(
|
||||||
|
db: ControlPlaneDB | None,
|
||||||
|
*,
|
||||||
|
observation: dict[str, Any],
|
||||||
|
mappings: Sequence[ProjectMapping] | None = None,
|
||||||
|
mapping: ProjectMapping | None = None,
|
||||||
|
apply: bool = False,
|
||||||
|
create_issue_fn: CreateIssueFn | None = None,
|
||||||
|
comment_issue_fn: CommentIssueFn | None = None,
|
||||||
|
force_gitea_issue_number: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Reconcile one observation into incident_links + optional Gitea issue.
|
||||||
|
|
||||||
|
*apply=False* (default): preview only — no DB mutation, no Gitea mutation.
|
||||||
|
*apply=True*: upsert link; create Gitea issue when none linked (requires
|
||||||
|
``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link.
|
||||||
|
|
||||||
|
When an existing link is reused and the provider reports *new* events,
|
||||||
|
``comment_issue_fn`` posts a sanitized recurrence comment on the linked
|
||||||
|
Gitea issue (AC4). Dry runs never comment, and a missing
|
||||||
|
``comment_issue_fn`` withholds the comment without failing the link.
|
||||||
|
|
||||||
|
Never creates control-plane ``work_items`` for raw incidents.
|
||||||
|
"""
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"success": False,
|
||||||
|
"apply": bool(apply),
|
||||||
|
"outcome": OUTCOME_NO_ACTION,
|
||||||
|
"performed": False,
|
||||||
|
"gitea_mutated": False,
|
||||||
|
"db_mutated": False,
|
||||||
|
"raw_incident_assignable": False,
|
||||||
|
"work_item_kind_would_be": None,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": None,
|
||||||
|
"incident": None,
|
||||||
|
"existing_link": None,
|
||||||
|
"gitea_issue": None,
|
||||||
|
"action": None,
|
||||||
|
"mapping": None,
|
||||||
|
"recurrence_comment": None,
|
||||||
|
"substrate": "control_plane_db.incident_links",
|
||||||
|
"durable_work_system": "gitea_issues",
|
||||||
|
}
|
||||||
|
|
||||||
|
if db is None:
|
||||||
|
base["reasons"] = [
|
||||||
|
"control-plane DB substrate unavailable (fail closed, #613/#612)"
|
||||||
|
]
|
||||||
|
base["outcome"] = OUTCOME_BLOCKED
|
||||||
|
return base
|
||||||
|
|
||||||
|
try:
|
||||||
|
maps = list(mappings or [])
|
||||||
|
resolved = resolve_mapping(observation, maps, explicit=mapping)
|
||||||
|
if resolved is None:
|
||||||
|
base["outcome"] = OUTCOME_BLOCKED
|
||||||
|
base["reasons"] = [
|
||||||
|
"no project mapping for observation (fail closed, #612); "
|
||||||
|
"configure GITEA_OBSERVABILITY_PROJECTS_JSON or pass mapping"
|
||||||
|
]
|
||||||
|
return base
|
||||||
|
base["mapping"] = resolved.as_dict()
|
||||||
|
inc = normalize_incident(observation, resolved)
|
||||||
|
base["incident"] = inc.as_dict()
|
||||||
|
except (ControlPlaneError, json.JSONDecodeError, TypeError, ValueError) as exc:
|
||||||
|
base["outcome"] = OUTCOME_BLOCKED
|
||||||
|
base["reasons"] = [str(exc)]
|
||||||
|
return base
|
||||||
|
|
||||||
|
# Environment filter (optional)
|
||||||
|
if resolved.environment_filters and inc.environment:
|
||||||
|
allowed = {e.lower() for e in resolved.environment_filters}
|
||||||
|
if inc.environment.lower() not in allowed:
|
||||||
|
base["outcome"] = OUTCOME_NO_ACTION
|
||||||
|
base["reasons"] = [
|
||||||
|
f"environment '{inc.environment}' not in filters "
|
||||||
|
f"{sorted(allowed)}; skip (policy)"
|
||||||
|
]
|
||||||
|
base["success"] = True
|
||||||
|
base["action"] = "skip_environment_filter"
|
||||||
|
return base
|
||||||
|
|
||||||
|
existing = db.get_incident_link_by_provider(
|
||||||
|
provider=inc.provider,
|
||||||
|
provider_issue_id=inc.provider_issue_id,
|
||||||
|
provider_base_url=inc.provider_base_url,
|
||||||
|
provider_org=inc.provider_org,
|
||||||
|
provider_project=inc.provider_project,
|
||||||
|
)
|
||||||
|
base["existing_link"] = existing
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
conflict = _link_conflict(existing, inc)
|
||||||
|
if conflict:
|
||||||
|
base["outcome"] = OUTCOME_BLOCKED
|
||||||
|
base["reasons"] = [conflict]
|
||||||
|
return base
|
||||||
|
|
||||||
|
title = build_gitea_issue_title(inc)
|
||||||
|
body = build_gitea_issue_body(inc)
|
||||||
|
labels = list(inc.default_labels)
|
||||||
|
if inc.provider and f"{inc.provider}" not in labels:
|
||||||
|
labels.append(inc.provider)
|
||||||
|
if "observability" not in labels:
|
||||||
|
labels.append("observability")
|
||||||
|
|
||||||
|
preview_issue = {
|
||||||
|
"title": title,
|
||||||
|
"body_preview": body[:500] + ("…" if len(body) > 500 else ""),
|
||||||
|
"labels": labels,
|
||||||
|
"gitea_org": inc.gitea_org,
|
||||||
|
"gitea_repo": inc.gitea_repo,
|
||||||
|
"would_create": existing is None and force_gitea_issue_number is None,
|
||||||
|
"would_reuse_issue": int(existing["gitea_issue_number"])
|
||||||
|
if existing
|
||||||
|
else force_gitea_issue_number,
|
||||||
|
}
|
||||||
|
base["gitea_issue_preview"] = preview_issue
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
base["success"] = True
|
||||||
|
base["outcome"] = OUTCOME_PREVIEW
|
||||||
|
base["action"] = (
|
||||||
|
"preview_reuse_link" if existing else "preview_create_issue"
|
||||||
|
)
|
||||||
|
base["reasons"] = [
|
||||||
|
"dry-run only (apply=false); no Gitea mutation and no DB write — "
|
||||||
|
"call again with apply=true to create/link"
|
||||||
|
]
|
||||||
|
if existing:
|
||||||
|
base["gitea_issue"] = {
|
||||||
|
"number": existing.get("gitea_issue_number"),
|
||||||
|
"org": existing.get("gitea_org"),
|
||||||
|
"repo": existing.get("gitea_repo"),
|
||||||
|
}
|
||||||
|
# Prove we never treat this as work_item kind incident
|
||||||
|
base["raw_incident_assignable"] = False
|
||||||
|
base["work_item_kind_would_be"] = "issue" # only after Gitea issue exists
|
||||||
|
return base
|
||||||
|
|
||||||
|
# --- apply path ---
|
||||||
|
issue_number: int | None = None
|
||||||
|
created = False
|
||||||
|
recurrence: tuple[bool, str] | None = None
|
||||||
|
if existing:
|
||||||
|
issue_number = int(existing["gitea_issue_number"])
|
||||||
|
action = "updated_existing_link"
|
||||||
|
outcome = OUTCOME_UPDATED
|
||||||
|
# Compare against the pre-upsert link row: the upsert below overwrites
|
||||||
|
# event_count/last_seen, which would erase the recurrence signal.
|
||||||
|
recurrence = incident_recurred(existing, inc)
|
||||||
|
elif force_gitea_issue_number is not None:
|
||||||
|
issue_number = int(force_gitea_issue_number)
|
||||||
|
action = "link_explicit_issue"
|
||||||
|
outcome = OUTCOME_LINKED
|
||||||
|
else:
|
||||||
|
if create_issue_fn is None:
|
||||||
|
base["outcome"] = OUTCOME_BLOCKED
|
||||||
|
base["reasons"] = [
|
||||||
|
"apply=true requires create_issue_fn when no existing link "
|
||||||
|
"(fail closed, #612)"
|
||||||
|
]
|
||||||
|
return base
|
||||||
|
try:
|
||||||
|
created_res = create_issue_fn(
|
||||||
|
title, body, labels, inc.gitea_org, inc.gitea_repo
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
base["outcome"] = OUTCOME_BLOCKED
|
||||||
|
base["reasons"] = [
|
||||||
|
f"Gitea issue creation failed: {redact_text(exc)} (fail closed)"
|
||||||
|
]
|
||||||
|
return base
|
||||||
|
number = created_res.get("number") if isinstance(created_res, dict) else None
|
||||||
|
if number is None:
|
||||||
|
base["outcome"] = OUTCOME_BLOCKED
|
||||||
|
base["reasons"] = [
|
||||||
|
"Gitea issue creation returned no issue number (fail closed, #612)"
|
||||||
|
]
|
||||||
|
base["create_result"] = created_res
|
||||||
|
return base
|
||||||
|
issue_number = int(number)
|
||||||
|
created = True
|
||||||
|
action = "created_gitea_issue"
|
||||||
|
outcome = OUTCOME_CREATED
|
||||||
|
base["gitea_mutated"] = True
|
||||||
|
base["create_result"] = {
|
||||||
|
k: created_res.get(k)
|
||||||
|
for k in ("number", "success", "performed", "reasons")
|
||||||
|
if isinstance(created_res, dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert issue_number is not None
|
||||||
|
try:
|
||||||
|
link = db.upsert_incident_link(
|
||||||
|
provider=inc.provider,
|
||||||
|
provider_issue_id=inc.provider_issue_id,
|
||||||
|
gitea_org=inc.gitea_org,
|
||||||
|
gitea_repo=inc.gitea_repo,
|
||||||
|
gitea_issue_number=issue_number,
|
||||||
|
provider_base_url=inc.provider_base_url,
|
||||||
|
provider_org=inc.provider_org,
|
||||||
|
provider_project=inc.provider_project,
|
||||||
|
provider_short_id=inc.provider_short_id,
|
||||||
|
provider_permalink=inc.provider_permalink,
|
||||||
|
fingerprint=inc.fingerprint,
|
||||||
|
first_seen=inc.first_seen,
|
||||||
|
last_seen=inc.last_seen,
|
||||||
|
event_count=inc.event_count,
|
||||||
|
status=inc.status if not created else "open",
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
base["outcome"] = OUTCOME_BLOCKED
|
||||||
|
base["reasons"] = [
|
||||||
|
f"incident_links upsert failed: {redact_text(exc)} (fail closed, #613)"
|
||||||
|
]
|
||||||
|
base["gitea_issue"] = {
|
||||||
|
"number": issue_number,
|
||||||
|
"org": inc.gitea_org,
|
||||||
|
"repo": inc.gitea_repo,
|
||||||
|
"created": created,
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
|
||||||
|
# AC4: continued provider events post a recurrence comment on the linked
|
||||||
|
# Gitea issue. The durable incident_links row is already written above, so
|
||||||
|
# a comment failure never rolls back or blocks the mapping — the next scan
|
||||||
|
# retries while the link stays authoritative.
|
||||||
|
if outcome == OUTCOME_UPDATED and recurrence is not None:
|
||||||
|
recurred, why = recurrence
|
||||||
|
if not recurred:
|
||||||
|
base["recurrence_comment"] = {"posted": False, "reason": why}
|
||||||
|
elif comment_issue_fn is None:
|
||||||
|
base["recurrence_comment"] = {
|
||||||
|
"posted": False,
|
||||||
|
"reason": (
|
||||||
|
"no comment_issue_fn supplied; recurrence comment withheld "
|
||||||
|
"(link remains durable)"
|
||||||
|
),
|
||||||
|
"recurrence_basis": why,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
comment_res = comment_issue_fn(
|
||||||
|
issue_number,
|
||||||
|
build_recurrence_comment_body(inc, existing, reason=why),
|
||||||
|
inc.gitea_org,
|
||||||
|
inc.gitea_repo,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - never break the link write
|
||||||
|
base["recurrence_comment"] = {
|
||||||
|
"posted": False,
|
||||||
|
"reason": (
|
||||||
|
f"recurrence comment failed: {redact_text(exc)} "
|
||||||
|
"(link remains durable)"
|
||||||
|
),
|
||||||
|
"recurrence_basis": why,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
posted = (
|
||||||
|
bool(comment_res.get("success"))
|
||||||
|
if isinstance(comment_res, dict)
|
||||||
|
else bool(comment_res)
|
||||||
|
)
|
||||||
|
base["recurrence_comment"] = {
|
||||||
|
"posted": posted,
|
||||||
|
"recurrence_basis": why,
|
||||||
|
"gitea_issue_number": issue_number,
|
||||||
|
"comment_id": (
|
||||||
|
comment_res.get("comment_id")
|
||||||
|
if isinstance(comment_res, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if posted:
|
||||||
|
base["gitea_mutated"] = True
|
||||||
|
elif isinstance(comment_res, dict):
|
||||||
|
base["recurrence_comment"]["reasons"] = [
|
||||||
|
redact_text(r) for r in (comment_res.get("reasons") or [])
|
||||||
|
]
|
||||||
|
|
||||||
|
base["success"] = True
|
||||||
|
base["performed"] = True
|
||||||
|
base["db_mutated"] = True
|
||||||
|
base["outcome"] = outcome
|
||||||
|
base["action"] = action
|
||||||
|
base["gitea_issue"] = {
|
||||||
|
"number": issue_number,
|
||||||
|
"org": inc.gitea_org,
|
||||||
|
"repo": inc.gitea_repo,
|
||||||
|
"created": created,
|
||||||
|
}
|
||||||
|
base["link"] = {
|
||||||
|
k: link.get(k)
|
||||||
|
for k in (
|
||||||
|
"link_id",
|
||||||
|
"provider",
|
||||||
|
"provider_issue_id",
|
||||||
|
"gitea_org",
|
||||||
|
"gitea_repo",
|
||||||
|
"gitea_issue_number",
|
||||||
|
"fingerprint",
|
||||||
|
"event_count",
|
||||||
|
"status",
|
||||||
|
"last_sync_at",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
base["reasons"] = [
|
||||||
|
(
|
||||||
|
f"reused Gitea issue #{issue_number} for provider "
|
||||||
|
f"{inc.provider}:{inc.provider_issue_id}"
|
||||||
|
if not created
|
||||||
|
else f"created Gitea issue #{issue_number} and stored incident_links row"
|
||||||
|
),
|
||||||
|
"raw provider incident is not an assignable work_item "
|
||||||
|
f"(WORK_KINDS={sorted(WORK_KINDS)})",
|
||||||
|
]
|
||||||
|
base["raw_incident_assignable"] = False
|
||||||
|
base["work_item_kind_would_be"] = "issue"
|
||||||
|
base["allocator_visible_as"] = {
|
||||||
|
"kind": "issue",
|
||||||
|
"number": issue_number,
|
||||||
|
"org": inc.gitea_org,
|
||||||
|
"repo": inc.gitea_repo,
|
||||||
|
"note": "allocator selects normal Gitea issues only (#600/#612)",
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def assert_not_raw_incident_work_item(kind: str) -> None:
|
||||||
|
"""Guard used by tests / callers: incidents must not enter WORK_KINDS."""
|
||||||
|
k = (kind or "").strip().lower()
|
||||||
|
if k not in WORK_KINDS:
|
||||||
|
if k in ("sentry_incident", "glitchtip_incident", "incident", "observation"):
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"kind '{k}' is not assignable; bridge must create Gitea issues first (#612)"
|
||||||
|
)
|
||||||
|
raise ControlPlaneError(f"kind '{k}' is not assignable")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
|||||||
|
"""Pre-create issue content gate (#582).
|
||||||
|
|
||||||
|
Blocks issue creation when the title/body is a *vague reference* to
|
||||||
|
out-of-band content the LLM cannot prove it has ("the drafted issue",
|
||||||
|
"the prepared issue", "the issue we discussed", "the previous draft")
|
||||||
|
with no durable source pointer.
|
||||||
|
|
||||||
|
The rule from #582: an LLM must not fabricate an issue from stale chat
|
||||||
|
memory. When the requested issue content is a vague reference and no
|
||||||
|
durable source pointer (existing issue/PR/comment, checked-in file, URL,
|
||||||
|
or scratchpad path) is present, the gate fails closed and the caller must
|
||||||
|
return BLOCKED + DIAGNOSE naming the exact vague/missing fields.
|
||||||
|
|
||||||
|
This gate intentionally does NOT require a non-empty body: title-only
|
||||||
|
issue creation remains allowed for callers that explicitly want it. It
|
||||||
|
only blocks content that is a placeholder reference to something the
|
||||||
|
current context does not contain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
# Vague reference phrases that point at out-of-band draft content. Stored
|
||||||
|
# normalized (lowercase, punctuation-stripped, whitespace-collapsed) so they
|
||||||
|
# can be matched against normalized field text.
|
||||||
|
VAGUE_REFERENCE_PHRASES: tuple[str, ...] = (
|
||||||
|
"the drafted issue",
|
||||||
|
"drafted issue",
|
||||||
|
"the prepared issue",
|
||||||
|
"prepared issue",
|
||||||
|
"the issue we discussed",
|
||||||
|
"the issue we talked about",
|
||||||
|
"the one we discussed",
|
||||||
|
"the previous draft",
|
||||||
|
"previous draft",
|
||||||
|
"the draft above",
|
||||||
|
"the issue above",
|
||||||
|
"as discussed",
|
||||||
|
"as we discussed",
|
||||||
|
"as previously discussed",
|
||||||
|
"per our discussion",
|
||||||
|
"per our conversation",
|
||||||
|
"per our chat",
|
||||||
|
"see above",
|
||||||
|
"same as before",
|
||||||
|
"the issue from earlier",
|
||||||
|
"the issue i drafted",
|
||||||
|
"the issue you drafted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A field that only restates a vague phrase (optionally with a leading verb
|
||||||
|
# such as "create"/"add"/"open"/"file") is still a vague reference.
|
||||||
|
_LEADING_VERBS = ("create", "add", "open", "file", "make", "please", "the")
|
||||||
|
|
||||||
|
# Durable source pointers: if any of these appears, the content points at a
|
||||||
|
# retrievable source of truth and is NOT treated as a fabricated reference.
|
||||||
|
_DURABLE_SOURCE_REGEXES: tuple[re.Pattern[str], ...] = (
|
||||||
|
re.compile(r"#\d+"), # #402
|
||||||
|
re.compile(r"\b(?:issue|pull|pr)\s+#?\d+", re.I), # issue 402 / PR #17
|
||||||
|
re.compile(r"\bcomment\s+#?\d+", re.I), # comment 8155
|
||||||
|
re.compile(r"https?://\S+", re.I), # URL
|
||||||
|
re.compile( # checked-in file
|
||||||
|
r"[\w./-]+\.(?:py|md|json|txt|sh|ya?ml|toml|cfg|ini|rst)\b", re.I
|
||||||
|
),
|
||||||
|
re.compile(r"\bscratchpad\b", re.I), # scratchpad path
|
||||||
|
re.compile(r"(?:^|\s)/[\w./-]+"), # absolute path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(text: str) -> str:
|
||||||
|
"""Lowercase, punctuation-stripped, whitespace-collapsed text."""
|
||||||
|
norm = unicodedata.normalize("NFKC", (text or "").strip().lower())
|
||||||
|
norm = re.sub(r"[^\w\s]", " ", norm)
|
||||||
|
norm = re.sub(r"\s+", " ", norm).strip()
|
||||||
|
return norm
|
||||||
|
|
||||||
|
|
||||||
|
def has_durable_source_pointer(text: str) -> bool:
|
||||||
|
"""True when the raw text references a durable, retrievable source."""
|
||||||
|
raw = text or ""
|
||||||
|
return any(rx.search(raw) for rx in _DURABLE_SOURCE_REGEXES)
|
||||||
|
|
||||||
|
|
||||||
|
def find_vague_reference(text: str) -> str | None:
|
||||||
|
"""Return the vague phrase a field is dominated by, else ``None``.
|
||||||
|
|
||||||
|
A field is a vague reference only when it contains a known vague phrase,
|
||||||
|
carries no durable source pointer, and the phrase dominates the field
|
||||||
|
(the field is essentially just the phrase). Long, specific bodies that
|
||||||
|
merely contain "as discussed" in passing are not blocked.
|
||||||
|
"""
|
||||||
|
if has_durable_source_pointer(text):
|
||||||
|
return None
|
||||||
|
norm = normalize_text(text)
|
||||||
|
if not norm:
|
||||||
|
return None
|
||||||
|
stripped = norm
|
||||||
|
for verb in _LEADING_VERBS:
|
||||||
|
if stripped.startswith(verb + " "):
|
||||||
|
stripped = stripped[len(verb) + 1:]
|
||||||
|
for phrase in VAGUE_REFERENCE_PHRASES:
|
||||||
|
if phrase in norm and len(stripped) <= len(phrase) + 12:
|
||||||
|
return phrase
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_issue_content(
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
*,
|
||||||
|
allow_incomplete: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Evaluate whether proposed issue content is durable enough to create.
|
||||||
|
|
||||||
|
Returns a dict with ``complete`` (bool), ``missing_fields``,
|
||||||
|
``vague_fields``, ``reasons``, ``diagnose`` (joined reasons), and
|
||||||
|
``has_durable_source_pointer``. ``allow_incomplete`` is an explicit
|
||||||
|
operator override that forces ``complete`` True.
|
||||||
|
"""
|
||||||
|
t = (title or "").strip()
|
||||||
|
b = (body or "").strip()
|
||||||
|
missing_fields: list[str] = []
|
||||||
|
vague_fields: list[str] = []
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if not t:
|
||||||
|
missing_fields.append("title")
|
||||||
|
reasons.append("issue title is required")
|
||||||
|
else:
|
||||||
|
phrase = find_vague_reference(t)
|
||||||
|
if phrase:
|
||||||
|
vague_fields.append("title")
|
||||||
|
reasons.append(
|
||||||
|
f"title is a vague reference ('{phrase}') with no durable "
|
||||||
|
"content or source pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if b:
|
||||||
|
phrase = find_vague_reference(b)
|
||||||
|
if phrase:
|
||||||
|
vague_fields.append("body")
|
||||||
|
reasons.append(
|
||||||
|
f"body is a vague reference ('{phrase}') with no durable "
|
||||||
|
"content or source pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
complete = allow_incomplete or (not missing_fields and not vague_fields)
|
||||||
|
return {
|
||||||
|
"complete": complete,
|
||||||
|
"missing_fields": missing_fields,
|
||||||
|
"vague_fields": vague_fields,
|
||||||
|
"reasons": reasons,
|
||||||
|
"diagnose": "; ".join(reasons),
|
||||||
|
"has_durable_source_pointer": has_durable_source_pointer(b)
|
||||||
|
or has_durable_source_pointer(t),
|
||||||
|
"override_applied": bool(
|
||||||
|
allow_incomplete and (missing_fields or vague_fields)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pre_create_issue_content_gate(
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
*,
|
||||||
|
allow_incomplete: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Content gate wrapper mirroring the duplicate gate shape.
|
||||||
|
|
||||||
|
``performed`` is True when the content is durable enough to create (or an
|
||||||
|
explicit override was supplied). When False, the caller must return
|
||||||
|
BLOCKED + DIAGNOSE and must not create the issue.
|
||||||
|
"""
|
||||||
|
assessment = assess_issue_content(
|
||||||
|
title, body, allow_incomplete=allow_incomplete
|
||||||
|
)
|
||||||
|
assessment["performed"] = assessment["complete"]
|
||||||
|
return assessment
|
||||||
@@ -85,6 +85,15 @@ def _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
|||||||
return re.search(pattern, name) is not None
|
return re.search(pattern, name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
||||||
|
"""Public accessor for the exact issue-marker match (#753).
|
||||||
|
|
||||||
|
Dead-session lock recovery needs the same word-boundary matcher to detect
|
||||||
|
ambiguous branch claims, so it is exposed rather than reached into.
|
||||||
|
"""
|
||||||
|
return _branch_carries_issue_marker(branch_name, issue_number)
|
||||||
|
|
||||||
|
|
||||||
def assess_own_branch_adoption(
|
def assess_own_branch_adoption(
|
||||||
*,
|
*,
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
|
|||||||
@@ -0,0 +1,665 @@
|
|||||||
|
"""Dead-session author issue-lock recovery (#753).
|
||||||
|
|
||||||
|
A durable author issue lock records the PID of the MCP session that took it.
|
||||||
|
When that process exits, ``issue_lock_store.assess_lock_freshness`` classifies
|
||||||
|
the lock as ``stale`` (``live=False``) even while its lease is still within TTL,
|
||||||
|
so every ownership check that requires a *live* lock fails closed.
|
||||||
|
|
||||||
|
Re-taking the lock through ``gitea_lock_issue`` is unreachable for real work:
|
||||||
|
``issue_lock_worktree.assess_issue_lock_worktree`` demands the worktree be
|
||||||
|
base-equivalent to ``master``/``main``/``dev``, and a branch that already
|
||||||
|
carries commits is ahead of its base by construction. The existing
|
||||||
|
``assess_expired_lock_reclaim`` affordance does not apply either, because
|
||||||
|
``assess_same_issue_lease_conflict`` only consults it once the lease has
|
||||||
|
*expired* — a dead PID under an unexpired lease never reaches it.
|
||||||
|
|
||||||
|
This module is the pure evidence assessor for that one narrow case. It grants
|
||||||
|
recovery only when every element of durable ownership still matches exactly and
|
||||||
|
the recorded process is demonstrably dead. It never trusts caller assertions:
|
||||||
|
every field is compared against durable lock state or live observation supplied
|
||||||
|
by the caller. It performs no mutation and no network I/O.
|
||||||
|
|
||||||
|
Recovery deliberately does **not** relax base-equivalence for brand-new issue
|
||||||
|
claims — only for a lock whose own prior record already proves the branch,
|
||||||
|
worktree, head, and author.
|
||||||
|
|
||||||
|
#768 extends the head requirement from strict equality to "equal, or a strict
|
||||||
|
descendant". Equality alone made remediation after a session death unreachable:
|
||||||
|
recovery needs a clean worktree, the only sanctioned way to clean one without
|
||||||
|
discarding work is to commit, and committing advances the head past the value
|
||||||
|
recorded at lock time. A commit that strictly descends from the recorded head,
|
||||||
|
on the same branch, in the same worktree, by the same claimant, preserves
|
||||||
|
everything equality protected — the recorded head is still reachable, still an
|
||||||
|
ancestor, still unmodified — so it is accepted, and nothing else is. The
|
||||||
|
descendant fact is observed server-side by
|
||||||
|
``issue_lock_worktree.read_head_ancestry`` and handed in as ``head_ancestry``;
|
||||||
|
no caller can assert it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
from issue_lock_store import is_process_alive
|
||||||
|
from reviewer_worktree import parse_dirty_tracked_files
|
||||||
|
|
||||||
|
# Outcome values
|
||||||
|
RECOVERY_SANCTIONED = "RECOVERY_SANCTIONED"
|
||||||
|
NO_CANDIDATE = "NO_CANDIDATE"
|
||||||
|
REFUSED = "REFUSED"
|
||||||
|
|
||||||
|
# Durable fields a lock must carry before it can be considered at all.
|
||||||
|
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
|
||||||
|
|
||||||
|
# How the clean local head relates to the head recorded at lock time (#768).
|
||||||
|
HEAD_RELATION_EQUAL = "equal"
|
||||||
|
HEAD_RELATION_STRICT_DESCENDANT = "strict_descendant"
|
||||||
|
|
||||||
|
|
||||||
|
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||||
|
if not left or not right:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return os.path.realpath(left) == os.path.realpath(right)
|
||||||
|
except OSError:
|
||||||
|
return left == right
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
claimant = lock.get("claimant")
|
||||||
|
if not isinstance(claimant, Mapping):
|
||||||
|
lease = lock.get("work_lease")
|
||||||
|
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
|
||||||
|
return dict(claimant) if isinstance(claimant, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _recorded_pid(lock: Mapping[str, Any]) -> Any:
|
||||||
|
pid = lock.get("session_pid")
|
||||||
|
if pid is None:
|
||||||
|
pid = lock.get("pid")
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
|
||||||
|
"""Names of durable fields that are missing or unusable."""
|
||||||
|
missing: list[str] = []
|
||||||
|
for field in REQUIRED_LOCK_FIELDS:
|
||||||
|
if not _text(lock.get(field)):
|
||||||
|
missing.append(field)
|
||||||
|
pid = _recorded_pid(lock)
|
||||||
|
if pid is None or _text(pid) == "":
|
||||||
|
missing.append("session_pid/pid")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if int(pid) <= 0:
|
||||||
|
missing.append("session_pid/pid")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
missing.append("session_pid/pid")
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def _assess_strict_descendant(
|
||||||
|
head_ancestry: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
recorded_head: str,
|
||||||
|
local_head: str,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Is ``local_head`` a proven strict descendant of ``recorded_head`` (#768)?
|
||||||
|
|
||||||
|
``head_ancestry`` is the server-side git observation from
|
||||||
|
``issue_lock_worktree.read_head_ancestry``. Its own ``ancestor_sha`` /
|
||||||
|
``descendant_sha`` are re-checked against the heads this assessment is
|
||||||
|
actually reasoning about, so a probe taken for some other pair of commits —
|
||||||
|
stale, mismatched, or hand-built — can never authorize a waiver.
|
||||||
|
|
||||||
|
Returns ``(proven, notes)``. Notes name the exact missing element so a
|
||||||
|
refused caller sees why, never a bare "unproven".
|
||||||
|
"""
|
||||||
|
if not isinstance(head_ancestry, Mapping):
|
||||||
|
return False, [
|
||||||
|
"no server-derived ancestry observation was available; a local head "
|
||||||
|
"that differs from the recorded head cannot be accepted"
|
||||||
|
]
|
||||||
|
|
||||||
|
notes: list[str] = []
|
||||||
|
probe_ancestor = _text(head_ancestry.get("ancestor_sha"))
|
||||||
|
probe_descendant = _text(head_ancestry.get("descendant_sha"))
|
||||||
|
if probe_ancestor != recorded_head or probe_descendant != local_head:
|
||||||
|
return False, [
|
||||||
|
f"ancestry observation covers {probe_ancestor or 'unknown'} -> "
|
||||||
|
f"{probe_descendant or 'unknown'}, not the heads under assessment "
|
||||||
|
f"({recorded_head} -> {local_head})"
|
||||||
|
]
|
||||||
|
if not head_ancestry.get("probe_ok"):
|
||||||
|
notes.extend(
|
||||||
|
list(head_ancestry.get("reasons") or [])
|
||||||
|
or ["ancestry probe did not complete; ancestry unproven"]
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
if not head_ancestry.get("ancestor_present"):
|
||||||
|
return False, [
|
||||||
|
f"recorded head {recorded_head} is no longer reachable; a rewritten "
|
||||||
|
"or force-moved head cannot be recovered"
|
||||||
|
]
|
||||||
|
if not head_ancestry.get("is_strict_descendant"):
|
||||||
|
notes.extend(
|
||||||
|
list(head_ancestry.get("reasons") or [])
|
||||||
|
or [
|
||||||
|
f"local head {local_head} is not a strict descendant of the "
|
||||||
|
f"recorded head {recorded_head}"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
|
||||||
|
proof = _text(head_ancestry.get("proof")) or (
|
||||||
|
f"{recorded_head} is an ancestor of {local_head}"
|
||||||
|
)
|
||||||
|
return True, [
|
||||||
|
f"local head {local_head} strictly descends from recorded head "
|
||||||
|
f"{recorded_head} ({proof})"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def assess_dead_session_lock_recovery(
|
||||||
|
existing_lock: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str,
|
||||||
|
worktree_path: str,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
identity: str | None,
|
||||||
|
profile: str | None,
|
||||||
|
current_branch: str | None,
|
||||||
|
porcelain_status: str,
|
||||||
|
head_sha: str | None,
|
||||||
|
remote_head_sha: str | None,
|
||||||
|
pr_head_sha: str | None = None,
|
||||||
|
pr_number: int | None = None,
|
||||||
|
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
|
||||||
|
candidate_branches: Iterable[str] | None = None,
|
||||||
|
current_pid: int | None = None,
|
||||||
|
head_ancestry: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Decide whether a dead-session author lock may be natively recovered.
|
||||||
|
|
||||||
|
Returns a dict with ``recovery_sanctioned`` (bool), ``outcome``, ``reasons``
|
||||||
|
(why it was refused, or the positive proof when sanctioned), and
|
||||||
|
``evidence`` (a redaction-safe record for auditing).
|
||||||
|
|
||||||
|
``NO_CANDIDATE`` means no recovery was attempted at all — there is no
|
||||||
|
existing lock, or the lock does not describe this issue. The caller must
|
||||||
|
treat that exactly as it treated the pre-#753 world. ``REFUSED`` means a
|
||||||
|
candidate existed but the evidence did not agree; the caller fails closed.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
evidence: dict[str, Any] = {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not existing_lock:
|
||||||
|
return _result(
|
||||||
|
NO_CANDIDATE, False, ["no existing durable lock for this issue"], evidence
|
||||||
|
)
|
||||||
|
|
||||||
|
lock = dict(existing_lock)
|
||||||
|
|
||||||
|
# ── Candidate identification ────────────────────────────────────────────
|
||||||
|
# Recovery only ever applies to a lock that already claims THIS issue.
|
||||||
|
# Anything else is not a recovery candidate and must not be reinterpreted.
|
||||||
|
if lock.get("issue_number") != issue_number:
|
||||||
|
return _result(
|
||||||
|
NO_CANDIDATE,
|
||||||
|
False,
|
||||||
|
[
|
||||||
|
f"existing lock targets issue #{lock.get('issue_number')}, "
|
||||||
|
f"not #{issue_number}; not a recovery candidate"
|
||||||
|
],
|
||||||
|
evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A malformed/incomplete durable record can never prove ownership.
|
||||||
|
missing = _malformed_reasons(lock)
|
||||||
|
if missing:
|
||||||
|
return _result(
|
||||||
|
REFUSED,
|
||||||
|
False,
|
||||||
|
[
|
||||||
|
"durable lock record is incomplete and cannot prove ownership "
|
||||||
|
f"(missing/unusable: {', '.join(missing)})"
|
||||||
|
],
|
||||||
|
evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded_pid = _recorded_pid(lock)
|
||||||
|
evidence["prior_session_pid"] = recorded_pid
|
||||||
|
evidence["replacement_session_pid"] = (
|
||||||
|
current_pid if current_pid is not None else os.getpid()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Repository scope ────────────────────────────────────────────────────
|
||||||
|
for field, expected in (("remote", remote), ("org", org), ("repo", repo)):
|
||||||
|
actual = _text(lock.get(field))
|
||||||
|
if actual != _text(expected):
|
||||||
|
reasons.append(
|
||||||
|
f"lock {field} '{actual}' does not match requested '{_text(expected)}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Branch identity ─────────────────────────────────────────────────────
|
||||||
|
locked_branch = _text(lock.get("branch_name"))
|
||||||
|
if locked_branch != _text(branch_name):
|
||||||
|
reasons.append(
|
||||||
|
f"lock branch '{locked_branch}' does not match requested "
|
||||||
|
f"'{_text(branch_name)}'"
|
||||||
|
)
|
||||||
|
evidence["locked_branch"] = locked_branch
|
||||||
|
|
||||||
|
# The worktree must actually be sitting on the locked branch. Without this
|
||||||
|
# a clean worktree parked elsewhere could stand in for the real work.
|
||||||
|
checked_out = _text(current_branch)
|
||||||
|
if not checked_out:
|
||||||
|
reasons.append(
|
||||||
|
"worktree is not on a named branch (detached HEAD); locked-branch "
|
||||||
|
"occupancy could not be proven"
|
||||||
|
)
|
||||||
|
elif checked_out != locked_branch:
|
||||||
|
reasons.append(
|
||||||
|
f"worktree is on branch '{checked_out}', not the locked branch "
|
||||||
|
f"'{locked_branch}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Worktree identity ───────────────────────────────────────────────────
|
||||||
|
locked_worktree = _text(lock.get("worktree_path"))
|
||||||
|
if not _same_realpath(locked_worktree, worktree_path):
|
||||||
|
reasons.append(
|
||||||
|
f"lock worktree '{locked_worktree}' does not match declared "
|
||||||
|
f"'{_text(worktree_path)}'"
|
||||||
|
)
|
||||||
|
evidence["locked_worktree_path"] = locked_worktree
|
||||||
|
|
||||||
|
# ── Cleanliness (never waived) ──────────────────────────────────────────
|
||||||
|
dirty_files = parse_dirty_tracked_files(porcelain_status)
|
||||||
|
if dirty_files:
|
||||||
|
reasons.append(
|
||||||
|
"worktree has tracked local edits; recovery requires a clean "
|
||||||
|
f"worktree (dirty files: {', '.join(dirty_files)})"
|
||||||
|
)
|
||||||
|
evidence["dirty_files"] = dirty_files
|
||||||
|
|
||||||
|
# ── Head agreement: local is the recorded head, or strictly descends it ──
|
||||||
|
# The recorded head is what the remote branch still carries. A local head
|
||||||
|
# equal to it is the #753 case. A local head that strictly descends from it
|
||||||
|
# is the #768 case: the author committed remediation, which is the only way
|
||||||
|
# to reach the clean worktree recovery itself demands.
|
||||||
|
local_head = _text(head_sha)
|
||||||
|
remote_head = _text(remote_head_sha)
|
||||||
|
head_relation: str | None = None
|
||||||
|
ancestry_proof: str | None = None
|
||||||
|
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:
|
||||||
|
if local_head == remote_head:
|
||||||
|
head_relation = HEAD_RELATION_EQUAL
|
||||||
|
else:
|
||||||
|
descends, notes = _assess_strict_descendant(
|
||||||
|
head_ancestry,
|
||||||
|
recorded_head=remote_head,
|
||||||
|
local_head=local_head,
|
||||||
|
)
|
||||||
|
if descends:
|
||||||
|
head_relation = HEAD_RELATION_STRICT_DESCENDANT
|
||||||
|
ancestry_proof = notes[0] if notes else None
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
f"local head {local_head} does not match remote branch head "
|
||||||
|
f"{remote_head}"
|
||||||
|
)
|
||||||
|
reasons.extend(notes)
|
||||||
|
evidence["local_head"] = local_head or None
|
||||||
|
evidence["remote_head"] = remote_head or None
|
||||||
|
# ``recorded_head`` is the head recovery is being measured against;
|
||||||
|
# ``accepted_head`` is the head this recovery actually adopts. They differ
|
||||||
|
# only in the descendant case, and downstream gates need both (#768 AC2/AC7).
|
||||||
|
evidence["recorded_head"] = remote_head or None
|
||||||
|
evidence["accepted_head"] = local_head or None
|
||||||
|
evidence["head_relation"] = head_relation
|
||||||
|
evidence["ancestry_proof"] = ancestry_proof
|
||||||
|
|
||||||
|
pr_head = _text(pr_head_sha)
|
||||||
|
if pr_head:
|
||||||
|
evidence["pr_head"] = pr_head
|
||||||
|
evidence["pr_number"] = pr_number
|
||||||
|
if local_head and pr_head != local_head:
|
||||||
|
# A descendant recovery has not been published yet, so the open PR
|
||||||
|
# legitimately still points at the recorded head. Any other
|
||||||
|
# disagreement is a real mismatch.
|
||||||
|
if not (
|
||||||
|
head_relation == HEAD_RELATION_STRICT_DESCENDANT
|
||||||
|
and remote_head
|
||||||
|
and pr_head == remote_head
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
f"open PR #{pr_number} head {pr_head} does not match local head "
|
||||||
|
f"{local_head}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Author identity ─────────────────────────────────────────────────────
|
||||||
|
claimant = _lock_claimant(lock)
|
||||||
|
locked_identity = _text(claimant.get("username"))
|
||||||
|
locked_profile = _text(claimant.get("profile"))
|
||||||
|
evidence["locked_identity"] = locked_identity or None
|
||||||
|
evidence["locked_profile"] = locked_profile or None
|
||||||
|
if not locked_identity or not locked_profile:
|
||||||
|
reasons.append(
|
||||||
|
"durable lock does not record a claimant identity/profile; "
|
||||||
|
"author ownership could not be proven"
|
||||||
|
)
|
||||||
|
if not _text(identity) or not _text(profile):
|
||||||
|
reasons.append(
|
||||||
|
"active session identity/profile is unknown; author ownership "
|
||||||
|
"could not be proven"
|
||||||
|
)
|
||||||
|
if locked_identity and _text(identity) and locked_identity != _text(identity):
|
||||||
|
reasons.append(
|
||||||
|
f"lock claimant '{locked_identity}' does not match active identity "
|
||||||
|
f"'{_text(identity)}'"
|
||||||
|
)
|
||||||
|
if locked_profile and _text(profile) and locked_profile != _text(profile):
|
||||||
|
reasons.append(
|
||||||
|
f"lock profile '{locked_profile}' does not match active profile "
|
||||||
|
f"'{_text(profile)}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── The defining condition: the recorded owner must be dead ─────────────
|
||||||
|
prior_alive = is_process_alive(recorded_pid)
|
||||||
|
evidence["prior_pid_alive"] = prior_alive
|
||||||
|
if prior_alive:
|
||||||
|
reasons.append(
|
||||||
|
f"prior owner pid {recorded_pid} is still alive; this is not a "
|
||||||
|
"dead-session recovery"
|
||||||
|
)
|
||||||
|
if current_pid is not None and recorded_pid is not None:
|
||||||
|
try:
|
||||||
|
if int(recorded_pid) == int(current_pid):
|
||||||
|
reasons.append(
|
||||||
|
"recorded pid is the current session; nothing to recover"
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Competing ownership ─────────────────────────────────────────────────
|
||||||
|
competing: list[dict[str, Any]] = []
|
||||||
|
for entry in competing_live_locks or ():
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
same_issue = entry.get("issue_number") == issue_number
|
||||||
|
same_branch = _text(entry.get("branch_name")) == locked_branch
|
||||||
|
if not (same_issue or same_branch):
|
||||||
|
continue
|
||||||
|
# The lock we are recovering is not competition with itself.
|
||||||
|
if (
|
||||||
|
same_issue
|
||||||
|
and same_branch
|
||||||
|
and _same_realpath(_text(entry.get("worktree_path")), worktree_path)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
competing.append(
|
||||||
|
{
|
||||||
|
"issue_number": entry.get("issue_number"),
|
||||||
|
"branch_name": entry.get("branch_name"),
|
||||||
|
"worktree_path": entry.get("worktree_path"),
|
||||||
|
"pid": entry.get("pid"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if competing:
|
||||||
|
described = ", ".join(
|
||||||
|
f"issue #{c['issue_number']} branch '{c['branch_name']}'" for c in competing
|
||||||
|
)
|
||||||
|
reasons.append(f"competing live lock or lease exists ({described})")
|
||||||
|
evidence["competing_live_locks"] = competing
|
||||||
|
|
||||||
|
# ── Ambiguous branch claims ─────────────────────────────────────────────
|
||||||
|
others = [
|
||||||
|
name
|
||||||
|
for name in (candidate_branches or ())
|
||||||
|
if _text(name) and _text(name) != locked_branch
|
||||||
|
]
|
||||||
|
if others:
|
||||||
|
reasons.append(
|
||||||
|
"multiple branches claim this issue "
|
||||||
|
f"({', '.join(sorted(set(others)))}); ownership is ambiguous"
|
||||||
|
)
|
||||||
|
evidence["other_candidate_branches"] = sorted(set(others))
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return _result(REFUSED, False, reasons, evidence)
|
||||||
|
|
||||||
|
proof = [
|
||||||
|
f"durable lock for issue #{issue_number} matches branch "
|
||||||
|
f"'{locked_branch}', worktree '{locked_worktree}', head {local_head}, "
|
||||||
|
f"and claimant '{locked_identity}'; recorded pid {recorded_pid} is dead"
|
||||||
|
]
|
||||||
|
if head_relation == HEAD_RELATION_STRICT_DESCENDANT and ancestry_proof:
|
||||||
|
proof.append(ancestry_proof)
|
||||||
|
return _result(RECOVERY_SANCTIONED, True, proof, evidence)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
outcome: str,
|
||||||
|
sanctioned: bool,
|
||||||
|
reasons: list[str],
|
||||||
|
evidence: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"outcome": outcome,
|
||||||
|
"recovery_sanctioned": sanctioned,
|
||||||
|
"is_candidate": outcome != NO_CANDIDATE,
|
||||||
|
"reasons": reasons,
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def owning_pr_recovery_evidence(
|
||||||
|
assessment: Mapping[str, Any] | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Server-derived proof of the open PR a sanctioned recovery already owns (#755).
|
||||||
|
|
||||||
|
A dead-session recovery is, by construction, recovery of work that already
|
||||||
|
has an open PR — so the duplicate-work gate's linked-open-PR blocker would
|
||||||
|
otherwise discard every sanctioned recovery. This distils the completed
|
||||||
|
assessment into the minimum evidence that gate needs to tell "the PR this
|
||||||
|
lock already owns" apart from "a competing duplicate PR".
|
||||||
|
|
||||||
|
Returns ``None`` unless recovery was actually granted and the assessment's
|
||||||
|
own evidence names exactly one owning PR whose head agrees with the heads
|
||||||
|
the assessor accepted. Nothing here is caller-supplied: every field is
|
||||||
|
copied from evidence the assessor built out of durable lock state plus live
|
||||||
|
git/Gitea observation, so a caller cannot manufacture an exemption.
|
||||||
|
|
||||||
|
#768: a descendant recovery carries two heads. ``head_sha`` stays the head
|
||||||
|
the open PR currently shows (the recorded head, since the remediation is not
|
||||||
|
published yet) and ``accepted_head`` is the local descendant that
|
||||||
|
publication will move it to. Downstream gates accept either, so the
|
||||||
|
exemption survives the very push it exists to permit.
|
||||||
|
"""
|
||||||
|
if not isinstance(assessment, Mapping):
|
||||||
|
return None
|
||||||
|
if assessment.get("outcome") != RECOVERY_SANCTIONED:
|
||||||
|
return None
|
||||||
|
if not assessment.get("recovery_sanctioned"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
evidence = assessment.get("evidence") or {}
|
||||||
|
branch_name = _text(evidence.get("locked_branch"))
|
||||||
|
pr_head = _text(evidence.get("pr_head"))
|
||||||
|
local_head = _text(evidence.get("local_head"))
|
||||||
|
remote_head = _text(evidence.get("remote_head"))
|
||||||
|
recorded_head = _text(evidence.get("recorded_head")) or remote_head
|
||||||
|
accepted_head = _text(evidence.get("accepted_head")) or local_head
|
||||||
|
relation = _text(evidence.get("head_relation")) or HEAD_RELATION_EQUAL
|
||||||
|
raw_pr_number = evidence.get("pr_number")
|
||||||
|
|
||||||
|
if raw_pr_number is None or not branch_name or not pr_head:
|
||||||
|
return None
|
||||||
|
# The assessor already required these to agree. Re-check, so a truncated or
|
||||||
|
# hand-built evidence map can never authorize an exemption.
|
||||||
|
if relation == HEAD_RELATION_EQUAL:
|
||||||
|
if pr_head != local_head or pr_head != remote_head:
|
||||||
|
return None
|
||||||
|
elif relation == HEAD_RELATION_STRICT_DESCENDANT:
|
||||||
|
# The PR must still be at the recorded head, and the accepted head must
|
||||||
|
# actually be a different commit — otherwise this is not a descendant.
|
||||||
|
if not recorded_head or pr_head != recorded_head:
|
||||||
|
return None
|
||||||
|
if not accepted_head or accepted_head == recorded_head:
|
||||||
|
return None
|
||||||
|
if accepted_head != local_head:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pr_number = int(raw_pr_number)
|
||||||
|
issue_number = int(evidence.get("issue_number"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"head_sha": pr_head,
|
||||||
|
"recorded_head": recorded_head or None,
|
||||||
|
"accepted_head": accepted_head or None,
|
||||||
|
"head_relation": relation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def recovered_owning_pr_from_lock(
|
||||||
|
lock_record: Mapping[str, Any] | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Rebuild owning-PR recovery evidence from a persisted lock (#768 AC2).
|
||||||
|
|
||||||
|
``gitea_lock_issue`` holds the live assessment only for the duration of the
|
||||||
|
lock call. The commit, push, create-PR, and duplicate-assessment gates run
|
||||||
|
later, in their own calls, and re-derive ownership from scratch — so an open
|
||||||
|
PR that recovery already proved belongs to this author reappears there as
|
||||||
|
competing duplicate work.
|
||||||
|
|
||||||
|
This reads the same proof back out of the durable ``dead_session_recovery``
|
||||||
|
block that only the server writes, on a lock the caller must already own.
|
||||||
|
It is a re-read of server-derived state, not a new assertion: a caller that
|
||||||
|
could forge this could equally forge the lock file itself, which every other
|
||||||
|
ownership gate already treats as authoritative.
|
||||||
|
"""
|
||||||
|
if not isinstance(lock_record, Mapping):
|
||||||
|
return None
|
||||||
|
record = lock_record.get("dead_session_recovery")
|
||||||
|
if not isinstance(record, Mapping) or not record.get("recovered"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
branch_name = _text(record.get("branch_name")) or _text(
|
||||||
|
lock_record.get("branch_name")
|
||||||
|
)
|
||||||
|
pr_head = _text(record.get("pr_head"))
|
||||||
|
recorded_head = _text(record.get("recorded_head")) or _text(
|
||||||
|
record.get("remote_head")
|
||||||
|
)
|
||||||
|
accepted_head = _text(record.get("accepted_head")) or _text(
|
||||||
|
record.get("local_head")
|
||||||
|
)
|
||||||
|
relation = _text(record.get("head_relation")) or HEAD_RELATION_EQUAL
|
||||||
|
raw_pr_number = record.get("pr_number")
|
||||||
|
raw_issue_number = lock_record.get("issue_number")
|
||||||
|
|
||||||
|
if raw_pr_number is None or raw_issue_number is None:
|
||||||
|
return None
|
||||||
|
if not branch_name or not pr_head:
|
||||||
|
return None
|
||||||
|
if relation == HEAD_RELATION_EQUAL:
|
||||||
|
if accepted_head and accepted_head != pr_head:
|
||||||
|
return None
|
||||||
|
elif relation == HEAD_RELATION_STRICT_DESCENDANT:
|
||||||
|
if not recorded_head or pr_head != recorded_head:
|
||||||
|
return None
|
||||||
|
if not accepted_head or accepted_head == recorded_head:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pr_number = int(raw_pr_number)
|
||||||
|
issue_number = int(raw_issue_number)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"head_sha": pr_head,
|
||||||
|
"recorded_head": recorded_head or None,
|
||||||
|
"accepted_head": accepted_head or None,
|
||||||
|
"head_relation": relation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_recovery_record(
|
||||||
|
assessment: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
recovered_at: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Durable, secret-free provenance for a completed recovery (#753 AC2/AC6).
|
||||||
|
|
||||||
|
#768 AC7: a granted recovery records, atomically with the lock itself, both
|
||||||
|
session identities, the head it was measured against, the head it adopted,
|
||||||
|
how those two relate, and the ancestry proof — so a descendant recovery can
|
||||||
|
be audited after the fact without re-running any probe.
|
||||||
|
"""
|
||||||
|
evidence = dict(assessment.get("evidence") or {})
|
||||||
|
return {
|
||||||
|
"recovered": True,
|
||||||
|
"reason": "owning MCP session exited; durable ownership evidence matched",
|
||||||
|
"recovered_at": recovered_at,
|
||||||
|
"prior_session_pid": evidence.get("prior_session_pid"),
|
||||||
|
"replacement_session_pid": evidence.get("replacement_session_pid"),
|
||||||
|
"prior_pid_alive": evidence.get("prior_pid_alive"),
|
||||||
|
"branch_name": evidence.get("locked_branch"),
|
||||||
|
"worktree_path": evidence.get("locked_worktree_path"),
|
||||||
|
"local_head": evidence.get("local_head"),
|
||||||
|
"remote_head": evidence.get("remote_head"),
|
||||||
|
"recorded_head": evidence.get("recorded_head"),
|
||||||
|
"accepted_head": evidence.get("accepted_head"),
|
||||||
|
"head_relation": evidence.get("head_relation"),
|
||||||
|
"ancestry_proof": evidence.get("ancestry_proof"),
|
||||||
|
"pr_head": evidence.get("pr_head"),
|
||||||
|
"pr_number": evidence.get("pr_number"),
|
||||||
|
"identity": evidence.get("locked_identity"),
|
||||||
|
"profile": evidence.get("locked_profile"),
|
||||||
|
"proof": list(assessment.get("reasons") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_recovery_refusal(assessment: Mapping[str, Any]) -> str:
|
||||||
|
"""Single fail-closed message for a refused recovery attempt."""
|
||||||
|
reasons = list(assessment.get("reasons") or []) or [
|
||||||
|
"dead-session lock recovery evidence did not agree"
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
"Dead-session issue-lock recovery refused: "
|
||||||
|
+ "; ".join(reasons)
|
||||||
|
+ " (fail closed)"
|
||||||
|
)
|
||||||
@@ -375,6 +375,64 @@ def _same_realpath(left: str | None, right: str | None) -> bool:
|
|||||||
return left == right
|
return left == right
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def assess_expired_lock_reclaim(
|
||||||
|
existing_lock: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Decide whether an expired/stale author issue lock may be reclaimed (#601).
|
||||||
|
|
||||||
|
Required proof (any reclaim of non-live lock):
|
||||||
|
* lease not live (expired or dead pid)
|
||||||
|
* owner process dead OR worktree missing
|
||||||
|
* no force-delete of live foreign ownership
|
||||||
|
"""
|
||||||
|
if not existing_lock:
|
||||||
|
return {
|
||||||
|
"reclaim_allowed": True,
|
||||||
|
"reasons": ["no existing lock"],
|
||||||
|
"freshness": assess_lock_freshness(None, now=now),
|
||||||
|
}
|
||||||
|
freshness = assess_lock_freshness(existing_lock, now=now)
|
||||||
|
if freshness.get("live"):
|
||||||
|
return {
|
||||||
|
"reclaim_allowed": False,
|
||||||
|
"reasons": ["lock is still live; cannot reclaim (fail closed)"],
|
||||||
|
"freshness": freshness,
|
||||||
|
}
|
||||||
|
pid = existing_lock.get("session_pid")
|
||||||
|
if pid is None:
|
||||||
|
pid = existing_lock.get("pid")
|
||||||
|
dead = not is_process_alive(pid) if pid is not None else True
|
||||||
|
wt = str(existing_lock.get("worktree_path") or "")
|
||||||
|
missing_wt = (not wt) or (not os.path.isdir(os.path.realpath(wt)))
|
||||||
|
if not (dead or missing_wt):
|
||||||
|
return {
|
||||||
|
"reclaim_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
"expired/stale lock still has live owner pid and present worktree; "
|
||||||
|
"recovery review required (fail closed)"
|
||||||
|
],
|
||||||
|
"freshness": freshness,
|
||||||
|
"owner_pid_dead": dead,
|
||||||
|
"worktree_missing": missing_wt,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"reclaim_allowed": True,
|
||||||
|
"reasons": [
|
||||||
|
"non-live lock with dead process and/or missing worktree; "
|
||||||
|
"sanctioned reclaim allowed"
|
||||||
|
],
|
||||||
|
"freshness": freshness,
|
||||||
|
"owner_pid_dead": dead,
|
||||||
|
"worktree_missing": missing_wt,
|
||||||
|
"prior_branch": existing_lock.get("branch_name"),
|
||||||
|
"prior_worktree": existing_lock.get("worktree_path"),
|
||||||
|
"prior_pid": pid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def assess_same_issue_lease_conflict(
|
def assess_same_issue_lease_conflict(
|
||||||
existing_lock: dict[str, Any] | None,
|
existing_lock: dict[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
@@ -405,6 +463,11 @@ def assess_same_issue_lease_conflict(
|
|||||||
and _same_realpath(str(existing_worktree or ""), worktree_path)
|
and _same_realpath(str(existing_worktree or ""), worktree_path)
|
||||||
)
|
)
|
||||||
if is_lease_expired(existing_lock, now=now):
|
if is_lease_expired(existing_lock, now=now):
|
||||||
|
reclaim = assess_expired_lock_reclaim(existing_lock, now=now)
|
||||||
|
if reclaim.get("reclaim_allowed"):
|
||||||
|
# #601: expired + dead pid / missing worktree may be reclaimed
|
||||||
|
# through the normal lock path (sanctioned overwrite).
|
||||||
|
return None
|
||||||
return (
|
return (
|
||||||
f"Issue #{issue_number} has an expired {operation_type} lease on "
|
f"Issue #{issue_number} has an expired {operation_type} lease on "
|
||||||
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
|
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
|
||||||
|
|||||||
+137
-3
@@ -20,16 +20,131 @@ BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
|||||||
def resolve_author_worktree_path(
|
def resolve_author_worktree_path(
|
||||||
explicit: str | None,
|
explicit: str | None,
|
||||||
project_root: str,
|
project_root: str,
|
||||||
|
*,
|
||||||
|
session_lock_worktree: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Resolve the author worktree path for lock/PR gates."""
|
"""Resolve the author worktree path for lock/PR gates.
|
||||||
|
|
||||||
|
#618: prefer explicit path, then env, then the active issue lock worktree.
|
||||||
|
Does not invent a branches/ worktree. Falling back to *project_root* is
|
||||||
|
retained only for lock-time bootstrap when the process itself is already
|
||||||
|
under branches/ or no binding exists yet (callers still fail closed via
|
||||||
|
preflight / durable resolution before mutation).
|
||||||
|
"""
|
||||||
path = (explicit or "").strip()
|
path = (explicit or "").strip()
|
||||||
if not path:
|
if not path:
|
||||||
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
|
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
|
||||||
|
if not path:
|
||||||
|
path = (os.environ.get("GITEA_ACTIVE_WORKTREE") or "").strip()
|
||||||
|
if not path:
|
||||||
|
path = (session_lock_worktree or "").strip()
|
||||||
if not path:
|
if not path:
|
||||||
path = project_root
|
path = project_root
|
||||||
return os.path.realpath(os.path.abspath(path))
|
return os.path.realpath(os.path.abspath(path))
|
||||||
|
|
||||||
|
|
||||||
|
def read_head_ancestry(
|
||||||
|
worktree_path: str,
|
||||||
|
*,
|
||||||
|
ancestor_sha: str | None,
|
||||||
|
descendant_sha: str | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Observe whether ``descendant_sha`` strictly descends from ``ancestor_sha`` (#768).
|
||||||
|
|
||||||
|
Server-side git observation for dead-session lock recovery. The recovering
|
||||||
|
author's only reachable clean-worktree state is one commit *ahead* of the
|
||||||
|
head recorded at lock time, so recovery needs to know whether that commit
|
||||||
|
extends the recorded head or replaces it.
|
||||||
|
|
||||||
|
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
|
||||||
|
field is read from git in the declared worktree — nothing here is supplied
|
||||||
|
by, or reachable from, an MCP caller (#768 AC6).
|
||||||
|
|
||||||
|
``ancestor_present`` proves the recorded head is still reachable, which is
|
||||||
|
what separates an honest fast-forward from a rewritten or force-moved
|
||||||
|
history: a rewritten recorded head leaves the object graph and the probe
|
||||||
|
fails closed.
|
||||||
|
"""
|
||||||
|
path = (worktree_path or "").strip()
|
||||||
|
ancestor = (ancestor_sha or "").strip()
|
||||||
|
descendant = (descendant_sha or "").strip()
|
||||||
|
result: dict = {
|
||||||
|
"ancestor_sha": ancestor or None,
|
||||||
|
"descendant_sha": descendant or None,
|
||||||
|
"probe_ok": False,
|
||||||
|
"ancestor_present": False,
|
||||||
|
"descendant_present": False,
|
||||||
|
"is_ancestor": False,
|
||||||
|
"is_strict_descendant": False,
|
||||||
|
"proof": None,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
if not path or not ancestor or not descendant:
|
||||||
|
result["reasons"].append(
|
||||||
|
"ancestry probe requires a worktree path and both commit SHAs"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _present(sha: str) -> bool:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", path, "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return res.returncode == 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
result["ancestor_present"] = _present(ancestor)
|
||||||
|
result["descendant_present"] = _present(descendant)
|
||||||
|
except OSError as exc: # git unavailable — fail closed, never assume
|
||||||
|
result["reasons"].append(f"ancestry probe could not run: {exc}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not result["ancestor_present"]:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"recorded head {ancestor} is not reachable in '{path}'; history may "
|
||||||
|
"have been rewritten or force-moved"
|
||||||
|
)
|
||||||
|
if not result["descendant_present"]:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"local head {descendant} is not reachable in '{path}'"
|
||||||
|
)
|
||||||
|
if not (result["ancestor_present"] and result["descendant_present"]):
|
||||||
|
return result
|
||||||
|
|
||||||
|
probe = subprocess.run(
|
||||||
|
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
# 0 = is an ancestor, 1 = is not. Anything else is a failed probe, not a "no".
|
||||||
|
if probe.returncode not in (0, 1):
|
||||||
|
result["reasons"].append(
|
||||||
|
f"ancestry probe failed with exit {probe.returncode}; ancestry unproven"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["probe_ok"] = True
|
||||||
|
result["is_ancestor"] = probe.returncode == 0
|
||||||
|
result["is_strict_descendant"] = result["is_ancestor"] and ancestor != descendant
|
||||||
|
result["proof"] = (
|
||||||
|
f"git -C <worktree> merge-base --is-ancestor {ancestor} {descendant} "
|
||||||
|
f"-> exit {probe.returncode}"
|
||||||
|
)
|
||||||
|
if not result["is_ancestor"]:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"local head {descendant} does not descend from recorded head {ancestor}"
|
||||||
|
)
|
||||||
|
elif not result["is_strict_descendant"]:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"local head {descendant} equals the recorded head; no descendant "
|
||||||
|
"recovery is involved"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def read_worktree_git_state(
|
def read_worktree_git_state(
|
||||||
worktree_path: str,
|
worktree_path: str,
|
||||||
extra_bases: tuple[str, ...] | list[str] = (),
|
extra_bases: tuple[str, ...] | list[str] = (),
|
||||||
@@ -92,8 +207,19 @@ def assess_issue_lock_worktree(
|
|||||||
inspected_git_root: str | None = None,
|
inspected_git_root: str | None = None,
|
||||||
base_branch: str | None = None,
|
base_branch: str | None = None,
|
||||||
base_branches: frozenset[str] | None = None,
|
base_branches: frozenset[str] | None = None,
|
||||||
|
recovery_sanctioned: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Fail closed when lock preconditions are not met on the declared worktree."""
|
"""Fail closed when lock preconditions are not met on the declared worktree.
|
||||||
|
|
||||||
|
``recovery_sanctioned`` is set only when ``issue_lock_recovery`` has already
|
||||||
|
proven, from the durable lock itself, that this is a dead-session recovery of
|
||||||
|
an existing claim (#753): same issue, branch, worktree, author, and head, with
|
||||||
|
the recording process dead. In that one case the base-equivalence requirement
|
||||||
|
is waived, because a branch that already carries the work is ahead of its base
|
||||||
|
by construction and could never satisfy it. Every other precondition —
|
||||||
|
notably worktree cleanliness — still applies unchanged, and brand-new issue
|
||||||
|
claims keep the full base-equivalence requirement.
|
||||||
|
"""
|
||||||
bases = base_branches or BASE_BRANCHES
|
bases = base_branches or BASE_BRANCHES
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
path = (worktree_path or "").strip()
|
path = (worktree_path or "").strip()
|
||||||
@@ -111,7 +237,11 @@ def assess_issue_lock_worktree(
|
|||||||
f"(dirty files: {', '.join(dirty_files)})"
|
f"(dirty files: {', '.join(dirty_files)})"
|
||||||
)
|
)
|
||||||
|
|
||||||
if base_equivalent is False:
|
if recovery_sanctioned:
|
||||||
|
# Base-equivalence intentionally not evaluated: ownership was proven
|
||||||
|
# against the durable lock record instead (#753).
|
||||||
|
pass
|
||||||
|
elif base_equivalent is False:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"issue lock worktree must be base-equivalent to one of "
|
"issue lock worktree must be base-equivalent to one of "
|
||||||
f"{_base_list(bases)} before implementation work; inspected "
|
f"{_base_list(bases)} before implementation work; inspected "
|
||||||
@@ -139,6 +269,7 @@ def assess_issue_lock_worktree(
|
|||||||
inspected_git_root=inspected_git_root,
|
inspected_git_root=inspected_git_root,
|
||||||
base_branch=base_branch,
|
base_branch=base_branch,
|
||||||
base_equivalent=base_equivalent,
|
base_equivalent=base_equivalent,
|
||||||
|
recovery_sanctioned=recovery_sanctioned,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -197,6 +328,7 @@ def _assessment(
|
|||||||
inspected_git_root: str | None = None,
|
inspected_git_root: str | None = None,
|
||||||
base_branch: str | None = None,
|
base_branch: str | None = None,
|
||||||
base_equivalent: bool | None = None,
|
base_equivalent: bool | None = None,
|
||||||
|
recovery_sanctioned: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"proven": proven,
|
"proven": proven,
|
||||||
@@ -208,6 +340,8 @@ def _assessment(
|
|||||||
"dirty_files": dirty_files,
|
"dirty_files": dirty_files,
|
||||||
"base_branch": base_branch,
|
"base_branch": base_branch,
|
||||||
"base_equivalent": base_equivalent,
|
"base_equivalent": base_equivalent,
|
||||||
|
"recovery_sanctioned": recovery_sanctioned,
|
||||||
|
"base_equivalence_waived": bool(recovery_sanctioned),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Mapping
|
||||||
|
|
||||||
import issue_claim_heartbeat as claim_hb
|
import issue_claim_heartbeat as claim_hb
|
||||||
|
|
||||||
@@ -27,6 +27,128 @@ def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
|||||||
return claim_hb._linked_open_pr(issue_number, open_prs)
|
return claim_hb._linked_open_pr(issue_number, open_prs)
|
||||||
|
|
||||||
|
|
||||||
|
def _pr_links_issue(issue_number: int, pr: Mapping[str, Any]) -> bool:
|
||||||
|
"""Same linkage rule ``claim_hb._linked_open_pr`` applies, per PR.
|
||||||
|
|
||||||
|
``_linked_open_pr`` only yields the *first* match, which cannot answer
|
||||||
|
"is there exactly one linked PR?" — a question the owning-PR exemption
|
||||||
|
below must answer before it can trust any of them.
|
||||||
|
"""
|
||||||
|
pattern = _issue_pattern(issue_number)
|
||||||
|
head = (pr.get("head") or {}).get("ref") or ""
|
||||||
|
text = f"{pr.get('title', '')} {pr.get('body', '')}".lower()
|
||||||
|
if pattern in head.lower():
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
f"closes #{int(issue_number)}" in text
|
||||||
|
or f"fixes #{int(issue_number)}" in text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _all_linked_open_prs(
|
||||||
|
issue_number: int, open_prs: list[dict]
|
||||||
|
) -> list[Mapping[str, Any]]:
|
||||||
|
return [pr for pr in (open_prs or []) if _pr_links_issue(issue_number, pr)]
|
||||||
|
|
||||||
|
|
||||||
|
def _assess_owning_pr_exemption(
|
||||||
|
issue_number: int,
|
||||||
|
*,
|
||||||
|
linked_open_prs: list[Mapping[str, Any]],
|
||||||
|
locked_branch: str | None,
|
||||||
|
recovered_owning_pr: Mapping[str, Any] | None,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Is the linked open PR provably the one a sanctioned recovery owns (#755)?
|
||||||
|
|
||||||
|
``recovered_owning_pr`` is produced by
|
||||||
|
``issue_lock_recovery.owning_pr_recovery_evidence`` from a completed
|
||||||
|
server-side recovery assessment — it is never a caller-supplied field.
|
||||||
|
Every element is re-checked here against the live PR list this gate was
|
||||||
|
given, so a stale or partial token cannot widen the exemption.
|
||||||
|
|
||||||
|
Returns ``(exempt, diagnostic_reasons)``. Diagnostics are only emitted when
|
||||||
|
a token was offered and rejected, so a blocked caller can see which element
|
||||||
|
of ownership disagreed.
|
||||||
|
"""
|
||||||
|
if not recovered_owning_pr:
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
notes: list[str] = []
|
||||||
|
token_issue = recovered_owning_pr.get("issue_number")
|
||||||
|
token_pr = recovered_owning_pr.get("pr_number")
|
||||||
|
token_branch = str(recovered_owning_pr.get("branch_name") or "").strip()
|
||||||
|
token_head = str(recovered_owning_pr.get("head_sha") or "").strip()
|
||||||
|
locked = (locked_branch or "").strip()
|
||||||
|
|
||||||
|
if token_issue is not None and int(token_issue) != int(issue_number):
|
||||||
|
notes.append(
|
||||||
|
f"recovery evidence is for issue #{token_issue}, not "
|
||||||
|
f"#{issue_number} (no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
if not locked or not token_branch or locked != token_branch:
|
||||||
|
notes.append(
|
||||||
|
f"recovery evidence branch '{token_branch or 'unknown'}' does not "
|
||||||
|
f"match the branch being locked '{locked or 'unknown'}' "
|
||||||
|
"(no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
if len(linked_open_prs) != 1:
|
||||||
|
numbers = ", ".join(
|
||||||
|
f"#{pr.get('number')}" for pr in linked_open_prs
|
||||||
|
) or "none"
|
||||||
|
notes.append(
|
||||||
|
f"{len(linked_open_prs)} open PRs link issue #{issue_number} "
|
||||||
|
f"({numbers}); recovery may only own exactly one "
|
||||||
|
"(no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
|
||||||
|
only = linked_open_prs[0]
|
||||||
|
head_obj = only.get("head") or {}
|
||||||
|
only_number = only.get("number")
|
||||||
|
only_ref = str(head_obj.get("ref") or "").strip()
|
||||||
|
only_sha = str(head_obj.get("sha") or "").strip()
|
||||||
|
|
||||||
|
if token_pr is None or only_number is None or int(only_number) != int(token_pr):
|
||||||
|
notes.append(
|
||||||
|
f"linked open PR #{only_number} is not the recovered owning PR "
|
||||||
|
f"#{token_pr} (no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
if only_ref != token_branch:
|
||||||
|
notes.append(
|
||||||
|
f"open PR #{only_number} head branch '{only_ref}' does not match "
|
||||||
|
f"the recovered branch '{token_branch}' (no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
# #768: a descendant recovery is measured against the head the PR still
|
||||||
|
# shows, then publishes the local descendant — so the live PR head is the
|
||||||
|
# recorded head before that push and the accepted head after it. Both are
|
||||||
|
# server-derived and name the same owned PR, so both are accepted; anything
|
||||||
|
# else still fails closed.
|
||||||
|
token_accepted = str(recovered_owning_pr.get("accepted_head") or "").strip()
|
||||||
|
acceptable_heads = [head for head in (token_head, token_accepted) if head]
|
||||||
|
if not acceptable_heads or not only_sha or only_sha not in acceptable_heads:
|
||||||
|
notes.append(
|
||||||
|
f"open PR #{only_number} head {only_sha or 'unknown'} does not "
|
||||||
|
f"match the recovered head {token_head or 'unknown'}"
|
||||||
|
+ (
|
||||||
|
f" or the accepted head {token_accepted}"
|
||||||
|
if token_accepted and token_accepted != token_head
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
+ " (no owning-PR exemption)"
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
|
||||||
|
return True, [
|
||||||
|
f"open PR #{only_number} is the exact PR already owned by the "
|
||||||
|
f"recovering lock for issue #{issue_number} (branch '{token_branch}', "
|
||||||
|
f"head {only_sha}); not duplicate work"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _matching_branches(
|
def _matching_branches(
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
branch_names: list[str],
|
branch_names: list[str],
|
||||||
@@ -52,8 +174,15 @@ def assess_work_issue_duplicate_gate(
|
|||||||
claim_entry: dict | None = None,
|
claim_entry: dict | None = None,
|
||||||
locked_branch: str | None = None,
|
locked_branch: str | None = None,
|
||||||
phase: str = PHASE_LOCK,
|
phase: str = PHASE_LOCK,
|
||||||
|
recovered_owning_pr: Mapping[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Fail closed when duplicate work is already in flight for an issue."""
|
"""Fail closed when duplicate work is already in flight for an issue.
|
||||||
|
|
||||||
|
``recovered_owning_pr`` (#755) is server-derived evidence that a sanctioned
|
||||||
|
dead-session lock recovery already owns one specific open PR. It exempts
|
||||||
|
*only* that exact PR from the linked-open-PR blocker; every other duplicate
|
||||||
|
signal, and every mismatch, keeps failing closed.
|
||||||
|
"""
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
||||||
prs = list(open_prs or [])
|
prs = list(open_prs or [])
|
||||||
@@ -61,12 +190,23 @@ def assess_work_issue_duplicate_gate(
|
|||||||
pattern = _issue_pattern(issue_number)
|
pattern = _issue_pattern(issue_number)
|
||||||
|
|
||||||
linked = _linked_open_pr(issue_number, prs)
|
linked = _linked_open_pr(issue_number, prs)
|
||||||
|
linked_open_prs = _all_linked_open_prs(issue_number, prs)
|
||||||
|
owning_pr_exempted = False
|
||||||
|
exemption_notes: list[str] = []
|
||||||
if linked:
|
if linked:
|
||||||
reasons.append(
|
owning_pr_exempted, exemption_notes = _assess_owning_pr_exemption(
|
||||||
f"open PR #{linked.get('number')} already covers issue "
|
issue_number,
|
||||||
f"#{issue_number} (fail closed)"
|
linked_open_prs=linked_open_prs,
|
||||||
|
locked_branch=locked_branch,
|
||||||
|
recovered_owning_pr=recovered_owning_pr,
|
||||||
)
|
)
|
||||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
if not owning_pr_exempted:
|
||||||
|
reasons.append(
|
||||||
|
f"open PR #{linked.get('number')} already covers issue "
|
||||||
|
f"#{issue_number} (fail closed)"
|
||||||
|
)
|
||||||
|
reasons.extend(exemption_notes)
|
||||||
|
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||||
|
|
||||||
conflicting_branches = _matching_branches(
|
conflicting_branches = _matching_branches(
|
||||||
issue_number, branches, locked_branch=locked_branch
|
issue_number, branches, locked_branch=locked_branch
|
||||||
@@ -122,6 +262,9 @@ def assess_work_issue_duplicate_gate(
|
|||||||
"phase": phase,
|
"phase": phase,
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
|
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
|
||||||
|
"linked_open_pr_count": len(linked_open_prs),
|
||||||
|
"owning_pr_recovery_exempted": owning_pr_exempted,
|
||||||
|
"owning_pr_recovery_notes": list(exemption_notes),
|
||||||
"conflicting_branches": conflicting_branches,
|
"conflicting_branches": conflicting_branches,
|
||||||
"claim_status": status or None,
|
"claim_status": status or None,
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
|
|||||||
+213
-1
@@ -34,6 +34,11 @@ STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
|||||||
LabelSpec("status:blocked", "b60205", "Issue is blocked"),
|
LabelSpec("status:blocked", "b60205", "Issue is blocked"),
|
||||||
LabelSpec("status:needs-review", "0052cc", "Issue work needs review"),
|
LabelSpec("status:needs-review", "0052cc", "Issue work needs review"),
|
||||||
LabelSpec("status:pr-open", "1d76db", "A linked PR is open"),
|
LabelSpec("status:pr-open", "1d76db", "A linked PR is open"),
|
||||||
|
LabelSpec(
|
||||||
|
"status:changes-requested",
|
||||||
|
"e11d21",
|
||||||
|
"Reviewer requested changes on the linked PR",
|
||||||
|
),
|
||||||
LabelSpec("status:approved", "0e8a16", "Linked PR is approved"),
|
LabelSpec("status:approved", "0e8a16", "Linked PR is approved"),
|
||||||
LabelSpec("status:merged", "5319e7", "Linked PR is merged"),
|
LabelSpec("status:merged", "5319e7", "Linked PR is merged"),
|
||||||
LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"),
|
LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"),
|
||||||
@@ -42,12 +47,74 @@ STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
|||||||
LabelSpec("status:wontfix", "000000", "Issue will not be fixed"),
|
LabelSpec("status:wontfix", "000000", "Issue will not be fixed"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Durable validation-outcome labels (#529): the four canonical distinctions a
|
||||||
|
# reviewer report can carry. These are orthogonal to the single active status:*
|
||||||
|
# label, so they use their own prefix and are not subject to the one-status
|
||||||
|
# invariant.
|
||||||
|
VALIDATION_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
|
LabelSpec("validation:clean-pass", "0e8a16", "Validation was a clean pass"),
|
||||||
|
LabelSpec(
|
||||||
|
"validation:baseline-accepted",
|
||||||
|
"fbca04",
|
||||||
|
"Validation passed with a baseline-proven unrelated failure",
|
||||||
|
),
|
||||||
|
LabelSpec(
|
||||||
|
"validation:blocked",
|
||||||
|
"b60205",
|
||||||
|
"Validation blocked by an unresolved failure",
|
||||||
|
),
|
||||||
|
LabelSpec(
|
||||||
|
"validation:post-merge-moot",
|
||||||
|
"5319e7",
|
||||||
|
"Validation is post-merge moot (PR already merged/closed before review)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lifecycle role-ownership labels (#603): which workflow role currently owns the
|
||||||
|
# item. Advisory visibility only — the control-plane lease (#601) remains the
|
||||||
|
# source of truth for mutation authority. Only one role:* label is active at a
|
||||||
|
# time, mirroring the single-active-status invariant.
|
||||||
|
ROLE_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
|
LabelSpec("role:author", "1d76db", "Author currently owns the item"),
|
||||||
|
LabelSpec("role:reviewer", "5319e7", "Reviewer currently owns the item"),
|
||||||
|
LabelSpec("role:merger", "0e8a16", "Merger currently owns the item"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lifecycle hazard labels (#603): orthogonal warning flags surfacing dangerous
|
||||||
|
# coordination conditions. Unlike status/role, multiple hazard:* labels may be
|
||||||
|
# active at once, and they never substitute for live lease / PR state checks.
|
||||||
|
HAZARD_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
|
LabelSpec("hazard:stale-lease", "d93f0b", "A stale or expired lease references this item"),
|
||||||
|
LabelSpec(
|
||||||
|
"hazard:workflow-contaminated",
|
||||||
|
"b60205",
|
||||||
|
"Session/workflow state is contaminated and must not mutate",
|
||||||
|
),
|
||||||
|
LabelSpec("hazard:conflicted", "e11d21", "Linked PR has merge conflicts"),
|
||||||
|
LabelSpec("hazard:root-mutation", "b60205", "Work was mutated in the project root checkout"),
|
||||||
|
LabelSpec("hazard:manual-state", "d93f0b", "Session or lease state was edited manually"),
|
||||||
|
LabelSpec(
|
||||||
|
"hazard:terminal-blocker",
|
||||||
|
"000000",
|
||||||
|
"A terminal review/merge lock blocks progress (#332/#602)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
TYPE_LABEL_SPECS + STATUS_LABEL_SPECS
|
TYPE_LABEL_SPECS
|
||||||
|
+ STATUS_LABEL_SPECS
|
||||||
|
+ VALIDATION_LABEL_SPECS
|
||||||
|
+ ROLE_LABEL_SPECS
|
||||||
|
+ HAZARD_LABEL_SPECS
|
||||||
)
|
)
|
||||||
|
|
||||||
TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS)
|
TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS)
|
||||||
STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPECS)
|
STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPECS)
|
||||||
|
VALIDATION_LABELS: frozenset[str] = frozenset(
|
||||||
|
spec.name for spec in VALIDATION_LABEL_SPECS
|
||||||
|
)
|
||||||
|
ROLE_LABELS: frozenset[str] = frozenset(spec.name for spec in ROLE_LABEL_SPECS)
|
||||||
|
HAZARD_LABELS: frozenset[str] = frozenset(spec.name for spec in HAZARD_LABEL_SPECS)
|
||||||
CANONICAL_LABELS: frozenset[str] = frozenset(
|
CANONICAL_LABELS: frozenset[str] = frozenset(
|
||||||
spec.name for spec in CANONICAL_LABEL_SPECS
|
spec.name for spec in CANONICAL_LABEL_SPECS
|
||||||
)
|
)
|
||||||
@@ -65,9 +132,15 @@ STATUS_TRANSITIONS: dict[str, str] = {
|
|||||||
"blocked": "status:blocked",
|
"blocked": "status:blocked",
|
||||||
"needs_review": "status:needs-review",
|
"needs_review": "status:needs-review",
|
||||||
"needs-review": "status:needs-review",
|
"needs-review": "status:needs-review",
|
||||||
|
"reviewing": "status:needs-review",
|
||||||
"pr_open": "status:pr-open",
|
"pr_open": "status:pr-open",
|
||||||
"pr-open": "status:pr-open",
|
"pr-open": "status:pr-open",
|
||||||
|
"changes_requested": "status:changes-requested",
|
||||||
|
"changes-requested": "status:changes-requested",
|
||||||
|
"changes": "status:changes-requested",
|
||||||
"approved": "status:approved",
|
"approved": "status:approved",
|
||||||
|
"merge_ready": "status:approved",
|
||||||
|
"merge-ready": "status:approved",
|
||||||
"merge": "status:reconcile",
|
"merge": "status:reconcile",
|
||||||
"merged": "status:reconcile",
|
"merged": "status:reconcile",
|
||||||
"reconcile": "status:reconcile",
|
"reconcile": "status:reconcile",
|
||||||
@@ -75,6 +148,37 @@ STATUS_TRANSITIONS: dict[str, str] = {
|
|||||||
"complete": "status:done",
|
"complete": "status:done",
|
||||||
"duplicate": "status:duplicate",
|
"duplicate": "status:duplicate",
|
||||||
"wontfix": "status:wontfix",
|
"wontfix": "status:wontfix",
|
||||||
|
"abandoned": "status:wontfix",
|
||||||
|
# #603 requested state:* synonyms folded into the canonical status vocabulary
|
||||||
|
"needs_triage": "status:triage",
|
||||||
|
"needs-triage": "status:triage",
|
||||||
|
"authoring": "status:in-progress",
|
||||||
|
}
|
||||||
|
|
||||||
|
# #603: single-active role ownership. Maps role kinds / role:* labels to the
|
||||||
|
# canonical role label. Mirrors STATUS_TRANSITIONS for the role dimension.
|
||||||
|
ROLE_TRANSITIONS: dict[str, str] = {
|
||||||
|
"author": "role:author",
|
||||||
|
"reviewer": "role:reviewer",
|
||||||
|
"merger": "role:merger",
|
||||||
|
}
|
||||||
|
|
||||||
|
# #603: hazard flag synonyms. Hazards are additive (not single-active), so this
|
||||||
|
# only normalizes names; it does not drive replacement.
|
||||||
|
HAZARD_TRANSITIONS: dict[str, str] = {
|
||||||
|
"stale_lease": "hazard:stale-lease",
|
||||||
|
"stale-lease": "hazard:stale-lease",
|
||||||
|
"workflow_contaminated": "hazard:workflow-contaminated",
|
||||||
|
"workflow-contaminated": "hazard:workflow-contaminated",
|
||||||
|
"contaminated": "hazard:workflow-contaminated",
|
||||||
|
"conflicted": "hazard:conflicted",
|
||||||
|
"conflict": "hazard:conflicted",
|
||||||
|
"root_mutation": "hazard:root-mutation",
|
||||||
|
"root-mutation": "hazard:root-mutation",
|
||||||
|
"manual_state": "hazard:manual-state",
|
||||||
|
"manual-state": "hazard:manual-state",
|
||||||
|
"terminal_blocker": "hazard:terminal-blocker",
|
||||||
|
"terminal-blocker": "hazard:terminal-blocker",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +233,100 @@ def transition_status_labels(
|
|||||||
return kept
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def role_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||||
|
return [name for name in label_names(labels) if name.startswith("role:")]
|
||||||
|
|
||||||
|
|
||||||
|
def hazard_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||||
|
return [name for name in label_names(labels) if name.startswith("hazard:")]
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_role_label(role_or_transition: str) -> str:
|
||||||
|
role = role_or_transition.strip()
|
||||||
|
if role in ROLE_LABELS:
|
||||||
|
return role
|
||||||
|
normalized = role.lower().replace(" ", "-")
|
||||||
|
try:
|
||||||
|
return ROLE_TRANSITIONS[normalized]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown workflow role or transition '{role_or_transition}'"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def transition_role_labels(
|
||||||
|
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||||
|
role_or_transition: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Replace all active role labels with the requested canonical role."""
|
||||||
|
new_role = canonical_role_label(role_or_transition)
|
||||||
|
kept = [name for name in label_names(existing_labels) if not name.startswith("role:")]
|
||||||
|
if new_role not in kept:
|
||||||
|
kept.append(new_role)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_hazard_label(hazard: str) -> str:
|
||||||
|
name = hazard.strip()
|
||||||
|
if name in HAZARD_LABELS:
|
||||||
|
return name
|
||||||
|
normalized = name.lower().replace(" ", "-")
|
||||||
|
try:
|
||||||
|
return HAZARD_TRANSITIONS[normalized]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"unknown workflow hazard '{hazard}'") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def add_hazard_label(
|
||||||
|
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||||
|
hazard: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Add a hazard flag without disturbing status/role/type labels (additive)."""
|
||||||
|
new_hazard = canonical_hazard_label(hazard)
|
||||||
|
kept = label_names(existing_labels)
|
||||||
|
if new_hazard not in kept:
|
||||||
|
kept.append(new_hazard)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def clear_hazard_label(
|
||||||
|
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||||
|
hazard: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Remove a single hazard flag, leaving all other labels intact."""
|
||||||
|
target = canonical_hazard_label(hazard)
|
||||||
|
return [name for name in label_names(existing_labels) if name != target]
|
||||||
|
|
||||||
|
|
||||||
|
def is_discussion(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> bool:
|
||||||
|
return "type:discussion" in type_labels(labels)
|
||||||
|
|
||||||
|
|
||||||
|
def is_implementation_candidate(
|
||||||
|
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||||
|
) -> bool:
|
||||||
|
"""Whether an item may enter an implementation queue on labels alone.
|
||||||
|
|
||||||
|
Discussion issues are excluded (#603 AC3) unless a controller explicitly
|
||||||
|
selects them; the allocator still cross-checks live lease/PR state and never
|
||||||
|
trusts labels alone (#603 AC2).
|
||||||
|
"""
|
||||||
|
return not is_discussion(labels)
|
||||||
|
|
||||||
|
|
||||||
|
def requires_blocking_reason(
|
||||||
|
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||||
|
) -> bool:
|
||||||
|
"""Whether the item must carry a blocking-reason / next-action comment (AC4).
|
||||||
|
|
||||||
|
True when blocked or when any hazard flag is present.
|
||||||
|
"""
|
||||||
|
names = label_names(labels)
|
||||||
|
if "status:blocked" in names:
|
||||||
|
return True
|
||||||
|
return any(name.startswith("hazard:") for name in names)
|
||||||
|
|
||||||
|
|
||||||
def labels_for_new_issue(
|
def labels_for_new_issue(
|
||||||
issue_type: str | None = None,
|
issue_type: str | None = None,
|
||||||
initial_status: str | None = None,
|
initial_status: str | None = None,
|
||||||
@@ -162,6 +360,8 @@ def assess_issue_labels(
|
|||||||
names = label_names(labels)
|
names = label_names(labels)
|
||||||
found_types = type_labels(names)
|
found_types = type_labels(names)
|
||||||
found_statuses = status_labels(names)
|
found_statuses = status_labels(names)
|
||||||
|
found_roles = role_labels(names)
|
||||||
|
found_hazards = hazard_labels(names)
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
|
||||||
@@ -176,6 +376,10 @@ def assess_issue_labels(
|
|||||||
"issue has multiple active status:* labels: "
|
"issue has multiple active status:* labels: "
|
||||||
+ ", ".join(found_statuses)
|
+ ", ".join(found_statuses)
|
||||||
)
|
)
|
||||||
|
if len(found_roles) > 1:
|
||||||
|
errors.append(
|
||||||
|
"issue has multiple active role:* labels: " + ", ".join(found_roles)
|
||||||
|
)
|
||||||
|
|
||||||
for name in found_types:
|
for name in found_types:
|
||||||
if name not in TYPE_LABELS:
|
if name not in TYPE_LABELS:
|
||||||
@@ -183,12 +387,20 @@ def assess_issue_labels(
|
|||||||
for name in found_statuses:
|
for name in found_statuses:
|
||||||
if name not in STATUS_LABELS:
|
if name not in STATUS_LABELS:
|
||||||
warnings.append(f"unknown status label '{name}'")
|
warnings.append(f"unknown status label '{name}'")
|
||||||
|
for name in found_roles:
|
||||||
|
if name not in ROLE_LABELS:
|
||||||
|
warnings.append(f"unknown role label '{name}'")
|
||||||
|
for name in found_hazards:
|
||||||
|
if name not in HAZARD_LABELS:
|
||||||
|
warnings.append(f"unknown hazard label '{name}'")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": not errors,
|
"valid": not errors,
|
||||||
"labels": names,
|
"labels": names,
|
||||||
"type_labels": found_types,
|
"type_labels": found_types,
|
||||||
"status_labels": found_statuses,
|
"status_labels": found_statuses,
|
||||||
|
"role_labels": found_roles,
|
||||||
|
"hazard_labels": found_hazards,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,723 @@
|
|||||||
|
"""First-class control-plane lease lifecycle (#601).
|
||||||
|
|
||||||
|
Active leases are queryable workflow state. Canonical operations:
|
||||||
|
|
||||||
|
* list / inspect
|
||||||
|
* adopt (with provenance)
|
||||||
|
* release (explicit, recorded)
|
||||||
|
* expire / reclaim
|
||||||
|
* abandon (requires proof: dead process and/or missing worktree, etc.)
|
||||||
|
|
||||||
|
Authority model:
|
||||||
|
|
||||||
|
* Control-plane DB is the coordination source for assignment/lease.
|
||||||
|
* File locks and comment-only leases are **not** authoritative alone.
|
||||||
|
* Reviewer/merger comment leases (``reviewer_pr_lease`` / merger adoption)
|
||||||
|
remain for Gitea-thread durability; they must not bypass DB assignment when
|
||||||
|
the control-plane path is in use.
|
||||||
|
|
||||||
|
Fail closed on ambiguous ownership, active foreign leases, mismatched
|
||||||
|
worktree, stale head, missing capability, and unsafe cleanup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
|
import control_plane_db as cpd
|
||||||
|
|
||||||
|
# Outcomes / safe next actions (stable vocabulary for tools + tests).
|
||||||
|
SAFE_OWNER_RESUME = "owner_resume"
|
||||||
|
SAFE_WAIT_FOREIGN = "wait_foreign_active"
|
||||||
|
SAFE_RECLAIM_EXPIRED = "reclaim_expired"
|
||||||
|
SAFE_ABANDON_ALLOWED = "abandon_allowed"
|
||||||
|
SAFE_RELEASE_OWNED = "release_owned"
|
||||||
|
SAFE_STALE_PROMPT = "stale_prompt_lease"
|
||||||
|
SAFE_UNKNOWN = "inspect_only"
|
||||||
|
SAFE_NO_AUTHORITY = "file_or_comment_not_authoritative"
|
||||||
|
|
||||||
|
LEASE_STATUS_ACTIVE = "active"
|
||||||
|
LEASE_STATUS_RELEASED = "released"
|
||||||
|
LEASE_STATUS_EXPIRED = "expired"
|
||||||
|
LEASE_STATUS_ABANDONED = "abandoned"
|
||||||
|
|
||||||
|
AUTHORITATIVE_SOURCE = "control_plane_db"
|
||||||
|
|
||||||
|
|
||||||
|
class LeaseLifecycleError(RuntimeError):
|
||||||
|
"""Fail-closed lifecycle policy error."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AbandonProof:
|
||||||
|
"""Required evidence to abandon a non-owned or expired lease safely."""
|
||||||
|
|
||||||
|
dead_process: bool = False
|
||||||
|
missing_worktree: bool = False
|
||||||
|
same_owner: bool = False
|
||||||
|
no_open_pr: bool = False
|
||||||
|
no_live_mutation_risk: bool = False
|
||||||
|
operator_authorized: bool = False
|
||||||
|
worktree_path: str | None = None
|
||||||
|
owner_pid: int | None = None
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"dead_process": self.dead_process,
|
||||||
|
"missing_worktree": self.missing_worktree,
|
||||||
|
"same_owner": self.same_owner,
|
||||||
|
"no_open_pr": self.no_open_pr,
|
||||||
|
"no_live_mutation_risk": self.no_live_mutation_risk,
|
||||||
|
"operator_authorized": self.operator_authorized,
|
||||||
|
"worktree_path": self.worktree_path,
|
||||||
|
"owner_pid": self.owner_pid,
|
||||||
|
"notes": self.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_sufficient(self) -> bool:
|
||||||
|
"""Abandon requires process death or missing worktree + no mutation risk.
|
||||||
|
|
||||||
|
Foreign active leases additionally need operator_authorized unless the
|
||||||
|
owner process is dead and the worktree is missing.
|
||||||
|
"""
|
||||||
|
if not self.no_live_mutation_risk:
|
||||||
|
return False
|
||||||
|
if not (self.dead_process or self.missing_worktree):
|
||||||
|
return False
|
||||||
|
if self.same_owner:
|
||||||
|
return True
|
||||||
|
# Foreign abandon: operator flag OR (dead + missing worktree + no risk)
|
||||||
|
if self.operator_authorized:
|
||||||
|
return True
|
||||||
|
return bool(self.dead_process and self.missing_worktree and self.no_open_pr)
|
||||||
|
|
||||||
|
|
||||||
|
def is_process_alive(pid: int | None) -> bool:
|
||||||
|
if pid is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
pid_i = int(pid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if pid_i <= 0:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid_i, 0)
|
||||||
|
return True
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
# Exists but not owned by us — treat as alive (fail closed).
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def worktree_exists(path: str | None) -> bool:
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return os.path.isdir(os.path.realpath(path))
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(value: str | None) -> datetime | None:
|
||||||
|
return cpd._parse_ts(value)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_lease_freshness(
|
||||||
|
lease: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
pid_checker: Callable[[int | None], bool] = is_process_alive,
|
||||||
|
worktree_checker: Callable[[str | None], bool] = worktree_exists,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify a control-plane lease row for workflow tooling."""
|
||||||
|
moment = now or _utc_now()
|
||||||
|
status = (lease.get("status") or "").strip().lower()
|
||||||
|
expires = _parse_ts(lease.get("expires_at"))
|
||||||
|
owner_pid = lease.get("owner_pid")
|
||||||
|
if owner_pid is None:
|
||||||
|
owner_pid = lease.get("session_pid")
|
||||||
|
wt = lease.get("worktree_path")
|
||||||
|
pid_alive = pid_checker(owner_pid) if owner_pid is not None else None
|
||||||
|
wt_present = worktree_checker(wt) if wt else None
|
||||||
|
expired_by_time = bool(expires and expires <= moment)
|
||||||
|
|
||||||
|
if status == LEASE_STATUS_ABANDONED:
|
||||||
|
freshness = "abandoned"
|
||||||
|
elif status == LEASE_STATUS_RELEASED:
|
||||||
|
freshness = "released"
|
||||||
|
elif status == LEASE_STATUS_EXPIRED or expired_by_time:
|
||||||
|
freshness = "expired"
|
||||||
|
elif status != LEASE_STATUS_ACTIVE:
|
||||||
|
freshness = status or "unknown"
|
||||||
|
elif pid_alive is False:
|
||||||
|
freshness = "stale_dead_process"
|
||||||
|
elif wt is not None and wt_present is False:
|
||||||
|
freshness = "stale_missing_worktree"
|
||||||
|
else:
|
||||||
|
freshness = "active"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"freshness": freshness,
|
||||||
|
"status": status,
|
||||||
|
"expired_by_time": expired_by_time,
|
||||||
|
"owner_pid": owner_pid,
|
||||||
|
"owner_pid_alive": pid_alive,
|
||||||
|
"worktree_path": wt,
|
||||||
|
"worktree_present": wt_present,
|
||||||
|
"expires_at": lease.get("expires_at"),
|
||||||
|
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decide_safe_next_action(
|
||||||
|
*,
|
||||||
|
lease: Mapping[str, Any] | None,
|
||||||
|
caller_session_id: str,
|
||||||
|
freshness: Mapping[str, Any] | None = None,
|
||||||
|
caller_worktree: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return safe_next_action + reasons for a caller inspecting a lease."""
|
||||||
|
if not lease:
|
||||||
|
return {
|
||||||
|
"safe_next_action": SAFE_STALE_PROMPT,
|
||||||
|
"reasons": ["lease not found in control-plane DB (stale prompt id?)"],
|
||||||
|
"block": True,
|
||||||
|
}
|
||||||
|
fr = freshness or classify_lease_freshness(lease)
|
||||||
|
owner = str(lease.get("session_id") or "")
|
||||||
|
same_owner = owner == str(caller_session_id)
|
||||||
|
status = fr.get("freshness")
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
# Worktree mismatch for owner resume
|
||||||
|
lease_wt = lease.get("worktree_path")
|
||||||
|
if (
|
||||||
|
same_owner
|
||||||
|
and lease_wt
|
||||||
|
and caller_worktree
|
||||||
|
and os.path.realpath(str(lease_wt)) != os.path.realpath(str(caller_worktree))
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"safe_next_action": SAFE_UNKNOWN,
|
||||||
|
"reasons": [
|
||||||
|
f"worktree mismatch: lease has {lease_wt!r}, caller has "
|
||||||
|
f"{caller_worktree!r} (fail closed)"
|
||||||
|
],
|
||||||
|
"block": True,
|
||||||
|
"same_owner": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status in ("abandoned", "released"):
|
||||||
|
return {
|
||||||
|
"safe_next_action": SAFE_STALE_PROMPT,
|
||||||
|
"reasons": [f"lease status is {status}; do not adopt blindly"],
|
||||||
|
"block": True,
|
||||||
|
"same_owner": same_owner,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == "expired" or fr.get("expired_by_time"):
|
||||||
|
return {
|
||||||
|
"safe_next_action": SAFE_RECLAIM_EXPIRED,
|
||||||
|
"reasons": ["lease expired; reclaim via expire+assign or adopt reclaim path"],
|
||||||
|
"block": False,
|
||||||
|
"same_owner": same_owner,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status in ("stale_dead_process", "stale_missing_worktree"):
|
||||||
|
if same_owner:
|
||||||
|
return {
|
||||||
|
"safe_next_action": SAFE_OWNER_RESUME,
|
||||||
|
"reasons": [
|
||||||
|
f"caller owns lease with freshness={status}; rebind via "
|
||||||
|
"adopt/owner-resume or abandon with proof"
|
||||||
|
],
|
||||||
|
"block": False,
|
||||||
|
"same_owner": True,
|
||||||
|
"also_allowed": [SAFE_ABANDON_ALLOWED, SAFE_RELEASE_OWNED],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"safe_next_action": SAFE_ABANDON_ALLOWED,
|
||||||
|
"reasons": [
|
||||||
|
f"lease freshness={status}; abandon with required proof then reassign"
|
||||||
|
],
|
||||||
|
"block": False,
|
||||||
|
"same_owner": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if same_owner and status == "active":
|
||||||
|
return {
|
||||||
|
"safe_next_action": SAFE_OWNER_RESUME,
|
||||||
|
"reasons": [
|
||||||
|
"caller owns active lease; resume via heartbeat/assign owner-resume "
|
||||||
|
"or release explicitly"
|
||||||
|
],
|
||||||
|
"block": False,
|
||||||
|
"same_owner": True,
|
||||||
|
"also_allowed": [SAFE_RELEASE_OWNED],
|
||||||
|
}
|
||||||
|
|
||||||
|
if not same_owner and status == "active":
|
||||||
|
return {
|
||||||
|
"safe_next_action": SAFE_WAIT_FOREIGN,
|
||||||
|
"reasons": [
|
||||||
|
f"foreign active lease held by session {owner}; "
|
||||||
|
"do not steal (fail closed)"
|
||||||
|
],
|
||||||
|
"block": True,
|
||||||
|
"same_owner": False,
|
||||||
|
"owner_session_id": owner,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"safe_next_action": SAFE_UNKNOWN,
|
||||||
|
"reasons": [f"unclassified freshness={status}"],
|
||||||
|
"block": True,
|
||||||
|
"same_owner": same_owner,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def non_db_lease_authority_report(
|
||||||
|
*,
|
||||||
|
file_lock_present: bool = False,
|
||||||
|
comment_lease_present: bool = False,
|
||||||
|
db_lease_present: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""File/comment leases alone are not coordination authority (#601 / #600)."""
|
||||||
|
authoritative = bool(db_lease_present)
|
||||||
|
reasons: list[str] = []
|
||||||
|
if file_lock_present and not db_lease_present:
|
||||||
|
reasons.append(
|
||||||
|
"file lock present but control-plane DB lease absent — "
|
||||||
|
"file lock is not authoritative alone"
|
||||||
|
)
|
||||||
|
if comment_lease_present and not db_lease_present:
|
||||||
|
reasons.append(
|
||||||
|
"comment-only lease present but control-plane DB lease absent — "
|
||||||
|
"comment lease is not authoritative alone"
|
||||||
|
)
|
||||||
|
if authoritative:
|
||||||
|
reasons.append("control-plane DB lease is the coordination authority")
|
||||||
|
return {
|
||||||
|
"authoritative_source": AUTHORITATIVE_SOURCE if authoritative else None,
|
||||||
|
"db_lease_present": db_lease_present,
|
||||||
|
"file_lock_present": file_lock_present,
|
||||||
|
"comment_lease_present": comment_lease_present,
|
||||||
|
"safe_next_action": (
|
||||||
|
SAFE_UNKNOWN if authoritative else SAFE_NO_AUTHORITY
|
||||||
|
),
|
||||||
|
"reasons": reasons,
|
||||||
|
"file_lock_only": bool(file_lock_present and not db_lease_present),
|
||||||
|
"comment_lease_only": bool(comment_lease_present and not db_lease_present),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_adopt_provenance(
|
||||||
|
*,
|
||||||
|
adopted_from_session_id: str,
|
||||||
|
adopted_by_session_id: str,
|
||||||
|
work_kind: str,
|
||||||
|
work_number: int,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
worktree_path: str | None,
|
||||||
|
expected_head_sha: str | None,
|
||||||
|
prior_lease_id: str | None,
|
||||||
|
reason: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"adopted_from_session_id": adopted_from_session_id,
|
||||||
|
"adopted_by_session_id": adopted_by_session_id,
|
||||||
|
"work_kind": work_kind,
|
||||||
|
"work_number": int(work_number),
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
"expected_head_sha": expected_head_sha,
|
||||||
|
"prior_lease_id": prior_lease_id,
|
||||||
|
"reason": reason,
|
||||||
|
"recorded_at": cpd._ts(),
|
||||||
|
"source": "lease_lifecycle.adopt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_lease(
|
||||||
|
db: cpd.ControlPlaneDB,
|
||||||
|
lease_id: str,
|
||||||
|
*,
|
||||||
|
caller_session_id: str,
|
||||||
|
caller_worktree: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Full first-class lease workflow state for one lease id."""
|
||||||
|
state = db.get_lease_workflow_state(lease_id)
|
||||||
|
if not state:
|
||||||
|
decision = decide_safe_next_action(
|
||||||
|
lease=None, caller_session_id=caller_session_id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"found": False,
|
||||||
|
"lease_id": lease_id,
|
||||||
|
"lease": None,
|
||||||
|
"freshness": None,
|
||||||
|
**decision,
|
||||||
|
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
lease = state["lease"]
|
||||||
|
freshness = classify_lease_freshness(lease)
|
||||||
|
decision = decide_safe_next_action(
|
||||||
|
lease=lease,
|
||||||
|
caller_session_id=caller_session_id,
|
||||||
|
freshness=freshness,
|
||||||
|
caller_worktree=caller_worktree,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"found": True,
|
||||||
|
"lease_id": lease_id,
|
||||||
|
"lease": lease,
|
||||||
|
"assignment": state.get("assignment"),
|
||||||
|
"work_item": state.get("work_item"),
|
||||||
|
"session": state.get("session"),
|
||||||
|
"freshness": freshness,
|
||||||
|
"provenance": state.get("provenance"),
|
||||||
|
**decision,
|
||||||
|
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_active_leases(
|
||||||
|
db: cpd.ControlPlaneDB,
|
||||||
|
*,
|
||||||
|
remote: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
include_non_active: bool = False,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
statuses = None if include_non_active else (LEASE_STATUS_ACTIVE,)
|
||||||
|
rows = db.list_leases(
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
role=role,
|
||||||
|
statuses=statuses,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
enriched = []
|
||||||
|
for row in rows:
|
||||||
|
fr = classify_lease_freshness(row)
|
||||||
|
enriched.append({**row, "freshness": fr})
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"count": len(enriched),
|
||||||
|
"leases": enriched,
|
||||||
|
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
"include_non_active": include_non_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def adopt_lease(
|
||||||
|
db: cpd.ControlPlaneDB,
|
||||||
|
*,
|
||||||
|
lease_id: str,
|
||||||
|
adopter_session_id: str,
|
||||||
|
role: str,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
owner_pid: int | None = None,
|
||||||
|
operator_authorized: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Sanctioned adopt path with provenance; never silent foreign steal."""
|
||||||
|
state = db.get_lease_workflow_state(lease_id)
|
||||||
|
if not state:
|
||||||
|
raise LeaseLifecycleError(
|
||||||
|
f"unknown lease_id {lease_id}; stale prompt id (fail closed)"
|
||||||
|
)
|
||||||
|
lease = state["lease"]
|
||||||
|
work = state["work_item"]
|
||||||
|
freshness = classify_lease_freshness(lease)
|
||||||
|
owner = str(lease.get("session_id") or "")
|
||||||
|
same_owner = owner == str(adopter_session_id)
|
||||||
|
|
||||||
|
if freshness["freshness"] == "active" and not same_owner:
|
||||||
|
raise LeaseLifecycleError(
|
||||||
|
f"refusing to steal active foreign lease {lease_id} owned by "
|
||||||
|
f"{owner} (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if freshness["freshness"] in ("abandoned", "released"):
|
||||||
|
raise LeaseLifecycleError(
|
||||||
|
f"lease {lease_id} is {freshness['freshness']}; cannot adopt "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expired or stale: require abandon-style safety before ownership transfer
|
||||||
|
# when not same owner; same owner may reclaim.
|
||||||
|
if not same_owner and freshness["freshness"] in (
|
||||||
|
"expired",
|
||||||
|
"stale_dead_process",
|
||||||
|
"stale_missing_worktree",
|
||||||
|
):
|
||||||
|
if not operator_authorized and freshness["freshness"] == "expired":
|
||||||
|
# Deterministic reclaim of expired foreign lease is allowed
|
||||||
|
# without operator flag (sanctioned expire reclaim).
|
||||||
|
pass
|
||||||
|
elif freshness["freshness"] != "expired" and not operator_authorized:
|
||||||
|
# stale active-looking requires abandon proof path
|
||||||
|
raise LeaseLifecycleError(
|
||||||
|
f"lease {lease_id} freshness={freshness['freshness']}; "
|
||||||
|
"use abandon with proof before foreign adopt (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
provenance = build_adopt_provenance(
|
||||||
|
adopted_from_session_id=owner,
|
||||||
|
adopted_by_session_id=adopter_session_id,
|
||||||
|
work_kind=str(work.get("kind")),
|
||||||
|
work_number=int(work.get("number")),
|
||||||
|
remote=str(work.get("remote")),
|
||||||
|
org=str(work.get("org")),
|
||||||
|
repo=str(work.get("repo")),
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
expected_head_sha=expected_head_sha or lease.get("expected_head_sha"),
|
||||||
|
prior_lease_id=lease_id,
|
||||||
|
reason=(
|
||||||
|
"owner-resume-adopt" if same_owner else "sanctioned-reclaim-adopt"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = db.adopt_lease(
|
||||||
|
lease_id=lease_id,
|
||||||
|
adopter_session_id=adopter_session_id,
|
||||||
|
role=role,
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
expected_head_sha=expected_head_sha,
|
||||||
|
owner_pid=owner_pid if owner_pid is not None else os.getpid(),
|
||||||
|
provenance=provenance,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": result.get("outcome"),
|
||||||
|
"same_owner": same_owner,
|
||||||
|
"prior_lease_id": lease_id,
|
||||||
|
"lease": result.get("lease"),
|
||||||
|
"assignment": result.get("assignment"),
|
||||||
|
"provenance": provenance,
|
||||||
|
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
"reasons": result.get("reasons") or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def release_lease(
|
||||||
|
db: cpd.ControlPlaneDB,
|
||||||
|
*,
|
||||||
|
lease_id: str,
|
||||||
|
session_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
proof = db.release_lease_recorded(lease_id=lease_id, session_id=session_id)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": "released",
|
||||||
|
"lease_id": lease_id,
|
||||||
|
"session_id": session_id,
|
||||||
|
"release_proof": proof,
|
||||||
|
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def expire_leases(db: cpd.ControlPlaneDB) -> dict[str, Any]:
|
||||||
|
n = db.expire_stale_leases()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": "expired",
|
||||||
|
"expired_count": int(n),
|
||||||
|
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reclaim_expired_lease(
|
||||||
|
db: cpd.ControlPlaneDB,
|
||||||
|
*,
|
||||||
|
lease_id: str,
|
||||||
|
session_id: str,
|
||||||
|
role: str,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Expire-if-needed then assign to *session_id* for the same work item."""
|
||||||
|
state = db.get_lease_workflow_state(lease_id)
|
||||||
|
if not state:
|
||||||
|
raise LeaseLifecycleError(f"unknown lease_id {lease_id}")
|
||||||
|
lease = state["lease"]
|
||||||
|
work = state["work_item"]
|
||||||
|
freshness = classify_lease_freshness(lease)
|
||||||
|
if freshness["freshness"] == "active":
|
||||||
|
if lease.get("session_id") != session_id:
|
||||||
|
raise LeaseLifecycleError(
|
||||||
|
f"cannot reclaim active foreign lease {lease_id} (fail closed)"
|
||||||
|
)
|
||||||
|
# owner resume
|
||||||
|
return adopt_lease(
|
||||||
|
db,
|
||||||
|
lease_id=lease_id,
|
||||||
|
adopter_session_id=session_id,
|
||||||
|
role=role,
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
expected_head_sha=expected_head_sha,
|
||||||
|
)
|
||||||
|
if freshness["freshness"] not in (
|
||||||
|
"expired",
|
||||||
|
"stale_dead_process",
|
||||||
|
"stale_missing_worktree",
|
||||||
|
"abandoned",
|
||||||
|
"released",
|
||||||
|
):
|
||||||
|
raise LeaseLifecycleError(
|
||||||
|
f"lease {lease_id} freshness={freshness['freshness']} not reclaimable"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure expired marker is applied
|
||||||
|
db.expire_stale_leases()
|
||||||
|
# If still active due to clock skew / non-time stale, force expire this lease
|
||||||
|
refreshed = db.get_lease_workflow_state(lease_id)
|
||||||
|
if refreshed and refreshed["lease"].get("status") == "active":
|
||||||
|
db.force_expire_lease(lease_id, reason="reclaim_expired_lease")
|
||||||
|
|
||||||
|
head = expected_head_sha or work.get("current_head_sha")
|
||||||
|
assigned = db.assign_and_lease(
|
||||||
|
session_id=session_id,
|
||||||
|
role=role,
|
||||||
|
remote=str(work["remote"]),
|
||||||
|
org=str(work["org"]),
|
||||||
|
repo=str(work["repo"]),
|
||||||
|
kind=str(work["kind"]),
|
||||||
|
number=int(work["number"]),
|
||||||
|
expected_head_sha=head,
|
||||||
|
phase="reclaimed",
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
owner_pid=os.getpid(),
|
||||||
|
)
|
||||||
|
if assigned.outcome != "assigned":
|
||||||
|
raise LeaseLifecycleError(
|
||||||
|
f"reclaim assign failed: {assigned.outcome} — {assigned.reason}"
|
||||||
|
)
|
||||||
|
provenance = build_adopt_provenance(
|
||||||
|
adopted_from_session_id=str(lease.get("session_id")),
|
||||||
|
adopted_by_session_id=session_id,
|
||||||
|
work_kind=str(work["kind"]),
|
||||||
|
work_number=int(work["number"]),
|
||||||
|
remote=str(work["remote"]),
|
||||||
|
org=str(work["org"]),
|
||||||
|
repo=str(work["repo"]),
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
expected_head_sha=head,
|
||||||
|
prior_lease_id=lease_id,
|
||||||
|
reason="reclaim_expired",
|
||||||
|
)
|
||||||
|
db.attach_lease_provenance(assigned.lease_id, provenance)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": "reclaimed",
|
||||||
|
"prior_lease_id": lease_id,
|
||||||
|
"assignment": assigned.as_dict(),
|
||||||
|
"provenance": provenance,
|
||||||
|
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def abandon_lease(
|
||||||
|
db: cpd.ControlPlaneDB,
|
||||||
|
*,
|
||||||
|
lease_id: str,
|
||||||
|
requester_session_id: str,
|
||||||
|
proof: AbandonProof,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
state = db.get_lease_workflow_state(lease_id)
|
||||||
|
if not state:
|
||||||
|
raise LeaseLifecycleError(f"unknown lease_id {lease_id}")
|
||||||
|
lease = state["lease"]
|
||||||
|
owner = str(lease.get("session_id") or "")
|
||||||
|
same_owner = owner == str(requester_session_id)
|
||||||
|
|
||||||
|
# Enrich proof with live checks when not provided
|
||||||
|
owner_pid = proof.owner_pid
|
||||||
|
if owner_pid is None:
|
||||||
|
owner_pid = lease.get("owner_pid")
|
||||||
|
wt = proof.worktree_path if proof.worktree_path is not None else lease.get(
|
||||||
|
"worktree_path"
|
||||||
|
)
|
||||||
|
dead = proof.dead_process or (
|
||||||
|
owner_pid is not None and not is_process_alive(owner_pid)
|
||||||
|
)
|
||||||
|
missing_wt = proof.missing_worktree or (
|
||||||
|
bool(wt) and not worktree_exists(str(wt))
|
||||||
|
)
|
||||||
|
enriched = AbandonProof(
|
||||||
|
dead_process=bool(dead),
|
||||||
|
missing_worktree=bool(missing_wt),
|
||||||
|
same_owner=same_owner or proof.same_owner,
|
||||||
|
no_open_pr=proof.no_open_pr,
|
||||||
|
no_live_mutation_risk=proof.no_live_mutation_risk,
|
||||||
|
operator_authorized=proof.operator_authorized,
|
||||||
|
worktree_path=str(wt) if wt else None,
|
||||||
|
owner_pid=int(owner_pid) if owner_pid is not None else None,
|
||||||
|
notes=proof.notes,
|
||||||
|
)
|
||||||
|
if not enriched.is_sufficient():
|
||||||
|
raise LeaseLifecycleError(
|
||||||
|
"abandon proof insufficient: need (dead_process or missing_worktree) "
|
||||||
|
"and no_live_mutation_risk; foreign abandon also needs "
|
||||||
|
"operator_authorized or (dead+missing_worktree+no_open_pr). "
|
||||||
|
f"proof={enriched.as_dict()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Active foreign with live process + present worktree: never abandon without
|
||||||
|
# operator (already covered by is_sufficient).
|
||||||
|
result = db.abandon_lease(
|
||||||
|
lease_id=lease_id,
|
||||||
|
requester_session_id=requester_session_id,
|
||||||
|
proof=enriched.as_dict(),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"outcome": "abandoned",
|
||||||
|
"lease_id": lease_id,
|
||||||
|
"requester_session_id": requester_session_id,
|
||||||
|
"prior_owner_session_id": owner,
|
||||||
|
"abandon_proof": enriched.as_dict(),
|
||||||
|
"audit": result,
|
||||||
|
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||||
|
"file_lock_only": False,
|
||||||
|
"comment_lease_only": False,
|
||||||
|
}
|
||||||
+12
-4
@@ -22,7 +22,7 @@ venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
|
|||||||
if os.path.exists(venv_python) and sys.executable != venv_python:
|
if os.path.exists(venv_python) and sys.executable != venv_python:
|
||||||
os.execv(venv_python, [venv_python] + sys.argv)
|
os.execv(venv_python, [venv_python] + sys.argv)
|
||||||
|
|
||||||
from gitea_auth import get_auth_header, api_request, repo_api_url
|
from gitea_auth import get_auth_header, api_request, api_get_all, repo_api_url
|
||||||
import issue_workflow_labels
|
import issue_workflow_labels
|
||||||
|
|
||||||
HOST = "gitea.dadeschools.net"
|
HOST = "gitea.dadeschools.net"
|
||||||
@@ -82,9 +82,17 @@ def api(method, path, auth, payload=None):
|
|||||||
|
|
||||||
|
|
||||||
def _labels_by_name(auth):
|
def _labels_by_name(auth):
|
||||||
"""Return {label name: id} for the repo's existing labels."""
|
"""Return {label name: id} for the repo's existing labels (all pages, #627)."""
|
||||||
existing = api("GET", "/labels?limit=100", auth) or []
|
existing = api_get_all(f"{BASE_URL}/labels", auth) or []
|
||||||
return {lb["name"]: lb["id"] for lb in existing}
|
name_to_id = {}
|
||||||
|
for lb in existing:
|
||||||
|
if not isinstance(lb, dict):
|
||||||
|
continue
|
||||||
|
name = lb.get("name")
|
||||||
|
lid = lb.get("id")
|
||||||
|
if name and lid is not None and name not in name_to_id:
|
||||||
|
name_to_id[name] = lid
|
||||||
|
return name_to_id
|
||||||
|
|
||||||
|
|
||||||
def create_labels(auth, dry=False):
|
def create_labels(auth, dry=False):
|
||||||
|
|||||||
+5
-5
@@ -17,7 +17,7 @@ if os.path.exists(venv_python) and sys.executable != venv_python:
|
|||||||
|
|
||||||
from gitea_auth import (
|
from gitea_auth import (
|
||||||
get_auth_header, resolve_remote, add_remote_args,
|
get_auth_header, resolve_remote, add_remote_args,
|
||||||
api_request, repo_api_url,
|
api_request, api_get_all, repo_api_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
LABEL_NAME = "status:in-progress"
|
LABEL_NAME = "status:in-progress"
|
||||||
@@ -45,12 +45,12 @@ def main(argv=None):
|
|||||||
base = repo_api_url(host, org, repo)
|
base = repo_api_url(host, org, repo)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Find the label ID
|
# Paginated inventory (#627): Gitea caps single pages at 50.
|
||||||
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
labels = api_get_all(f"{base}/labels", auth) or []
|
||||||
label_id = None
|
label_id = None
|
||||||
for lb in labels:
|
for lb in labels:
|
||||||
if lb["name"] == LABEL_NAME:
|
if lb.get("name") == LABEL_NAME:
|
||||||
label_id = lb["id"]
|
label_id = lb.get("id")
|
||||||
break
|
break
|
||||||
|
|
||||||
if label_id is None:
|
if label_id is None:
|
||||||
|
|||||||
@@ -166,3 +166,128 @@ def format_parity(assessment: dict) -> str:
|
|||||||
if not assessment.get("determinable"):
|
if not assessment.get("determinable"):
|
||||||
return "parity indeterminate (baseline or current HEAD unknown)"
|
return "parity indeterminate (baseline or current HEAD unknown)"
|
||||||
return f"in parity at {_short(assessment.get('current_head'))}"
|
return f"in parity at {_short(assessment.get('current_head'))}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Target-repository parity (#739 F3)
|
||||||
|
#
|
||||||
|
# Everything above measures ONE dimension: the Gitea-Tools server's own
|
||||||
|
# implementation commit, comparing the SHA this process was loaded from against
|
||||||
|
# the SHA now on disk at PROJECT_ROOT. That is deliberate and is left untouched
|
||||||
|
# — it is what proves the in-memory capability gates are current.
|
||||||
|
#
|
||||||
|
# It is not, however, a statement about the repository a cross-repository
|
||||||
|
# namespace actually mutates. The assessment below is a separate, separately
|
||||||
|
# labelled dimension for the configured canonical target repository. It never
|
||||||
|
# feeds the mutation gate and never changes startup_head/current_head.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DEFAULT_TARGET_TRACKING_REF = "refs/remotes/origin/master"
|
||||||
|
|
||||||
|
|
||||||
|
def _git_capture(root: str, *args: str) -> str | None:
|
||||||
|
"""Run a read-only git command in *root*; ``None`` on any failure.
|
||||||
|
|
||||||
|
Deliberately does not honour ``GITEA_TEST_CURRENT_HEAD``: that override
|
||||||
|
exists to pin the *server's* HEAD, and applying it here would make a target
|
||||||
|
repository silently report the server's forced SHA.
|
||||||
|
"""
|
||||||
|
if not root:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", root, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if res.returncode != 0:
|
||||||
|
return None
|
||||||
|
return (res.stdout or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_target_repository_parity(
|
||||||
|
*,
|
||||||
|
canonical_root: str | None,
|
||||||
|
source: str | None,
|
||||||
|
tracking_ref: str = DEFAULT_TARGET_TRACKING_REF,
|
||||||
|
) -> dict:
|
||||||
|
"""Assess the configured cross-repository target checkout.
|
||||||
|
|
||||||
|
Reports the target's canonical root, repository identity, checked-out
|
||||||
|
commit, and last-known remote master commit, plus whether the checkout is
|
||||||
|
behind that ref. No network call is made: the remote side is read from the
|
||||||
|
existing remote-tracking ref, so a target that has never been fetched is
|
||||||
|
reported as indeterminate rather than guessed at.
|
||||||
|
|
||||||
|
An unconfigured namespace is ``configured=False`` and never ``stale`` — the
|
||||||
|
single-repository default has no second dimension to be stale about. A
|
||||||
|
configured root that cannot be read is ``determinable=False`` with reasons.
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"configured": bool(canonical_root),
|
||||||
|
"canonical_repository_root": None,
|
||||||
|
"source": source,
|
||||||
|
"repository_slug": None,
|
||||||
|
"checkout_head": None,
|
||||||
|
"tracking_ref": tracking_ref,
|
||||||
|
"remote_tracking_head": None,
|
||||||
|
"determinable": False,
|
||||||
|
"stale": False,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
if not canonical_root:
|
||||||
|
result["determinable"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["canonical_repository_root"] = canonical_root
|
||||||
|
if not os.path.isdir(canonical_root):
|
||||||
|
result["reasons"].append(
|
||||||
|
f"configured canonical repository root '{canonical_root}' "
|
||||||
|
f"does not exist or is not a directory"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
toplevel = _git_capture(canonical_root, "rev-parse", "--show-toplevel")
|
||||||
|
if not toplevel:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"configured canonical repository root '{canonical_root}' "
|
||||||
|
f"is not a git checkout"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
head = _git_capture(canonical_root, "rev-parse", "HEAD")
|
||||||
|
if not head:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"target repository HEAD could not be read at '{canonical_root}'"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
result["checkout_head"] = head
|
||||||
|
result["determinable"] = True
|
||||||
|
|
||||||
|
remote_url = _git_capture(canonical_root, "remote", "get-url", "origin")
|
||||||
|
if remote_url:
|
||||||
|
# Local import keeps this module dependency-light for its startup role.
|
||||||
|
import remote_repo_guard
|
||||||
|
|
||||||
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(remote_url)
|
||||||
|
if parsed:
|
||||||
|
result["repository_slug"] = f"{parsed[0]}/{parsed[1]}"
|
||||||
|
if not result["repository_slug"]:
|
||||||
|
result["reasons"].append(
|
||||||
|
"target repository identity could not be derived from its git remote"
|
||||||
|
)
|
||||||
|
|
||||||
|
tracking_head = _git_capture(canonical_root, "rev-parse", tracking_ref)
|
||||||
|
if not tracking_head:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"remote-tracking ref '{tracking_ref}' is unknown in the target "
|
||||||
|
f"checkout; target staleness is indeterminate (no fetch is "
|
||||||
|
f"performed by this assessment)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
result["remote_tracking_head"] = tracking_head
|
||||||
|
result["stale"] = tracking_head != head
|
||||||
|
return result
|
||||||
|
|||||||
+43
-15
@@ -19,6 +19,32 @@ print_banner() {
|
|||||||
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
|
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
show_workflow_dashboard_help() {
|
||||||
|
printf '\n--- Workflow dashboard (queue, leases, next safe action) ---\n\n'
|
||||||
|
printf 'Read-only operational view (#605). Does NOT assign work.\n'
|
||||||
|
printf 'Exclusive assignment still requires gitea_allocate_next_work.\n\n'
|
||||||
|
printf 'Canonical MCP tool (any healthy Gitea namespace with gitea.read):\n\n'
|
||||||
|
printf ' gitea_workflow_dashboard(\n'
|
||||||
|
printf ' remote=\"prgs\",\n'
|
||||||
|
printf ' org=\"Scaled-Tech-Consulting\",\n'
|
||||||
|
printf ' repo=\"Gitea-Tools\",\n'
|
||||||
|
printf ' )\n\n'
|
||||||
|
printf 'Returns machine-readable sections:\n'
|
||||||
|
printf ' - open_pr_queue / open_issue_queue\n'
|
||||||
|
printf ' - active_leases_by_role / stale_or_expired_leases\n'
|
||||||
|
printf ' - terminal_review_lock\n'
|
||||||
|
printf ' - blocked_items (never presented as safe)\n'
|
||||||
|
printf ' - review_ready_prs / merge_ready_prs / author_remediation\n'
|
||||||
|
printf ' - discussion_issues / controller_needed\n'
|
||||||
|
printf ' - next_safe_by_role + primary_next_safe_action with exact prompts\n'
|
||||||
|
printf ' - human_summary (copy-friendly multi-line text)\n\n'
|
||||||
|
printf 'Safety:\n'
|
||||||
|
printf ' - Never suggests blocked or terminal-locked items as safe.\n'
|
||||||
|
printf ' - Incomplete inventory fails closed (no safe suggestions).\n'
|
||||||
|
printf ' - This menu entry is documentation only; it does not call Gitea.\n'
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
show_root_checkout_health() {
|
show_root_checkout_health() {
|
||||||
printf '\n--- Project status / root checkout health ---\n\n'
|
printf '\n--- Project status / root checkout health ---\n\n'
|
||||||
printf 'Current directory: %s\n' "$(pwd)"
|
printf 'Current directory: %s\n' "$(pwd)"
|
||||||
@@ -241,25 +267,27 @@ main_menu() {
|
|||||||
while true; do
|
while true; do
|
||||||
print_banner
|
print_banner
|
||||||
printf ' 1) Project status / root checkout health\n'
|
printf ' 1) Project status / root checkout health\n'
|
||||||
printf ' 2) Author workflow prompts\n'
|
printf ' 2) Workflow dashboard (queue, leases, next safe action)\n'
|
||||||
printf ' 3) Reviewer workflow prompts\n'
|
printf ' 3) Author workflow prompts\n'
|
||||||
printf ' 4) Merger workflow prompts\n'
|
printf ' 4) Reviewer workflow prompts\n'
|
||||||
printf ' 5) Reconciler workflow prompts\n'
|
printf ' 5) Merger workflow prompts\n'
|
||||||
printf ' 6) Onboarding new project to this MCP workflow\n'
|
printf ' 6) Reconciler workflow prompts\n'
|
||||||
printf ' 7) Proxmox deployment menu placeholder\n'
|
printf ' 7) Onboarding new project to this MCP workflow\n'
|
||||||
printf ' 8) Create Proxmox LXC placeholder\n'
|
printf ' 8) Proxmox deployment menu placeholder\n'
|
||||||
printf ' 9) Run tests\n'
|
printf ' 9) Create Proxmox LXC placeholder\n'
|
||||||
|
printf ' t) Run tests\n'
|
||||||
printf ' 0) Exit\n'
|
printf ' 0) Exit\n'
|
||||||
read -r -p 'Choice: ' choice
|
read -r -p 'Choice: ' choice
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
1) show_root_checkout_health ;;
|
1) show_root_checkout_health ;;
|
||||||
2) show_author_prompts ;;
|
2) show_workflow_dashboard_help ;;
|
||||||
3) show_reviewer_prompts ;;
|
3) show_author_prompts ;;
|
||||||
4) show_merger_prompts ;;
|
4) show_reviewer_prompts ;;
|
||||||
5) show_reconciler_prompts ;;
|
5) show_merger_prompts ;;
|
||||||
6) show_onboarding_prompt ;;
|
6) show_reconciler_prompts ;;
|
||||||
7|8) show_proxmox_placeholder ;;
|
7) show_onboarding_prompt ;;
|
||||||
9) run_tests ;;
|
8|9) show_proxmox_placeholder ;;
|
||||||
|
t|T|tests) run_tests ;;
|
||||||
0) printf 'Goodbye.\n'; exit 0 ;;
|
0) printf 'Goodbye.\n'; exit 0 ;;
|
||||||
*) printf 'Invalid choice.\n'; pause ;;
|
*) printf 'Invalid choice.\n'; pause ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+464
-31
@@ -1,62 +1,439 @@
|
|||||||
"""Sanctioned MCP daemon guards for imports and credential access (#558).
|
"""Sanctioned MCP daemon guards for imports and credential access (#558 / #695).
|
||||||
|
|
||||||
Direct ``import gitea_mcp_server`` / ``import gitea_auth`` from a shell, plus
|
Direct ``import gitea_mcp_server`` from a shell bypasses native MCP transport.
|
||||||
raw keychain dumps, bypass preflight purity and role gates. Mutation helpers
|
#558 introduced a daemon marker; #695 hardens it so:
|
||||||
and keychain fallbacks therefore require an explicit sanctioned runtime.
|
|
||||||
|
|
||||||
Sanctioned contexts (any one):
|
- Environment variables alone cannot reconstruct a native session
|
||||||
- ``GITEA_MCP_SANCTIONED_DAEMON=1`` (set by the official MCP entrypoint)
|
(``GITEA_MCP_SANCTIONED_DAEMON=1`` / ``GITEA_ALLOW_DIRECT_MCP_IMPORT=1`` are
|
||||||
- pytest (``PYTEST_CURRENT_TEST`` present)
|
insufficient for mutation gates).
|
||||||
- explicit operator opt-in ``GITEA_ALLOW_DIRECT_MCP_IMPORT=1`` (tests/tools only)
|
- A process-local runtime record is established only by the resolved canonical
|
||||||
|
entrypoint path (not basename) **and** the actual native MCP transport
|
||||||
|
lifecycle (``bind_native_mcp_transport`` before ``mcp.run``). Merely
|
||||||
|
importing or launching the entrypoint offline does not grant mutation
|
||||||
|
authority.
|
||||||
|
- Public caller-controlled flags (including any former
|
||||||
|
``allow_test_bootstrap``) never establish trusted mutation provenance.
|
||||||
|
- Offline scripts that import internals fail closed on mutations.
|
||||||
|
- Pytest remains allowed for hermetic unit tests via ``is_pytest_runtime()``.
|
||||||
|
A separate test-only seam may establish a **test-mode** native record for
|
||||||
|
unit tests of transport gates; that record cannot authorize production
|
||||||
|
Gitea mutation endpoints.
|
||||||
|
|
||||||
Credential keychain fill additionally allows:
|
Manual deletion of session-state files is never a recovery path.
|
||||||
- ``GITEA_ALLOW_KEYCHAIN_CLI=1`` for operator-only non-MCP scripts that must
|
|
||||||
use git-credential (never the default for LLM shells).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
SANCTIONED_DAEMON_ENV = "GITEA_MCP_SANCTIONED_DAEMON"
|
SANCTIONED_DAEMON_ENV = "GITEA_MCP_SANCTIONED_DAEMON"
|
||||||
ALLOW_DIRECT_IMPORT_ENV = "GITEA_ALLOW_DIRECT_MCP_IMPORT"
|
ALLOW_DIRECT_IMPORT_ENV = "GITEA_ALLOW_DIRECT_MCP_IMPORT"
|
||||||
ALLOW_KEYCHAIN_CLI_ENV = "GITEA_ALLOW_KEYCHAIN_CLI"
|
ALLOW_KEYCHAIN_CLI_ENV = "GITEA_ALLOW_KEYCHAIN_CLI"
|
||||||
|
# Test-only: force provenance failure even under pytest (#695 regressions).
|
||||||
|
FORCE_PROVENANCE_FAIL_ENV = "GITEA_TEST_FORCE_UNSANCTIONED"
|
||||||
|
|
||||||
|
# Process-local native runtime (never persisted, never read from env alone).
|
||||||
|
_NATIVE_RUNTIME: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
# Production transport identifiers accepted by bind_native_mcp_transport.
|
||||||
|
_PRODUCTION_TRANSPORTS = frozenset({"stdio"})
|
||||||
|
_RUNTIME_MODE_PRODUCTION = "production"
|
||||||
|
_RUNTIME_MODE_TEST = "test"
|
||||||
|
_PHASE_ENTRYPOINT_CLAIMED = "entrypoint_claimed"
|
||||||
|
_PHASE_TRANSPORT_BOUND = "transport_bound"
|
||||||
|
|
||||||
|
# Default session-state root (mirrors mcp_session_state; kept local to avoid
|
||||||
|
# import cycles). Used only to pin authority at transport bind (#695 AC2).
|
||||||
|
_DEFAULT_SESSION_STATE_DIR = os.path.expanduser("~/.cache/gitea-tools/session-state")
|
||||||
|
SESSION_STATE_DIR_ENV = "GITEA_MCP_SESSION_STATE_DIR"
|
||||||
|
|
||||||
|
|
||||||
class UnsanctionedRuntimeError(RuntimeError):
|
class UnsanctionedRuntimeError(RuntimeError):
|
||||||
"""Raised when mutation/credential code runs outside a sanctioned MCP daemon."""
|
"""Raised when mutation/credential code runs outside a native MCP daemon."""
|
||||||
|
|
||||||
|
|
||||||
def is_pytest_runtime() -> bool:
|
def is_pytest_runtime() -> bool:
|
||||||
|
if (os.environ.get(FORCE_PROVENANCE_FAIL_ENV) or "").strip() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}:
|
||||||
|
return False
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if "pytest" in sys.modules:
|
||||||
|
return True
|
||||||
return bool((os.environ.get("PYTEST_CURRENT_TEST") or "").strip())
|
return bool((os.environ.get("PYTEST_CURRENT_TEST") or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _package_root() -> Path:
|
||||||
|
"""Directory that contains the canonical MCP entrypoint modules."""
|
||||||
|
return Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_entrypoint_paths() -> frozenset[str]:
|
||||||
|
"""Resolved absolute paths of official entrypoints (not basenames)."""
|
||||||
|
root = _package_root()
|
||||||
|
return frozenset(
|
||||||
|
{
|
||||||
|
str((root / "mcp_server.py").resolve()),
|
||||||
|
str((root / "gitea_mcp_server.py").resolve()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(path: str | None) -> str | None:
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(Path(path).resolve())
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _caller_official_entrypoint_path() -> str | None:
|
||||||
|
"""Return the resolved canonical entrypoint path in the call stack, or None.
|
||||||
|
|
||||||
|
Basename-only matches (e.g. an attacker file named ``mcp_server.py``
|
||||||
|
elsewhere) are rejected. The path must equal one of
|
||||||
|
:func:`canonical_entrypoint_paths`.
|
||||||
|
"""
|
||||||
|
canonical = canonical_entrypoint_paths()
|
||||||
|
for frame in inspect.stack()[1:20]:
|
||||||
|
resolved = _resolve_path(frame.filename)
|
||||||
|
if resolved and resolved in canonical:
|
||||||
|
return resolved
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _caller_is_official_entrypoint() -> bool:
|
||||||
|
"""True when invoked from a resolved canonical entrypoint path (#695)."""
|
||||||
|
return _caller_official_entrypoint_path() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _new_runtime_token() -> tuple[str, str]:
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
fingerprint = hashlib.sha256(token.encode()).hexdigest()[:16]
|
||||||
|
return token, fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
def mark_sanctioned_daemon() -> dict[str, Any]:
|
||||||
|
"""Claim the official entrypoint for this process (#695).
|
||||||
|
|
||||||
|
This alone does **not** authorize mutations. Callers must subsequently
|
||||||
|
bind the native MCP transport via :func:`bind_native_mcp_transport`.
|
||||||
|
|
||||||
|
Only a stack frame whose **resolved absolute path** is the canonical
|
||||||
|
``mcp_server.py`` or ``gitea_mcp_server.py`` next to this module may
|
||||||
|
claim the entrypoint. Basename spoofing is rejected.
|
||||||
|
|
||||||
|
There is no public ``allow_test_bootstrap`` argument: caller-controlled
|
||||||
|
flags must never establish trusted mutation provenance. Hermetic tests
|
||||||
|
use :func:`install_test_native_runtime` (pytest-only, test mode).
|
||||||
|
"""
|
||||||
|
global _NATIVE_RUNTIME
|
||||||
|
if is_pytest_runtime():
|
||||||
|
# Under pytest, production mark is a no-op for transport authority.
|
||||||
|
# Tests that need a native-transport record use install_test_native_runtime.
|
||||||
|
return native_runtime_status()
|
||||||
|
|
||||||
|
entrypoint_path = _caller_official_entrypoint_path()
|
||||||
|
if entrypoint_path is None:
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
"mark_sanctioned_daemon rejected: not called from the resolved "
|
||||||
|
"canonical MCP entrypoint path (#695). Basename-only names "
|
||||||
|
"(e.g. a renamed runner called mcp_server.py) are insufficient. "
|
||||||
|
"Offline import / standalone scripts cannot reconstruct native "
|
||||||
|
"transport. Stop after native MCP failure; do not run offline "
|
||||||
|
"mutation helpers."
|
||||||
|
)
|
||||||
|
|
||||||
|
token, fingerprint = _new_runtime_token()
|
||||||
|
_NATIVE_RUNTIME = {
|
||||||
|
"token": token,
|
||||||
|
"token_fingerprint": fingerprint,
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"started_at": time.time(),
|
||||||
|
"entrypoint": "mcp_server",
|
||||||
|
"entrypoint_path": entrypoint_path,
|
||||||
|
"phase": _PHASE_ENTRYPOINT_CLAIMED,
|
||||||
|
"transport": None,
|
||||||
|
"mode": _RUNTIME_MODE_PRODUCTION,
|
||||||
|
}
|
||||||
|
# Legacy signal for older probes; alone does not authorize mutations.
|
||||||
|
os.environ[SANCTIONED_DAEMON_ENV] = "1"
|
||||||
|
return native_runtime_status()
|
||||||
|
|
||||||
|
|
||||||
|
def bind_native_mcp_transport(*, transport: str) -> dict[str, Any]:
|
||||||
|
"""Bind the live native MCP transport lifecycle (#695).
|
||||||
|
|
||||||
|
Must be called from the resolved canonical entrypoint immediately before
|
||||||
|
the real MCP server transport loop (e.g. ``mcp.run(transport=\"stdio\")``).
|
||||||
|
Requires a prior successful :func:`mark_sanctioned_daemon` claim in this
|
||||||
|
process. Import-only or offline launch without this bind leaves
|
||||||
|
:func:`is_native_mcp_transport` false.
|
||||||
|
"""
|
||||||
|
global _NATIVE_RUNTIME
|
||||||
|
transport_name = (transport or "").strip().lower()
|
||||||
|
if transport_name not in _PRODUCTION_TRANSPORTS:
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
f"bind_native_mcp_transport rejected: transport {transport!r} is "
|
||||||
|
f"not a production MCP transport (#695). Allowed: "
|
||||||
|
f"{sorted(_PRODUCTION_TRANSPORTS)}."
|
||||||
|
)
|
||||||
|
|
||||||
|
entrypoint_path = _caller_official_entrypoint_path()
|
||||||
|
if entrypoint_path is None:
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
"bind_native_mcp_transport rejected: not called from the resolved "
|
||||||
|
"canonical MCP entrypoint path (#695)."
|
||||||
|
)
|
||||||
|
|
||||||
|
if _NATIVE_RUNTIME is None or int(_NATIVE_RUNTIME.get("pid") or -1) != os.getpid():
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
"bind_native_mcp_transport rejected: no entrypoint claim in this "
|
||||||
|
"process (#695). Call mark_sanctioned_daemon() from the official "
|
||||||
|
"entrypoint first."
|
||||||
|
)
|
||||||
|
|
||||||
|
if _NATIVE_RUNTIME.get("mode") != _RUNTIME_MODE_PRODUCTION:
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
"bind_native_mcp_transport rejected: runtime mode is not "
|
||||||
|
"production (#695)."
|
||||||
|
)
|
||||||
|
|
||||||
|
claimed = (_NATIVE_RUNTIME.get("entrypoint_path") or "").strip()
|
||||||
|
if claimed and claimed != entrypoint_path:
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
"bind_native_mcp_transport rejected: entrypoint path mismatch "
|
||||||
|
"between mark and bind (#695)."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pin session-state root for this server lifetime (#695 AC2 / PR #701).
|
||||||
|
# Changing GITEA_MCP_SESSION_STATE_DIR after bind must not manufacture a
|
||||||
|
# second authority domain for decision locks / workflow proofs.
|
||||||
|
raw_state = (os.environ.get(SESSION_STATE_DIR_ENV) or "").strip()
|
||||||
|
if not raw_state:
|
||||||
|
raw_state = _DEFAULT_SESSION_STATE_DIR
|
||||||
|
try:
|
||||||
|
pinned_state = str(Path(raw_state).resolve())
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
pinned_state = raw_state
|
||||||
|
|
||||||
|
_NATIVE_RUNTIME["phase"] = _PHASE_TRANSPORT_BOUND
|
||||||
|
_NATIVE_RUNTIME["transport"] = transport_name
|
||||||
|
_NATIVE_RUNTIME["entrypoint_path"] = entrypoint_path
|
||||||
|
_NATIVE_RUNTIME["bound_at"] = time.time()
|
||||||
|
_NATIVE_RUNTIME["session_state_dir"] = pinned_state
|
||||||
|
os.environ[SANCTIONED_DAEMON_ENV] = "1"
|
||||||
|
return native_runtime_status()
|
||||||
|
|
||||||
|
|
||||||
|
def install_test_native_runtime() -> dict[str, Any]:
|
||||||
|
"""Pytest-only seam for hermetic native-transport unit tests (#695).
|
||||||
|
|
||||||
|
Establishes a **test-mode** process-local record so unit tests can exercise
|
||||||
|
gates that require ``is_native_mcp_transport()``. This record:
|
||||||
|
|
||||||
|
- is rejected outside pytest (including a fresh offline interpreter);
|
||||||
|
- never uses production mode;
|
||||||
|
- cannot authorize production Gitea mutation endpoints
|
||||||
|
(:func:`assert_production_mutation_runtime` / production path of
|
||||||
|
:func:`assert_sanctioned_mutation_runtime` when not under pytest).
|
||||||
|
|
||||||
|
There is no public caller-controlled flag that forges production native
|
||||||
|
transport.
|
||||||
|
"""
|
||||||
|
global _NATIVE_RUNTIME
|
||||||
|
if not is_pytest_runtime():
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
"install_test_native_runtime rejected: test-mode native runtime "
|
||||||
|
"is only available under pytest (#695). allow_test_bootstrap and "
|
||||||
|
"similar caller-controlled flags do not exist and cannot authorize "
|
||||||
|
"a fresh offline interpreter."
|
||||||
|
)
|
||||||
|
token, fingerprint = _new_runtime_token()
|
||||||
|
_NATIVE_RUNTIME = {
|
||||||
|
"token": token,
|
||||||
|
"token_fingerprint": fingerprint,
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"started_at": time.time(),
|
||||||
|
"entrypoint": "test_bootstrap",
|
||||||
|
"entrypoint_path": None,
|
||||||
|
"phase": _PHASE_TRANSPORT_BOUND,
|
||||||
|
"transport": "test",
|
||||||
|
"mode": _RUNTIME_MODE_TEST,
|
||||||
|
"bound_at": time.time(),
|
||||||
|
}
|
||||||
|
return native_runtime_status()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_native_runtime_for_tests() -> None:
|
||||||
|
"""Test helper: drop native runtime (does not clear env)."""
|
||||||
|
global _NATIVE_RUNTIME
|
||||||
|
_NATIVE_RUNTIME = None
|
||||||
|
|
||||||
|
|
||||||
|
def pinned_session_state_dir() -> str | None:
|
||||||
|
"""Session-state root pinned for this production transport lifetime (#695 AC2).
|
||||||
|
|
||||||
|
When production native transport is bound, durable session proofs must use
|
||||||
|
this directory only. Env overrides of ``GITEA_MCP_SESSION_STATE_DIR`` after
|
||||||
|
bind are ignored so redirected dirs (e.g. ``.mcp_session_701``) cannot
|
||||||
|
manufacture independent decision-lock authority (PR #701 recurrence).
|
||||||
|
"""
|
||||||
|
if not is_production_native_mcp_transport():
|
||||||
|
return None
|
||||||
|
pinned = (_NATIVE_RUNTIME or {}).get("session_state_dir")
|
||||||
|
text = (str(pinned) if pinned is not None else "").strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def direct_import_env_enabled() -> bool:
|
||||||
|
"""True when the legacy direct-import opt-in env is set (never authorizes)."""
|
||||||
|
return (os.environ.get(ALLOW_DIRECT_IMPORT_ENV) or "").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_direct_import_bypass(context: str = "mutation") -> None:
|
||||||
|
"""Fail closed when GITEA_ALLOW_DIRECT_MCP_IMPORT is used for mutations (#695 AC1).
|
||||||
|
|
||||||
|
The env flag is never a sanctioned recovery path for LLM/agent sessions.
|
||||||
|
Under pytest hermetic tests this is a no-op so unit tests can set the flag
|
||||||
|
to prove it does not grant authority.
|
||||||
|
"""
|
||||||
|
if is_pytest_runtime():
|
||||||
|
return
|
||||||
|
if not direct_import_env_enabled():
|
||||||
|
return
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
f"{ALLOW_DIRECT_IMPORT_ENV} does not authorize {context} (#695 AC1). "
|
||||||
|
"Direct import of gitea_mcp_server mutation tools is forbidden. "
|
||||||
|
"Stop after native MCP failure; reconnect the official MCP daemon. "
|
||||||
|
"Do not set direct-import flags, offline runners, or redirected "
|
||||||
|
f"{SESSION_STATE_DIR_ENV} directories to reconstruct gates."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_native_mcp_transport() -> bool:
|
||||||
|
"""True when this process holds a transport-bound native runtime (#695)."""
|
||||||
|
if (os.environ.get(FORCE_PROVENANCE_FAIL_ENV) or "").strip() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}:
|
||||||
|
return False
|
||||||
|
if _NATIVE_RUNTIME is None:
|
||||||
|
return False
|
||||||
|
if int(_NATIVE_RUNTIME.get("pid") or -1) != os.getpid():
|
||||||
|
return False
|
||||||
|
if not (_NATIVE_RUNTIME.get("token") or "").strip():
|
||||||
|
return False
|
||||||
|
if _NATIVE_RUNTIME.get("phase") != _PHASE_TRANSPORT_BOUND:
|
||||||
|
return False
|
||||||
|
if not (_NATIVE_RUNTIME.get("transport") or "").strip():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_production_native_mcp_transport() -> bool:
|
||||||
|
"""True only for production-mode, transport-bound native runtime."""
|
||||||
|
if not is_native_mcp_transport():
|
||||||
|
return False
|
||||||
|
return (_NATIVE_RUNTIME or {}).get("mode") == _RUNTIME_MODE_PRODUCTION
|
||||||
|
|
||||||
|
|
||||||
def is_sanctioned_mcp_daemon() -> bool:
|
def is_sanctioned_mcp_daemon() -> bool:
|
||||||
if (os.environ.get(SANCTIONED_DAEMON_ENV) or "").strip() in {"1", "true", "yes"}:
|
"""Backward-compatible name; #695 requires native transport, not env alone."""
|
||||||
return True
|
if is_production_native_mcp_transport():
|
||||||
if (os.environ.get(ALLOW_DIRECT_IMPORT_ENV) or "").strip() in {"1", "true", "yes"}:
|
|
||||||
return True
|
return True
|
||||||
if is_pytest_runtime():
|
if is_pytest_runtime():
|
||||||
return True
|
return True
|
||||||
|
# Explicit direct-import override is for non-LLM operator/test tools only.
|
||||||
|
# It is deliberately ignored when a native runtime is expected for mutations
|
||||||
|
# under LLM sessions (tests use pytest path). Env alone never grants native.
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def mark_sanctioned_daemon() -> None:
|
def assert_production_mutation_runtime(context: str = "mutation") -> None:
|
||||||
"""Call from the official MCP server entrypoint before serving tools."""
|
"""Fail closed unless production native MCP transport is bound (#695).
|
||||||
os.environ[SANCTIONED_DAEMON_ENV] = "1"
|
|
||||||
|
Test-mode bootstrap records and pytest-only hermetic allowances do **not**
|
||||||
|
satisfy this gate. Use for production Gitea mutation endpoints that must
|
||||||
|
never be reachable via test bootstrap.
|
||||||
|
"""
|
||||||
|
if is_production_native_mcp_transport():
|
||||||
|
return
|
||||||
|
mode = (_NATIVE_RUNTIME or {}).get("mode")
|
||||||
|
if mode == _RUNTIME_MODE_TEST:
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
f"Test-mode native runtime cannot authorize production {context} "
|
||||||
|
"(#695). install_test_native_runtime / former allow_test_bootstrap "
|
||||||
|
"must never reach real Gitea mutation endpoints."
|
||||||
|
)
|
||||||
|
assert_sanctioned_mutation_runtime(context)
|
||||||
|
|
||||||
|
|
||||||
def assert_sanctioned_mutation_runtime(context: str = "mutation") -> None:
|
def assert_sanctioned_mutation_runtime(context: str = "mutation") -> None:
|
||||||
"""Fail closed when server mutation code is used outside the MCP daemon."""
|
"""Fail closed when mutation code runs outside native MCP transport (#695).
|
||||||
if is_sanctioned_mcp_daemon():
|
|
||||||
|
Under pytest, hermetic unit tests are allowed (profile/permission tests).
|
||||||
|
Outside pytest, requires production-mode transport-bound native runtime.
|
||||||
|
Test-mode records do not authorize non-pytest production mutations.
|
||||||
|
``GITEA_ALLOW_DIRECT_MCP_IMPORT`` never authorizes mutations (#695 AC1).
|
||||||
|
"""
|
||||||
|
if is_pytest_runtime():
|
||||||
return
|
return
|
||||||
|
# AC1: direct-import env is never a mutation recovery path (PR #701).
|
||||||
|
assert_no_direct_import_bypass(context)
|
||||||
|
if is_production_native_mcp_transport():
|
||||||
|
return
|
||||||
|
mode = (_NATIVE_RUNTIME or {}).get("mode")
|
||||||
|
if mode == _RUNTIME_MODE_TEST:
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
f"Test-mode native runtime cannot authorize production {context} "
|
||||||
|
"(#695). Test bootstrap cannot reach production mutation endpoints."
|
||||||
|
)
|
||||||
|
env_spoof = (os.environ.get(SANCTIONED_DAEMON_ENV) or "").strip() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
extra = ""
|
||||||
|
if env_spoof:
|
||||||
|
extra = (
|
||||||
|
f" Note: {SANCTIONED_DAEMON_ENV} alone is not sufficient (#695); "
|
||||||
|
"native transport requires the official MCP entrypoint and a live "
|
||||||
|
"transport bind."
|
||||||
|
)
|
||||||
|
phase = (_NATIVE_RUNTIME or {}).get("phase")
|
||||||
|
if phase == _PHASE_ENTRYPOINT_CLAIMED:
|
||||||
|
extra = (
|
||||||
|
(extra + " ") if extra else " "
|
||||||
|
) + (
|
||||||
|
"Entrypoint was claimed but native MCP transport was never bound "
|
||||||
|
"(#695); offline launch/import of the real entrypoint does not "
|
||||||
|
"grant mutation authority."
|
||||||
|
)
|
||||||
raise UnsanctionedRuntimeError(
|
raise UnsanctionedRuntimeError(
|
||||||
f"Unsanctioned runtime blocked {context} (#558). "
|
f"Unsanctioned / non-native runtime blocked {context} (#695). "
|
||||||
"Do not import gitea_mcp_server / call mutation helpers from a raw "
|
"Do not import gitea_mcp_server or call mutation helpers from a raw "
|
||||||
"shell or ad-hoc script. Use the official MCP daemon entrypoint "
|
"shell, offline runner, or ad-hoc script after native MCP failure. "
|
||||||
f"(sets {SANCTIONED_DAEMON_ENV}=1), or run under pytest. "
|
"Stop and reconnect the official MCP daemon (mcp_server.py) over "
|
||||||
f"Operator-only override: {ALLOW_DIRECT_IMPORT_ENV}=1 (not for LLM sessions)."
|
"native transport. "
|
||||||
|
f"Do not set {ALLOW_DIRECT_IMPORT_ENV}, override "
|
||||||
|
f"{SESSION_STATE_DIR_ENV}, or use raw token env vars in LLM "
|
||||||
|
f"sessions.{extra}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -64,21 +441,77 @@ def assert_keychain_access_allowed() -> None:
|
|||||||
"""Fail closed for git-credential keychain fill outside sanctioned contexts."""
|
"""Fail closed for git-credential keychain fill outside sanctioned contexts."""
|
||||||
if is_sanctioned_mcp_daemon():
|
if is_sanctioned_mcp_daemon():
|
||||||
return
|
return
|
||||||
|
# Operator-only keychain CLI remains available outside LLM mutation path.
|
||||||
if (os.environ.get(ALLOW_KEYCHAIN_CLI_ENV) or "").strip() in {"1", "true", "yes"}:
|
if (os.environ.get(ALLOW_KEYCHAIN_CLI_ENV) or "").strip() in {"1", "true", "yes"}:
|
||||||
return
|
if not is_pytest_runtime():
|
||||||
|
# Still block pure env spoof of SANCTIONED_DAEMON for keychain when
|
||||||
|
# FORCE is set for tests.
|
||||||
|
if (os.environ.get(FORCE_PROVENANCE_FAIL_ENV) or "").strip() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return
|
||||||
raise UnsanctionedRuntimeError(
|
raise UnsanctionedRuntimeError(
|
||||||
"Unsanctioned keychain/credential fill blocked (#558). "
|
"Unsanctioned keychain/credential fill blocked (#558/#695). "
|
||||||
"Token extraction via git-credential is only allowed inside the "
|
"Token extraction via git-credential is only allowed inside the "
|
||||||
f"official MCP daemon ({SANCTIONED_DAEMON_ENV}=1), pytest, or with "
|
f"official native MCP daemon or with explicit operator opt-in "
|
||||||
f"explicit operator opt-in {ALLOW_KEYCHAIN_CLI_ENV}=1."
|
f"{ALLOW_KEYCHAIN_CLI_ENV}=1 (never for offline mutation runners)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def runtime_status() -> dict[str, Any]:
|
def native_runtime_status() -> dict[str, Any]:
|
||||||
|
"""LLM-safe native runtime status (no raw token)."""
|
||||||
|
rt = _NATIVE_RUNTIME or {}
|
||||||
return {
|
return {
|
||||||
"sanctioned_daemon": is_sanctioned_mcp_daemon(),
|
"native_mcp_transport": is_native_mcp_transport(),
|
||||||
|
"production_native_mcp_transport": is_production_native_mcp_transport(),
|
||||||
"pytest": is_pytest_runtime(),
|
"pytest": is_pytest_runtime(),
|
||||||
|
"pid": rt.get("pid"),
|
||||||
|
"token_fingerprint": rt.get("token_fingerprint"),
|
||||||
|
"started_at": rt.get("started_at"),
|
||||||
|
"entrypoint": rt.get("entrypoint"),
|
||||||
|
"entrypoint_path": rt.get("entrypoint_path"),
|
||||||
|
"phase": rt.get("phase"),
|
||||||
|
"transport": rt.get("transport"),
|
||||||
|
"mode": rt.get("mode"),
|
||||||
|
"session_state_dir": pinned_session_state_dir() or rt.get("session_state_dir"),
|
||||||
|
"session_state_dir_pinned": pinned_session_state_dir() is not None,
|
||||||
|
"direct_import_env_set": direct_import_env_enabled(),
|
||||||
|
"env_sanctioned_alone_insufficient": True,
|
||||||
"sanctioned_env": SANCTIONED_DAEMON_ENV,
|
"sanctioned_env": SANCTIONED_DAEMON_ENV,
|
||||||
"allow_direct_import_env": ALLOW_DIRECT_IMPORT_ENV,
|
"allow_direct_import_env": ALLOW_DIRECT_IMPORT_ENV,
|
||||||
|
"session_state_dir_env": SESSION_STATE_DIR_ENV,
|
||||||
"allow_keychain_cli_env": ALLOW_KEYCHAIN_CLI_ENV,
|
"allow_keychain_cli_env": ALLOW_KEYCHAIN_CLI_ENV,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_status() -> dict[str, Any]:
|
||||||
|
"""Backward-compatible status payload."""
|
||||||
|
status = native_runtime_status()
|
||||||
|
status["sanctioned_daemon"] = is_sanctioned_mcp_daemon()
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_provenance_fields() -> dict[str, Any]:
|
||||||
|
"""Fields to attach to live mutation / review audit records (#695 AC6)."""
|
||||||
|
st = native_runtime_status()
|
||||||
|
transport = "native_mcp" if st["native_mcp_transport"] else "untrusted"
|
||||||
|
if st.get("mode") == _RUNTIME_MODE_TEST and st["native_mcp_transport"]:
|
||||||
|
transport = "test_native_mcp"
|
||||||
|
return {
|
||||||
|
"transport": transport,
|
||||||
|
"native_mcp_transport": bool(st["native_mcp_transport"]),
|
||||||
|
"production_native_mcp_transport": bool(
|
||||||
|
st.get("production_native_mcp_transport")
|
||||||
|
),
|
||||||
|
"native_runtime_pid": st.get("pid"),
|
||||||
|
"native_token_fingerprint": st.get("token_fingerprint"),
|
||||||
|
"entrypoint": st.get("entrypoint"),
|
||||||
|
"phase": st.get("phase"),
|
||||||
|
"mode": st.get("mode"),
|
||||||
|
"session_state_dir": st.get("session_state_dir"),
|
||||||
|
"session_state_dir_pinned": bool(st.get("session_state_dir_pinned")),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Assess live MCP namespace health without trusting static registration.
|
||||||
|
|
||||||
|
The IDE/client namespace can fail with EOF even when this Python process still
|
||||||
|
registers the Gitea tools with FastMCP. These helpers keep that distinction
|
||||||
|
explicit so reviewer/merger flows can fail closed on the live path.
|
||||||
|
|
||||||
|
Probe sources
|
||||||
|
-------------
|
||||||
|
* ``client_namespace`` — evidence from the IDE-managed MCP client path (the
|
||||||
|
only source that can prove the workflow namespace is healthy).
|
||||||
|
* ``offline_spawn`` — a separate ``subprocess.Popen`` JSON-RPC handshake
|
||||||
|
(e.g. ``test_mcp_conn.py``). Useful offline, but **never** proves the
|
||||||
|
IDE-managed namespace is callable.
|
||||||
|
* ``unknown`` — legacy/unspecified; treated as not IDE-proven.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED_NAMESPACE_TOOLS = {
|
||||||
|
"gitea-author": "gitea_whoami",
|
||||||
|
"gitea-reviewer": "gitea_whoami",
|
||||||
|
"gitea-merger": "gitea_whoami",
|
||||||
|
"gitea-tools": "gitea_list_profiles",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_NAMESPACES = tuple(REQUIRED_NAMESPACE_TOOLS)
|
||||||
|
|
||||||
|
# Namespaces that must be healthy for a given mutation task.
|
||||||
|
TASK_REQUIRED_NAMESPACES = {
|
||||||
|
"review_pr": "gitea-reviewer",
|
||||||
|
"submit_review": "gitea-reviewer",
|
||||||
|
"merge_pr": "gitea-merger",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROBE_SOURCE_CLIENT = "client_namespace"
|
||||||
|
PROBE_SOURCE_OFFLINE = "offline_spawn"
|
||||||
|
PROBE_SOURCE_UNKNOWN = "unknown"
|
||||||
|
VALID_PROBE_SOURCES = frozenset(
|
||||||
|
{PROBE_SOURCE_CLIENT, PROBE_SOURCE_OFFLINE, PROBE_SOURCE_UNKNOWN}
|
||||||
|
)
|
||||||
|
|
||||||
|
EOF_PATTERNS = (
|
||||||
|
"client is closing: eof",
|
||||||
|
"transport closed",
|
||||||
|
"connection closed",
|
||||||
|
"broken pipe",
|
||||||
|
"end of file",
|
||||||
|
"eof",
|
||||||
|
)
|
||||||
|
|
||||||
|
SAFE_ENV_KEYS = (
|
||||||
|
"GITEA_MCP_PROFILE",
|
||||||
|
"GITEA_PROFILE_NAME",
|
||||||
|
"GITEA_SERVICE",
|
||||||
|
"GITEA_EXECUTION_ROLE",
|
||||||
|
"GITEA_MCP_CONFIG",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list[str] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [str(v) for v in value]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_eof(text: str | None) -> bool:
|
||||||
|
lowered = (text or "").lower()
|
||||||
|
return any(pattern in lowered for pattern in EOF_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_probe_source(probe_source: str | None) -> str:
|
||||||
|
raw = (probe_source or PROBE_SOURCE_UNKNOWN).strip().lower()
|
||||||
|
if raw in VALID_PROBE_SOURCES:
|
||||||
|
return raw
|
||||||
|
return PROBE_SOURCE_UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_env_summary(process: dict[str, Any] | None) -> dict[str, str]:
|
||||||
|
if not process:
|
||||||
|
return {}
|
||||||
|
env = process.get("env") or process.get("environment") or {}
|
||||||
|
if not isinstance(env, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
key: str(env[key])
|
||||||
|
for key in SAFE_ENV_KEYS
|
||||||
|
if key in env and env[key] not in (None, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_namespace_probe(
|
||||||
|
namespace: str,
|
||||||
|
*,
|
||||||
|
required_tool: str | None = None,
|
||||||
|
registered_tools: list[str] | tuple[str, ...] | set[str] | None = None,
|
||||||
|
probe_result: dict[str, Any] | None = None,
|
||||||
|
process: dict[str, Any] | None = None,
|
||||||
|
config_path: str | None = None,
|
||||||
|
profile: str | None = None,
|
||||||
|
configured: bool = True,
|
||||||
|
probe_source: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify whether a required tool is callable through a live namespace.
|
||||||
|
|
||||||
|
``registered_tools`` is static/server-side evidence. ``probe_result`` is
|
||||||
|
live invocation evidence. Only ``probe_source=client_namespace`` proves the
|
||||||
|
IDE-managed path; ``offline_spawn`` is an offline subprocess check only.
|
||||||
|
"""
|
||||||
|
ns = (namespace or "").strip()
|
||||||
|
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami"
|
||||||
|
source = _normalize_probe_source(probe_source)
|
||||||
|
registered_list = _as_list(registered_tools)
|
||||||
|
registered = None if registered_list is None else tool in registered_list
|
||||||
|
|
||||||
|
probe = probe_result or {}
|
||||||
|
probe_success = bool(probe.get("success"))
|
||||||
|
error_message = str(
|
||||||
|
probe.get("error")
|
||||||
|
or probe.get("message")
|
||||||
|
or probe.get("stderr")
|
||||||
|
or probe.get("exception")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
error_type = str(probe.get("error_type") or "").strip()
|
||||||
|
if not error_type and error_message:
|
||||||
|
if _contains_eof(error_message):
|
||||||
|
error_type = "namespace_eof"
|
||||||
|
elif "timeout" in error_message.lower():
|
||||||
|
error_type = "namespace_timeout"
|
||||||
|
else:
|
||||||
|
error_type = "namespace_call_failed"
|
||||||
|
|
||||||
|
if not configured:
|
||||||
|
error_type = "namespace_not_configured"
|
||||||
|
elif registered is False:
|
||||||
|
error_type = "tool_missing"
|
||||||
|
elif not probe_result:
|
||||||
|
error_type = "live_probe_missing"
|
||||||
|
elif not probe_success and not error_type:
|
||||||
|
error_type = "namespace_call_failed"
|
||||||
|
|
||||||
|
callable_live = bool(configured and probe_result and probe_success)
|
||||||
|
# Probe-path health (spawn or client). IDE-proven only for client path.
|
||||||
|
healthy = bool(configured and registered is not False and callable_live)
|
||||||
|
ide_namespace_proven = bool(healthy and source == PROBE_SOURCE_CLIENT)
|
||||||
|
process_pid = process.get("pid") if isinstance(process, dict) else None
|
||||||
|
profile_name = profile or (
|
||||||
|
process.get("profile") if isinstance(process, dict) else None
|
||||||
|
)
|
||||||
|
env_summary = _safe_env_summary(process)
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not configured:
|
||||||
|
reasons.append(f"MCP namespace '{ns}' is not configured.")
|
||||||
|
if registered is False:
|
||||||
|
reasons.append(
|
||||||
|
f"Required tool '{tool}' is not registered in namespace '{ns}'."
|
||||||
|
)
|
||||||
|
if error_type == "live_probe_missing":
|
||||||
|
reasons.append(
|
||||||
|
f"No live client invocation proof was supplied for '{ns}.{tool}'."
|
||||||
|
)
|
||||||
|
elif error_type == "namespace_eof":
|
||||||
|
reasons.append(
|
||||||
|
f"Live MCP namespace '{ns}' returned EOF while invoking '{tool}'."
|
||||||
|
)
|
||||||
|
elif error_type == "namespace_timeout":
|
||||||
|
reasons.append(
|
||||||
|
f"Live MCP namespace '{ns}' timed out while invoking '{tool}'."
|
||||||
|
)
|
||||||
|
elif error_type == "namespace_call_failed":
|
||||||
|
reasons.append(
|
||||||
|
f"Live MCP namespace '{ns}' failed while invoking '{tool}'."
|
||||||
|
)
|
||||||
|
if source == PROBE_SOURCE_OFFLINE:
|
||||||
|
reasons.append(
|
||||||
|
"Probe source is offline_spawn (subprocess JSON-RPC); this does "
|
||||||
|
"not prove the IDE-managed MCP namespace is healthy."
|
||||||
|
)
|
||||||
|
elif source == PROBE_SOURCE_UNKNOWN and probe_result:
|
||||||
|
reasons.append(
|
||||||
|
"Probe source unspecified; treat as not IDE-namespace proof unless "
|
||||||
|
"re-supplied with probe_source='client_namespace'."
|
||||||
|
)
|
||||||
|
|
||||||
|
remediation = []
|
||||||
|
if not healthy or not ide_namespace_proven:
|
||||||
|
remediation.append(
|
||||||
|
"Reconnect the IDE MCP client namespace (client reconnect / "
|
||||||
|
f"relaunch), then invoke '{tool}' through namespace '{ns}' and "
|
||||||
|
"record the result with probe_source='client_namespace'."
|
||||||
|
)
|
||||||
|
remediation.append(
|
||||||
|
"Do not treat offline subprocess probes (test_mcp_conn.py) or "
|
||||||
|
"shell kill/PID respawn as proof the IDE namespace is repaired."
|
||||||
|
)
|
||||||
|
if process_pid and source != PROBE_SOURCE_OFFLINE:
|
||||||
|
remediation.append(
|
||||||
|
f"Diagnostics may include PID {process_pid}; process details "
|
||||||
|
"are informational only — recovery is client-layer reconnect."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
remediation.append(
|
||||||
|
f"IDE-managed namespace '{ns}' can invoke '{tool}' "
|
||||||
|
f"(probe_source={source})."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Client-namespace broken health blocks review/merge. Offline probes never
|
||||||
|
# authorize mutations and only block when they report unhealthy (still
|
||||||
|
# fail-closed for known bad spawn evidence).
|
||||||
|
blocks = False
|
||||||
|
if source == PROBE_SOURCE_CLIENT:
|
||||||
|
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
||||||
|
elif source == PROBE_SOURCE_OFFLINE:
|
||||||
|
# Offline never proves IDE health; never unblock. Unhealthy offline
|
||||||
|
# still surfaces as a soft diagnostic, not a mutation-ledger block.
|
||||||
|
blocks = False
|
||||||
|
else:
|
||||||
|
# Unknown source: only block when evidence is unhealthy (fail closed
|
||||||
|
# on bad data without treating success as IDE proof).
|
||||||
|
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": healthy,
|
||||||
|
"healthy": healthy,
|
||||||
|
"namespace": ns,
|
||||||
|
"required_tool": tool,
|
||||||
|
"configured": configured,
|
||||||
|
"registered_tools_checked": registered_list is not None,
|
||||||
|
"required_tool_registered": registered,
|
||||||
|
"required_tool_callable": callable_live,
|
||||||
|
"probe_source": source,
|
||||||
|
"ide_namespace_proven": ide_namespace_proven,
|
||||||
|
"error_type": None if healthy else error_type,
|
||||||
|
"error_message": error_message or None,
|
||||||
|
"reasons": reasons,
|
||||||
|
"remediation": remediation,
|
||||||
|
"diagnostics": {
|
||||||
|
"namespace": ns,
|
||||||
|
"required_tool": tool,
|
||||||
|
"process_pid": process_pid,
|
||||||
|
"profile": profile_name,
|
||||||
|
"env": env_summary,
|
||||||
|
"config_path": config_path,
|
||||||
|
"probe_source": source,
|
||||||
|
},
|
||||||
|
"blocks_merge_workflow": blocks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def namespace_health_blocks_task(task: str, healthy: bool) -> bool:
|
||||||
|
"""Return whether a broken namespace must block a workflow task."""
|
||||||
|
if healthy:
|
||||||
|
return False
|
||||||
|
return (task or "").strip() in {"merge_pr", "review_pr", "submit_review"}
|
||||||
|
|
||||||
|
|
||||||
|
def required_namespace_for_task(task: str) -> str | None:
|
||||||
|
"""Map a mutation task to the MCP namespace that must be healthy."""
|
||||||
|
return TASK_REQUIRED_NAMESPACES.get((task or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_gate_from_session(
|
||||||
|
task: str,
|
||||||
|
session_health: dict[str, dict[str, Any]] | None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Fail-closed gate using recorded client-namespace health assessments.
|
||||||
|
|
||||||
|
* Missing session entry → no gate (caller has not assessed yet).
|
||||||
|
* Client-namespace unhealthy / not IDE-proven → block mutation.
|
||||||
|
* Offline-only session entries never authorize mutations.
|
||||||
|
"""
|
||||||
|
ns = required_namespace_for_task(task)
|
||||||
|
if not ns:
|
||||||
|
return []
|
||||||
|
store = session_health or {}
|
||||||
|
entry = store.get(ns)
|
||||||
|
if not entry:
|
||||||
|
return []
|
||||||
|
source = _normalize_probe_source(entry.get("probe_source"))
|
||||||
|
if source != PROBE_SOURCE_CLIENT:
|
||||||
|
return [
|
||||||
|
f"recorded namespace health for '{ns}' is probe_source={source}, "
|
||||||
|
"not client_namespace; re-probe through the IDE-managed path "
|
||||||
|
f"before {(task or 'mutation')}"
|
||||||
|
]
|
||||||
|
if entry.get("ide_namespace_proven") and entry.get("healthy"):
|
||||||
|
return []
|
||||||
|
if entry.get("blocks_merge_workflow") or not entry.get("healthy"):
|
||||||
|
detail = entry.get("error_type") or "unhealthy"
|
||||||
|
return [
|
||||||
|
f"live MCP namespace '{ns}' is recorded {detail} "
|
||||||
|
f"(probe_source={source}); repair the IDE namespace before "
|
||||||
|
f"{(task or 'mutation')} (fail closed, #543)"
|
||||||
|
]
|
||||||
|
if not entry.get("ide_namespace_proven"):
|
||||||
|
return [
|
||||||
|
f"live MCP namespace '{ns}' is not IDE-proven; supply a "
|
||||||
|
"client_namespace probe before mutation (fail closed, #543)"
|
||||||
|
]
|
||||||
|
return []
|
||||||
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
|
|
||||||
AUTHORIZED_CLEANUP_TOOLS = frozenset({
|
AUTHORIZED_CLEANUP_TOOLS = frozenset({
|
||||||
"gitea_cleanup_post_merge_moot_lease",
|
"gitea_cleanup_post_merge_moot_lease",
|
||||||
|
"gitea_cleanup_obsolete_reviewer_comment_lease",
|
||||||
"gitea_reconcile_merged_cleanups",
|
"gitea_reconcile_merged_cleanups",
|
||||||
"gitea_delete_branch",
|
"gitea_delete_branch",
|
||||||
"gitea_cleanup_merged_pr_branch",
|
"gitea_cleanup_merged_pr_branch",
|
||||||
|
|||||||
+9
-3
@@ -6,6 +6,10 @@ Runs over stdio. All tools authenticate via macOS keychain (git credential fill)
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||||
|
sys.stderr = open("/tmp/mcp_server_stderr.log", "a", buffering=1)
|
||||||
|
sys.stderr.write(f"\n--- MCP SERVER STARTUP (PID {os.getpid()}) ---\n")
|
||||||
|
|
||||||
from role_session_router import (
|
from role_session_router import (
|
||||||
python_bytes_have_conflict_markers,
|
python_bytes_have_conflict_markers,
|
||||||
skip_python_scan_walk_root,
|
skip_python_scan_walk_root,
|
||||||
@@ -37,15 +41,17 @@ def check_conflict_markers():
|
|||||||
|
|
||||||
check_conflict_markers()
|
check_conflict_markers()
|
||||||
|
|
||||||
# #558: official entrypoint marks the process as the sanctioned MCP daemon
|
# #558 / #695: claim the official entrypoint before loading mutation modules.
|
||||||
# before loading mutation modules (blocks raw shell import bypasses).
|
# This alone does NOT authorize mutations — gitea_mcp_server binds the live
|
||||||
|
# native MCP transport (stdio) immediately before mcp.run. Import-only or
|
||||||
|
# offline launch without that bind fails closed on mutations.
|
||||||
try:
|
try:
|
||||||
import mcp_daemon_guard
|
import mcp_daemon_guard
|
||||||
|
|
||||||
mcp_daemon_guard.mark_sanctioned_daemon()
|
mcp_daemon_guard.mark_sanctioned_daemon()
|
||||||
except Exception:
|
except Exception:
|
||||||
# Guard import failures must not hide conflict-marker infra_stop above;
|
# Guard import failures must not hide conflict-marker infra_stop above;
|
||||||
# gitea_mcp_server main also marks sanctioned when run as __main__.
|
# gitea_mcp_server main also marks + binds when run as __main__.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Execute the actual server logic via exec in this namespace.
|
# Execute the actual server logic via exec in this namespace.
|
||||||
|
|||||||
+466
-8
@@ -30,12 +30,84 @@ DEFAULT_TTL_HOURS = 4.0
|
|||||||
|
|
||||||
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
||||||
KIND_DECISION_LOCK = "review_decision_lock"
|
KIND_DECISION_LOCK = "review_decision_lock"
|
||||||
|
KIND_REVIEW_DRAFT = "review_draft"
|
||||||
|
# Durable marker set when a worker session attempts a direct stable-branch push
|
||||||
|
# or a root-checkout local commit (#671). Keyed per profile identity like the
|
||||||
|
# other session proofs; a contaminated session fails closed on gated mutations
|
||||||
|
# until a reconciler audits and clears it.
|
||||||
|
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
||||||
|
# Durable shadow of the in-memory reviewer session lease (#702). Written on
|
||||||
|
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
|
||||||
|
# daemon that dies without teardown leaves provable orphan evidence (owner
|
||||||
|
# pid + session id) for the guarded lease-cleanup path. Never a substitute
|
||||||
|
# for the in-session lease: mutation gates ignore it.
|
||||||
|
KIND_REVIEWER_SESSION_LEASE = "reviewer_session_lease"
|
||||||
|
# Durable audit record written whenever the daemon's sanctioned stale-binding
|
||||||
|
# recovery clears an inherited GITEA_ACTIVE_WORKTREE binding (#702).
|
||||||
|
KIND_STALE_BINDING_RECOVERY = "stale_binding_recovery"
|
||||||
|
# #709: archive prior terminal decision ledgers instead of silent overwrite.
|
||||||
|
KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive"
|
||||||
|
# #709: post-merge cleanup/audit reconciliation-required durable record.
|
||||||
|
KIND_POST_MERGE_DECISION_RECOVERY = "post_merge_decision_recovery"
|
||||||
|
# #709: truthful record when historical terminal evidence is irrecoverably gone.
|
||||||
|
KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
|
||||||
|
# #709 F1: server-side non-forgeable authorization artifact for recovery.
|
||||||
|
KIND_IRRECOVERABLE_PROVENANCE_AUTH = "irrecoverable_provenance_authorization"
|
||||||
|
|
||||||
|
# Kinds that must survive the default session-state TTL (forensic / recovery).
|
||||||
|
#
|
||||||
|
# KIND_DECISION_LOCK is recovery-critical (#720): terminal review provenance is
|
||||||
|
# not disposable cache. A generic four-hour TTL must not drop old-head evidence
|
||||||
|
# or make ``fresh_review_on_current_head_allowed`` unreachable. Same-head / same-
|
||||||
|
# run #332 protections still apply once the ledger is loadable. Other session
|
||||||
|
# kinds (workflow load, drafts, etc.) remain TTL-bound.
|
||||||
|
RECOVERY_CRITICAL_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
KIND_DECISION_LOCK,
|
||||||
|
KIND_DECISION_LOCK_ARCHIVE,
|
||||||
|
KIND_POST_MERGE_DECISION_RECOVERY,
|
||||||
|
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
||||||
|
KIND_IRRECOVERABLE_PROVENANCE_AUTH,
|
||||||
|
# #702 crash-orphan evidence (must outlive TTL; F4)
|
||||||
|
KIND_REVIEWER_SESSION_LEASE,
|
||||||
|
KIND_STALE_BINDING_RECOVERY,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||||
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
||||||
|
|
||||||
|
|
||||||
def default_state_dir() -> str:
|
def default_state_dir() -> str:
|
||||||
|
"""Resolve the durable session-state root.
|
||||||
|
|
||||||
|
When production native MCP transport is bound (#695 AC2), the directory
|
||||||
|
pinned at transport bind is authoritative: later overrides of
|
||||||
|
``GITEA_MCP_SESSION_STATE_DIR`` cannot manufacture a second authority
|
||||||
|
domain (PR #701 recurrence: ``.mcp_session_701`` evasion of cross-PR
|
||||||
|
decision locks).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import mcp_daemon_guard
|
||||||
|
|
||||||
|
pinned = mcp_daemon_guard.pinned_session_state_dir()
|
||||||
|
if pinned:
|
||||||
|
return pinned
|
||||||
|
except Exception:
|
||||||
|
# Fail open to env/default only when guard is unavailable (e.g. partial
|
||||||
|
# import during bootstrap). Mutation gates still fail closed separately.
|
||||||
|
pass
|
||||||
|
raw = (os.environ.get(STATE_DIR_ENV) or DEFAULT_STATE_DIR).strip()
|
||||||
|
return raw or DEFAULT_STATE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def env_session_state_dir_unpinned() -> str:
|
||||||
|
"""Raw env/default session-state dir ignoring production transport pin.
|
||||||
|
|
||||||
|
Intended for diagnostics and tests that assert pin behavior — not for
|
||||||
|
mutation-sensitive durable proofs under a bound native daemon.
|
||||||
|
"""
|
||||||
raw = (os.environ.get(STATE_DIR_ENV) or DEFAULT_STATE_DIR).strip()
|
raw = (os.environ.get(STATE_DIR_ENV) or DEFAULT_STATE_DIR).strip()
|
||||||
return raw or DEFAULT_STATE_DIR
|
return raw or DEFAULT_STATE_DIR
|
||||||
|
|
||||||
@@ -78,6 +150,7 @@ def state_key(
|
|||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
profile_identity: str | None = None,
|
profile_identity: str | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build durable filename key.
|
"""Build durable filename key.
|
||||||
|
|
||||||
@@ -85,17 +158,20 @@ def state_key(
|
|||||||
so the primary key is kind + profile identity. Remote/org/repo are stored
|
so the primary key is kind + profile identity. Remote/org/repo are stored
|
||||||
inside the payload and validated on load (#559), which lets a later daemon
|
inside the payload and validated on load (#559), which lets a later daemon
|
||||||
process recover state without already knowing the remote argument.
|
process recover state without already knowing the remote argument.
|
||||||
|
|
||||||
|
*instance_id* extends the key for kinds where one-per-profile collides —
|
||||||
|
concurrent same-profile sessions each own their reviewer-lease shadow
|
||||||
|
(#702 F5), keyed by their lease session id so a later heartbeat can never
|
||||||
|
overwrite or misattribute another session's crash evidence.
|
||||||
"""
|
"""
|
||||||
# Keep remote/org/repo parameters for API stability / future kinds; they are
|
# Keep remote/org/repo parameters for API stability / future kinds; they are
|
||||||
# intentionally not part of the filename for session-scoped proofs.
|
# intentionally not part of the filename for session-scoped proofs.
|
||||||
_ = (remote, org, repo)
|
_ = (remote, org, repo)
|
||||||
return "-".join(
|
parts = [kind, profile_identity or "unknown-profile"]
|
||||||
_sanitize_segment(part)
|
instance = (instance_id or "").strip()
|
||||||
for part in (
|
if instance:
|
||||||
kind,
|
parts.append(instance)
|
||||||
profile_identity or "unknown-profile",
|
return "-".join(_sanitize_segment(part) for part in parts)
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def state_file_path(
|
def state_file_path(
|
||||||
@@ -106,6 +182,7 @@ def state_file_path(
|
|||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
profile_identity: str | None = None,
|
profile_identity: str | None = None,
|
||||||
state_dir: str | None = None,
|
state_dir: str | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
root = (state_dir or default_state_dir()).strip()
|
root = (state_dir or default_state_dir()).strip()
|
||||||
name = state_key(
|
name = state_key(
|
||||||
@@ -114,6 +191,7 @@ def state_file_path(
|
|||||||
org=org,
|
org=org,
|
||||||
repo=repo,
|
repo=repo,
|
||||||
profile_identity=profile_identity,
|
profile_identity=profile_identity,
|
||||||
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
return os.path.join(root, f"{name}.json")
|
return os.path.join(root, f"{name}.json")
|
||||||
|
|
||||||
@@ -192,6 +270,16 @@ def _write_json(path: str, data: dict[str, Any]) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def is_recovery_critical_record(record: dict[str, Any] | None, kind: str | None = None) -> bool:
|
||||||
|
"""True when a durable record must outlive the generic session-state TTL."""
|
||||||
|
if not record and not kind:
|
||||||
|
return False
|
||||||
|
record_kind = ((record or {}).get("kind") or kind or "").strip()
|
||||||
|
if record_kind in RECOVERY_CRITICAL_KINDS:
|
||||||
|
return True
|
||||||
|
return bool((record or {}).get("recovery_critical"))
|
||||||
|
|
||||||
|
|
||||||
def identity_match_reasons(
|
def identity_match_reasons(
|
||||||
record: dict[str, Any] | None,
|
record: dict[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
@@ -234,7 +322,9 @@ def identity_match_reasons(
|
|||||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||||
else:
|
else:
|
||||||
age = _now_utc() - recorded_at
|
age = _now_utc() - recorded_at
|
||||||
if age > timedelta(hours=ttl_hours()):
|
kind = (record.get("kind") or "").strip()
|
||||||
|
ttl_exempt = is_recovery_critical_record(record, kind=kind)
|
||||||
|
if age > timedelta(hours=ttl_hours()) and not ttl_exempt:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||||
)
|
)
|
||||||
@@ -243,6 +333,113 @@ def identity_match_reasons(
|
|||||||
return reasons
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_state_envelope(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
remote: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
profile_identity: str | None = None,
|
||||||
|
state_dir: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read-only disk inspection for assessment when TTL would otherwise hide state (#720).
|
||||||
|
|
||||||
|
Does **not** apply identity/TTL rejection to the returned presence flags.
|
||||||
|
Callers use this to distinguish:
|
||||||
|
* no file on disk
|
||||||
|
* file present but TTL would reject a non-critical kind
|
||||||
|
* recovery-critical ledger (e.g. KIND_DECISION_LOCK) still loadable
|
||||||
|
Never mutates files. Never returns secrets.
|
||||||
|
"""
|
||||||
|
profile = current_profile_identity(profile_identity=profile_identity)
|
||||||
|
path = state_file_path(
|
||||||
|
kind=kind,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
state_dir=state_dir,
|
||||||
|
)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"kind": kind,
|
||||||
|
"profile_identity": profile,
|
||||||
|
"path_basename": os.path.basename(path),
|
||||||
|
"on_disk": False,
|
||||||
|
"has_payload": False,
|
||||||
|
"recorded_at": None,
|
||||||
|
"updated_at": None,
|
||||||
|
"age_hours": None,
|
||||||
|
"ttl_hours": ttl_hours(),
|
||||||
|
"age_exceeds_default_ttl": False,
|
||||||
|
"recovery_critical": kind in RECOVERY_CRITICAL_KINDS,
|
||||||
|
"ttl_exempt": kind in RECOVERY_CRITICAL_KINDS,
|
||||||
|
"would_ttl_reject": False,
|
||||||
|
"identity_reasons": [],
|
||||||
|
"summary": "no session-state file on disk",
|
||||||
|
}
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return result
|
||||||
|
result["on_disk"] = True
|
||||||
|
envelope = _read_json(path)
|
||||||
|
if not envelope:
|
||||||
|
result["summary"] = "session-state file present but unreadable or empty"
|
||||||
|
return result
|
||||||
|
payload = envelope.get("payload")
|
||||||
|
merged: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {}
|
||||||
|
result["has_payload"] = isinstance(payload, dict)
|
||||||
|
for key in (
|
||||||
|
"kind",
|
||||||
|
"remote",
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
"profile_identity",
|
||||||
|
"session_profile_lock",
|
||||||
|
"recorded_at",
|
||||||
|
"updated_at",
|
||||||
|
"writer_pid",
|
||||||
|
"recovery_critical",
|
||||||
|
):
|
||||||
|
if key in envelope and key not in merged:
|
||||||
|
merged[key] = envelope[key]
|
||||||
|
if not merged.get("kind"):
|
||||||
|
merged["kind"] = kind
|
||||||
|
recorded_at = _parse_iso(merged.get("recorded_at") or merged.get("updated_at"))
|
||||||
|
result["recorded_at"] = merged.get("recorded_at") or merged.get("updated_at")
|
||||||
|
result["updated_at"] = merged.get("updated_at") or merged.get("recorded_at")
|
||||||
|
if recorded_at is not None:
|
||||||
|
age = _now_utc() - recorded_at
|
||||||
|
age_hours = age.total_seconds() / 3600.0
|
||||||
|
result["age_hours"] = age_hours
|
||||||
|
result["age_exceeds_default_ttl"] = age > timedelta(hours=ttl_hours())
|
||||||
|
ttl_exempt = is_recovery_critical_record(merged, kind=kind)
|
||||||
|
result["recovery_critical"] = ttl_exempt
|
||||||
|
result["ttl_exempt"] = ttl_exempt
|
||||||
|
identity_reasons = identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
)
|
||||||
|
result["identity_reasons"] = list(identity_reasons)
|
||||||
|
result["would_ttl_reject"] = any("expired" in r for r in identity_reasons)
|
||||||
|
if result["has_payload"] and ttl_exempt:
|
||||||
|
result["summary"] = (
|
||||||
|
"recovery-critical session-state present on disk and TTL-exempt; "
|
||||||
|
"load via load_state for full payload"
|
||||||
|
)
|
||||||
|
elif result["has_payload"] and result["would_ttl_reject"]:
|
||||||
|
result["summary"] = (
|
||||||
|
"session-state file present on disk but generic TTL would reject load "
|
||||||
|
f"(age_hours={result.get('age_hours')!r}, ttl={ttl_hours():g}h)"
|
||||||
|
)
|
||||||
|
elif result["has_payload"]:
|
||||||
|
result["summary"] = "session-state file present and within TTL / identity gates"
|
||||||
|
else:
|
||||||
|
result["summary"] = "session-state file present without a dict payload"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def load_state(
|
def load_state(
|
||||||
*,
|
*,
|
||||||
kind: str,
|
kind: str,
|
||||||
@@ -251,6 +448,7 @@ def load_state(
|
|||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
profile_identity: str | None = None,
|
profile_identity: str | None = None,
|
||||||
state_dir: str | None = None,
|
state_dir: str | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Load durable state payload when identity checks pass."""
|
"""Load durable state payload when identity checks pass."""
|
||||||
profile = current_profile_identity(profile_identity=profile_identity)
|
profile = current_profile_identity(profile_identity=profile_identity)
|
||||||
@@ -261,6 +459,7 @@ def load_state(
|
|||||||
repo=repo,
|
repo=repo,
|
||||||
profile_identity=profile,
|
profile_identity=profile,
|
||||||
state_dir=state_dir,
|
state_dir=state_dir,
|
||||||
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
lock_path = f"{path}.lock"
|
lock_path = f"{path}.lock"
|
||||||
with _exclusive_file_lock(lock_path):
|
with _exclusive_file_lock(lock_path):
|
||||||
@@ -306,6 +505,7 @@ def save_state(
|
|||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
profile_identity: str | None = None,
|
profile_identity: str | None = None,
|
||||||
state_dir: str | None = None,
|
state_dir: str | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Persist or clear durable state for the given session identity key."""
|
"""Persist or clear durable state for the given session identity key."""
|
||||||
profile = current_profile_identity(
|
profile = current_profile_identity(
|
||||||
@@ -318,6 +518,11 @@ def save_state(
|
|||||||
key_remote = remote if remote is not None else (payload or {}).get("remote")
|
key_remote = remote if remote is not None else (payload or {}).get("remote")
|
||||||
key_org = org if org is not None else (payload or {}).get("org")
|
key_org = org if org is not None else (payload or {}).get("org")
|
||||||
key_repo = repo if repo is not None else (payload or {}).get("repo")
|
key_repo = repo if repo is not None else (payload or {}).get("repo")
|
||||||
|
key_instance = (
|
||||||
|
(instance_id or "").strip()
|
||||||
|
or str((payload or {}).get("instance_id") or "").strip()
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
|
||||||
root = _ensure_state_dir(state_dir)
|
root = _ensure_state_dir(state_dir)
|
||||||
path = state_file_path(
|
path = state_file_path(
|
||||||
@@ -327,6 +532,7 @@ def save_state(
|
|||||||
repo=key_repo,
|
repo=key_repo,
|
||||||
profile_identity=profile,
|
profile_identity=profile,
|
||||||
state_dir=root,
|
state_dir=root,
|
||||||
|
instance_id=key_instance,
|
||||||
)
|
)
|
||||||
lock_path = f"{path}.lock"
|
lock_path = f"{path}.lock"
|
||||||
with _exclusive_file_lock(lock_path):
|
with _exclusive_file_lock(lock_path):
|
||||||
@@ -354,6 +560,28 @@ def save_state(
|
|||||||
body["org"] = key_org
|
body["org"] = key_org
|
||||||
if key_repo is not None:
|
if key_repo is not None:
|
||||||
body["repo"] = key_repo
|
body["repo"] = key_repo
|
||||||
|
# Stamp session-state authority used for this write (#695 AC2 / AC6).
|
||||||
|
body.setdefault("session_state_dir", root)
|
||||||
|
if key_instance:
|
||||||
|
body["instance_id"] = key_instance
|
||||||
|
try:
|
||||||
|
import mcp_daemon_guard
|
||||||
|
|
||||||
|
prov = mcp_daemon_guard.mutation_provenance_fields()
|
||||||
|
body.setdefault(
|
||||||
|
"native_token_fingerprint", prov.get("native_token_fingerprint")
|
||||||
|
)
|
||||||
|
body.setdefault(
|
||||||
|
"native_mcp_transport", bool(prov.get("native_mcp_transport"))
|
||||||
|
)
|
||||||
|
body.setdefault(
|
||||||
|
"production_native_mcp_transport",
|
||||||
|
bool(prov.get("production_native_mcp_transport")),
|
||||||
|
)
|
||||||
|
body.setdefault("transport", prov.get("transport"))
|
||||||
|
except Exception:
|
||||||
|
body.setdefault("native_mcp_transport", False)
|
||||||
|
body.setdefault("transport", "untrusted")
|
||||||
|
|
||||||
envelope = {
|
envelope = {
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
@@ -365,8 +593,12 @@ def save_state(
|
|||||||
"recorded_at": body["recorded_at"],
|
"recorded_at": body["recorded_at"],
|
||||||
"updated_at": body["updated_at"],
|
"updated_at": body["updated_at"],
|
||||||
"writer_pid": body["writer_pid"],
|
"writer_pid": body["writer_pid"],
|
||||||
|
"session_state_dir": body.get("session_state_dir"),
|
||||||
|
"transport": body.get("transport"),
|
||||||
"payload": body,
|
"payload": body,
|
||||||
}
|
}
|
||||||
|
if key_instance:
|
||||||
|
envelope["instance_id"] = key_instance
|
||||||
_write_json(path, envelope)
|
_write_json(path, envelope)
|
||||||
return dict(body)
|
return dict(body)
|
||||||
|
|
||||||
@@ -379,6 +611,7 @@ def clear_state(
|
|||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
profile_identity: str | None = None,
|
profile_identity: str | None = None,
|
||||||
state_dir: str | None = None,
|
state_dir: str | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
save_state(
|
save_state(
|
||||||
kind=kind,
|
kind=kind,
|
||||||
@@ -388,4 +621,229 @@ def clear_state(
|
|||||||
repo=repo,
|
repo=repo,
|
||||||
profile_identity=profile_identity,
|
profile_identity=profile_identity,
|
||||||
state_dir=state_dir,
|
state_dir=state_dir,
|
||||||
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_states(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
profile_identity: str | None = None,
|
||||||
|
state_dir: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Enumerate durable records of *kind* across all instance keys.
|
||||||
|
|
||||||
|
Crash recovery cannot know which session id left a shadow behind, so it
|
||||||
|
must be able to enumerate every record of a kind (#702 F5). Identity
|
||||||
|
checks (profile / TTL policy) apply per record exactly as in
|
||||||
|
:func:`load_state`; records that fail closed are omitted.
|
||||||
|
"""
|
||||||
|
root = (state_dir or default_state_dir()).strip()
|
||||||
|
if not root or not os.path.isdir(root):
|
||||||
|
return []
|
||||||
|
profile = current_profile_identity(profile_identity=profile_identity)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(root))
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
for name in names:
|
||||||
|
if not name.endswith(".json") or name.startswith("."):
|
||||||
|
continue
|
||||||
|
envelope = _read_json(os.path.join(root, name))
|
||||||
|
if not envelope or envelope.get("kind") != kind:
|
||||||
|
continue
|
||||||
|
payload = envelope.get("payload")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
merged = dict(payload)
|
||||||
|
for key in (
|
||||||
|
"kind",
|
||||||
|
"remote",
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
"profile_identity",
|
||||||
|
"session_profile_lock",
|
||||||
|
"recorded_at",
|
||||||
|
"updated_at",
|
||||||
|
"writer_pid",
|
||||||
|
):
|
||||||
|
if key in envelope and key not in merged:
|
||||||
|
merged[key] = envelope[key]
|
||||||
|
if identity_match_reasons(merged, profile_identity=profile):
|
||||||
|
continue
|
||||||
|
results.append(merged)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def list_decision_lock_profile_identities(
|
||||||
|
state_dir: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return profile identities that have a durable review_decision_lock file (#709).
|
||||||
|
|
||||||
|
Filename form: ``review_decision_lock-<profile>.json`` (see ``state_key``).
|
||||||
|
Does not validate TTL or identity — callers must load via ``load_state``.
|
||||||
|
"""
|
||||||
|
root = (state_dir or default_state_dir()).strip()
|
||||||
|
if not root or not os.path.isdir(root):
|
||||||
|
return []
|
||||||
|
prefix = f"{_sanitize_segment(KIND_DECISION_LOCK)}-"
|
||||||
|
suffix = ".json"
|
||||||
|
found: list[str] = []
|
||||||
|
try:
|
||||||
|
names = os.listdir(root)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
for name in names:
|
||||||
|
if not name.startswith(prefix) or not name.endswith(suffix):
|
||||||
|
continue
|
||||||
|
if name.endswith(".lock"):
|
||||||
|
continue
|
||||||
|
mid = name[len(prefix) : -len(suffix)]
|
||||||
|
if mid:
|
||||||
|
found.append(mid)
|
||||||
|
return sorted(set(found))
|
||||||
|
|
||||||
|
|
||||||
|
def load_state_for_profile(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
profile_identity: str,
|
||||||
|
remote: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
state_dir: str | None = None,
|
||||||
|
skip_identity_match: bool = False,
|
||||||
|
enforce_repo_scope: bool = True,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Load durable state for an explicit profile identity (#709 cross-profile).
|
||||||
|
|
||||||
|
When *skip_identity_match* is True, still requires the file's recorded
|
||||||
|
profile_identity to equal the requested profile (anti-stomp), but does not
|
||||||
|
require the *active* session identity to match — needed so a merger can
|
||||||
|
inspect a reviewer lock after merge.
|
||||||
|
|
||||||
|
#709 F3: remote/org/repo filter reasons are **enforced** (not merely
|
||||||
|
computed). When *enforce_repo_scope* is True (default) and the caller
|
||||||
|
supplies remote/org/repo, mismatches fail closed with ``None``.
|
||||||
|
"""
|
||||||
|
# #709 F3: refuse traversal / malformed profile identities.
|
||||||
|
try:
|
||||||
|
from irrecoverable_provenance import assess_profile_path_identity
|
||||||
|
|
||||||
|
path_gate = assess_profile_path_identity(profile_identity)
|
||||||
|
if not path_gate.get("valid"):
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
# Module may be mid-import in edge bootstraps; fall through to
|
||||||
|
# conservative checks below.
|
||||||
|
raw = str(profile_identity or "")
|
||||||
|
if ".." in raw or "/" in raw or "\\" in raw or "\x00" in raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
profile = current_profile_identity(profile_identity=profile_identity)
|
||||||
|
root = _ensure_state_dir(state_dir)
|
||||||
|
# Refuse symlink escape of the state root (#709 F3).
|
||||||
|
try:
|
||||||
|
real_root = os.path.realpath(root)
|
||||||
|
path = state_file_path(
|
||||||
|
kind=kind,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
state_dir=root,
|
||||||
|
)
|
||||||
|
real_path = os.path.realpath(path) if os.path.exists(path) else path
|
||||||
|
if os.path.exists(path) and not str(real_path).startswith(
|
||||||
|
str(real_root) + os.sep
|
||||||
|
) and str(real_path) != str(real_root):
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = state_file_path(
|
||||||
|
kind=kind,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
state_dir=root,
|
||||||
|
)
|
||||||
|
lock_path = f"{path}.lock"
|
||||||
|
with _exclusive_file_lock(lock_path):
|
||||||
|
envelope = _read_json(path)
|
||||||
|
if not envelope:
|
||||||
|
return None
|
||||||
|
payload = envelope.get("payload")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
merged = dict(payload)
|
||||||
|
for key in (
|
||||||
|
"kind",
|
||||||
|
"remote",
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
"profile_identity",
|
||||||
|
"session_profile_lock",
|
||||||
|
"recorded_at",
|
||||||
|
"updated_at",
|
||||||
|
"writer_pid",
|
||||||
|
):
|
||||||
|
if key in envelope and key not in merged:
|
||||||
|
merged[key] = envelope[key]
|
||||||
|
stored = (merged.get("profile_identity") or "").strip()
|
||||||
|
if stored and stored != profile:
|
||||||
|
return None
|
||||||
|
if skip_identity_match:
|
||||||
|
# Enforce remote/org/repo when caller provides them (#709 F3).
|
||||||
|
# Drop only *active session* profile-identity mismatches; keep
|
||||||
|
# expiry, spoof, and repository-scope reasons.
|
||||||
|
scope_remote = remote if enforce_repo_scope else None
|
||||||
|
scope_org = org if enforce_repo_scope else None
|
||||||
|
scope_repo = repo if enforce_repo_scope else None
|
||||||
|
reasons = identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=scope_remote,
|
||||||
|
org=scope_org,
|
||||||
|
repo=scope_repo,
|
||||||
|
profile_identity=stored or profile,
|
||||||
|
)
|
||||||
|
filtered = [
|
||||||
|
r
|
||||||
|
for r in reasons
|
||||||
|
if "profile identity mismatch" not in r
|
||||||
|
or (stored and stored != profile)
|
||||||
|
]
|
||||||
|
# When caller requested a specific remote/org/repo, also fail if the
|
||||||
|
# stored record lacks those identity fields entirely (legacy incomplete).
|
||||||
|
if enforce_repo_scope:
|
||||||
|
for field, want in (
|
||||||
|
("remote", remote),
|
||||||
|
("org", org),
|
||||||
|
("repo", repo),
|
||||||
|
):
|
||||||
|
want_s = (want or "").strip()
|
||||||
|
if not want_s:
|
||||||
|
continue
|
||||||
|
have = (str(merged.get(field) or "")).strip()
|
||||||
|
if not have:
|
||||||
|
filtered.append(
|
||||||
|
f"session state {field} missing on durable record "
|
||||||
|
f"(expected={want_s!r}; fail closed, #709 F3)"
|
||||||
|
)
|
||||||
|
elif have != want_s:
|
||||||
|
# identity_match_reasons already adds mismatch; ensure kept
|
||||||
|
pass
|
||||||
|
if filtered:
|
||||||
|
return None
|
||||||
|
return merged
|
||||||
|
reasons = identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
return None
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,634 @@
|
|||||||
|
"""MCP tool-boundary error mapping for known Gitea client failures (#699).
|
||||||
|
|
||||||
|
Known authentication / authorization / network / configuration failures leave
|
||||||
|
the tool boundary as a sanitized structured ``CallToolResult`` with
|
||||||
|
``isError=True``. Stdio transport remains connected.
|
||||||
|
|
||||||
|
Design constraints (reviewer-ratified, #699 / PR #701):
|
||||||
|
|
||||||
|
1. **No secret material** in tool results or daemon logs. Messages are fixed
|
||||||
|
constants keyed by ``reason_code``; HTTP bodies / exception text never
|
||||||
|
surface. Sanitization failure fails closed to ``internal_error``.
|
||||||
|
2. **Narrow boundary** wraps the original FastMCP ``Tool.run`` success path;
|
||||||
|
re-raises framework control-flow exceptions (``UrlElicitationRequiredError``);
|
||||||
|
maps only typed client failures. Installation is idempotent.
|
||||||
|
3. **No RuntimeError substring heuristics.** Only explicit typed
|
||||||
|
authentication / authorization / network / configuration exceptions
|
||||||
|
receive those labels.
|
||||||
|
4. **HTTP classification** lives in ``gitea_auth.classify_http_status``;
|
||||||
|
every 403 is authorization-class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("gitea_mcp.tool_error_boundary")
|
||||||
|
|
||||||
|
# Stable reason codes (#699).
|
||||||
|
REASON_AUTH_FAILED = "auth_failed"
|
||||||
|
REASON_AUTH_INVALID_TOKEN = "auth_invalid_token"
|
||||||
|
REASON_AUTHZ_INSUFFICIENT_SCOPE = "authz_insufficient_scope"
|
||||||
|
REASON_AUTHZ_DENIED = "authz_denied"
|
||||||
|
REASON_NETWORK_ERROR = "network_error"
|
||||||
|
REASON_CONFIG_ERROR = "config_error"
|
||||||
|
REASON_INTERNAL_ERROR = "internal_error"
|
||||||
|
REASON_UPSTREAM_UNAVAILABLE = "upstream_unavailable"
|
||||||
|
REASON_HTTP_ERROR = "http_error"
|
||||||
|
# Typed structured errors for previously-opaque raw mutation failures.
|
||||||
|
REASON_PREFLIGHT_ORDER = "preflight_order_violation"
|
||||||
|
REASON_MALFORMED_RESPONSE = "malformed_response"
|
||||||
|
|
||||||
|
ERROR_CLASS_AUTHENTICATION = "authentication"
|
||||||
|
ERROR_CLASS_AUTHORIZATION = "authorization"
|
||||||
|
ERROR_CLASS_NETWORK = "network"
|
||||||
|
ERROR_CLASS_CONFIGURATION = "configuration"
|
||||||
|
ERROR_CLASS_INTERNAL = "internal"
|
||||||
|
ERROR_CLASS_UPSTREAM = "upstream"
|
||||||
|
ERROR_CLASS_PRECONDITION = "precondition"
|
||||||
|
|
||||||
|
# Fixed, secret-free operator messages. Never interpolate HTTP bodies,
|
||||||
|
# Keychain contents, tokens, or arbitrary exception text.
|
||||||
|
FIXED_MESSAGES: dict[str, str] = {
|
||||||
|
REASON_AUTH_FAILED: "Gitea authentication failed",
|
||||||
|
REASON_AUTH_INVALID_TOKEN: (
|
||||||
|
"Gitea authentication failed: invalid or revoked credentials"
|
||||||
|
),
|
||||||
|
REASON_AUTHZ_INSUFFICIENT_SCOPE: (
|
||||||
|
"Gitea authorization failed: insufficient token scope"
|
||||||
|
),
|
||||||
|
REASON_AUTHZ_DENIED: "Gitea authorization failed: access denied",
|
||||||
|
REASON_NETWORK_ERROR: "Network error contacting Gitea",
|
||||||
|
REASON_CONFIG_ERROR: "Gitea configuration or credential resolution failed",
|
||||||
|
REASON_INTERNAL_ERROR: "Internal tool error",
|
||||||
|
REASON_UPSTREAM_UNAVAILABLE: "Gitea upstream unavailable",
|
||||||
|
REASON_HTTP_ERROR: "Gitea HTTP request failed",
|
||||||
|
REASON_PREFLIGHT_ORDER: (
|
||||||
|
"Mutation blocked: pre-flight order violation (fail closed)"
|
||||||
|
),
|
||||||
|
REASON_MALFORMED_RESPONSE: "Malformed response from Gitea",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error class for a self-declared safe reason code (``gitea_reason_code``).
|
||||||
|
_REASON_ERROR_CLASS: dict[str, str] = {
|
||||||
|
REASON_AUTH_FAILED: ERROR_CLASS_AUTHENTICATION,
|
||||||
|
REASON_AUTH_INVALID_TOKEN: ERROR_CLASS_AUTHENTICATION,
|
||||||
|
REASON_AUTHZ_INSUFFICIENT_SCOPE: ERROR_CLASS_AUTHORIZATION,
|
||||||
|
REASON_AUTHZ_DENIED: ERROR_CLASS_AUTHORIZATION,
|
||||||
|
REASON_NETWORK_ERROR: ERROR_CLASS_NETWORK,
|
||||||
|
REASON_CONFIG_ERROR: ERROR_CLASS_CONFIGURATION,
|
||||||
|
REASON_UPSTREAM_UNAVAILABLE: ERROR_CLASS_UPSTREAM,
|
||||||
|
REASON_HTTP_ERROR: ERROR_CLASS_INTERNAL,
|
||||||
|
REASON_PREFLIGHT_ORDER: ERROR_CLASS_PRECONDITION,
|
||||||
|
REASON_MALFORMED_RESPONSE: ERROR_CLASS_INTERNAL,
|
||||||
|
REASON_INTERNAL_ERROR: ERROR_CLASS_INTERNAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bounds for the safe diagnostic fields added to the ``internal_error`` path.
|
||||||
|
_DETAIL_LIMIT = 200
|
||||||
|
_CLASS_LIMIT = 120
|
||||||
|
_STAGE_LIMIT = 64
|
||||||
|
|
||||||
|
# Absolute-path prefixes stripped from diagnostic detail (never leak layout).
|
||||||
|
_ABS_PATH_RE = re.compile(r"/(?:Users|home|private|var|tmp|opt|etc|root)/[^\s'\"]*")
|
||||||
|
_DETAIL_SECRET_PREFIXES = ("token ", "Basic ", "Bearer ", "Authorization: ")
|
||||||
|
_SECRET_KEY = (
|
||||||
|
r"token|password|passwd|secret|authorization|api[_-]?key|"
|
||||||
|
r"access[_-]?token|refresh[_-]?token|session|cookie"
|
||||||
|
)
|
||||||
|
_SECRET_JSON_RE = re.compile(
|
||||||
|
r'"(' + _SECRET_KEY + r')"\s*:\s*"[^"]*"', re.IGNORECASE
|
||||||
|
)
|
||||||
|
_SECRET_KV_RE = re.compile(
|
||||||
|
r"\b(" + _SECRET_KEY + r")\s*=\s*[^\s&\"']+", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
_MUTATION_STAGE_ATTR = "_gitea_mutation_stage"
|
||||||
|
_SELF_DECLARED_REASON_ATTR = "gitea_reason_code"
|
||||||
|
|
||||||
|
_INSTALL_FLAG = "_gitea_auth_boundary_installed"
|
||||||
|
_ORIGINAL_ATTR = "_gitea_auth_boundary_original"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_str(exc: BaseException) -> str:
|
||||||
|
"""``str(exc)`` that never raises (a poisoned ``__str__`` must not escape)."""
|
||||||
|
try:
|
||||||
|
return str(exc)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_exception_class(exc: BaseException) -> str:
|
||||||
|
"""Fully-qualified type identifier for *exc* — never instance/message text."""
|
||||||
|
try:
|
||||||
|
t = type(exc)
|
||||||
|
mod = getattr(t, "__module__", "") or ""
|
||||||
|
name = getattr(t, "__qualname__", None) or getattr(t, "__name__", "") or ""
|
||||||
|
ident = f"{mod}.{name}" if mod else name
|
||||||
|
return ident[:_CLASS_LIMIT]
|
||||||
|
except Exception:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_detail(text: Any, limit: int = _DETAIL_LIMIT) -> str:
|
||||||
|
"""Strictly redact free-form exception text for safe diagnostics.
|
||||||
|
|
||||||
|
Removes token/Authorization credentials, raw URLs and hostnames (via the
|
||||||
|
shared audit redactor), and absolute filesystem paths, then collapses
|
||||||
|
whitespace and truncates. Never raises; on any failure returns "".
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
out = str(text)
|
||||||
|
# Drop credential-prefixed runs (token/Basic/Bearer/Authorization).
|
||||||
|
for prefix in _DETAIL_SECRET_PREFIXES:
|
||||||
|
idx = 0
|
||||||
|
while True:
|
||||||
|
i = out.find(prefix, idx)
|
||||||
|
if i == -1:
|
||||||
|
break
|
||||||
|
j = i + len(prefix)
|
||||||
|
while j < len(out) and not out[j].isspace():
|
||||||
|
j += 1
|
||||||
|
out = out[:i] + prefix + "[REDACTED]" + out[j:]
|
||||||
|
idx = i + len(prefix) + len("[REDACTED]")
|
||||||
|
# Secret VALUES carried in JSON ("key":"value") or kv (key=value) form,
|
||||||
|
# even short ones (e.g. a password), keyed by a sensitive field name.
|
||||||
|
out = _SECRET_JSON_RE.sub(r'"\1":"[REDACTED]"', out)
|
||||||
|
out = _SECRET_KV_RE.sub(r"\1=[REDACTED]", out)
|
||||||
|
# URLs / query secrets / hostnames.
|
||||||
|
try:
|
||||||
|
import gitea_audit
|
||||||
|
|
||||||
|
out = gitea_audit.redact_urls(out)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Absolute filesystem paths (workspace layout is not for LLM output).
|
||||||
|
out = _ABS_PATH_RE.sub("[PATH]", out)
|
||||||
|
# Any bare hostname the URL redactor missed (defence in depth).
|
||||||
|
out = re.sub(r"\b[\w.-]+\.(?:cc|net|com|org|io|dev|local)\b", "[HOST]", out)
|
||||||
|
out = " ".join(out.split())
|
||||||
|
return out[:limit]
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mutation_stage(exc: BaseException) -> str | None:
|
||||||
|
"""Return the bounded, redacted mutation stage tagged on *exc* (or None)."""
|
||||||
|
try:
|
||||||
|
raw = getattr(exc, _MUTATION_STAGE_ATTR, None)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return _redact_detail(raw, limit=_STAGE_LIMIT) or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fixed_message(reason_code: str) -> str:
|
||||||
|
"""Return the fixed sanitized message for *reason_code* (fail closed)."""
|
||||||
|
return FIXED_MESSAGES.get(reason_code, FIXED_MESSAGES[REASON_INTERNAL_ERROR])
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_profile_name() -> str | None:
|
||||||
|
try:
|
||||||
|
from gitea_auth import get_profile
|
||||||
|
|
||||||
|
name = (get_profile() or {}).get("profile_name")
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
text = str(name).strip()
|
||||||
|
# Profile names are non-secret identifiers; still bound length.
|
||||||
|
return text[:80] if text else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_framework_control_flow(exc: BaseException) -> bool:
|
||||||
|
"""True for exceptions the MCP framework must re-raise unchanged."""
|
||||||
|
try:
|
||||||
|
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||||
|
|
||||||
|
if isinstance(exc, UrlElicitationRequiredError):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# BaseException subclasses that must never become tool isError payloads.
|
||||||
|
if isinstance(exc, (KeyboardInterrupt, SystemExit, GeneratorExit)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_client_failure(exc: BaseException) -> bool:
|
||||||
|
"""True only for explicit typed Gitea client / config failures.
|
||||||
|
|
||||||
|
Never true for arbitrary ``RuntimeError`` (reviewer finding #3).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import gitea_auth
|
||||||
|
|
||||||
|
if isinstance(
|
||||||
|
exc,
|
||||||
|
(
|
||||||
|
gitea_auth.GiteaAuthError,
|
||||||
|
gitea_auth.GiteaAuthzError,
|
||||||
|
gitea_auth.GiteaNetworkError,
|
||||||
|
gitea_auth.GiteaConfigError,
|
||||||
|
gitea_auth.GiteaHttpError,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
import gitea_config
|
||||||
|
|
||||||
|
if isinstance(exc, gitea_config.ConfigError):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def classify_exception(exc: BaseException) -> dict[str, Any]:
|
||||||
|
"""Return structured classification with **fixed** messages only.
|
||||||
|
|
||||||
|
Only typed authentication / authorization / network / configuration
|
||||||
|
failures receive those labels. Unexpected programming failures are
|
||||||
|
``internal_error``. HTTP bodies and exception text are never copied
|
||||||
|
into ``message``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _classify_exception_impl(exc)
|
||||||
|
except Exception:
|
||||||
|
# Fail closed: sanitization / classification failure must not leak.
|
||||||
|
return {
|
||||||
|
"reason_code": REASON_INTERNAL_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_INTERNAL,
|
||||||
|
"http_status": None,
|
||||||
|
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _self_declared_classification(exc: BaseException) -> dict[str, Any] | None:
|
||||||
|
"""Honor a safe ``gitea_reason_code`` attribute set by server-side raisers.
|
||||||
|
|
||||||
|
Converts a raw exception that self-declares a **known** reason code into a
|
||||||
|
typed structured error (fixed message) instead of an opaque internal_error.
|
||||||
|
Unknown / non-string / secret values are ignored (fail closed to internal).
|
||||||
|
"""
|
||||||
|
reason = getattr(exc, _SELF_DECLARED_REASON_ATTR, None)
|
||||||
|
if not isinstance(reason, str) or reason not in FIXED_MESSAGES:
|
||||||
|
return None
|
||||||
|
if reason == REASON_INTERNAL_ERROR:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"reason_code": reason,
|
||||||
|
"error_class": _REASON_ERROR_CLASS.get(reason, ERROR_CLASS_INTERNAL),
|
||||||
|
"http_status": None,
|
||||||
|
"message": fixed_message(reason),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
|
||||||
|
import gitea_auth
|
||||||
|
|
||||||
|
declared = _self_declared_classification(exc)
|
||||||
|
if declared is not None:
|
||||||
|
return declared
|
||||||
|
|
||||||
|
if isinstance(exc, gitea_auth.GiteaAuthError):
|
||||||
|
code = getattr(exc, "reason_code", None) or REASON_AUTH_INVALID_TOKEN
|
||||||
|
if code not in (
|
||||||
|
REASON_AUTH_FAILED,
|
||||||
|
REASON_AUTH_INVALID_TOKEN,
|
||||||
|
):
|
||||||
|
code = REASON_AUTH_INVALID_TOKEN
|
||||||
|
return {
|
||||||
|
"reason_code": code,
|
||||||
|
"error_class": ERROR_CLASS_AUTHENTICATION,
|
||||||
|
"http_status": getattr(exc, "http_status", None) or 401,
|
||||||
|
"message": fixed_message(code),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
if isinstance(exc, gitea_auth.GiteaAuthzError):
|
||||||
|
code = getattr(exc, "reason_code", None) or REASON_AUTHZ_DENIED
|
||||||
|
if code not in (REASON_AUTHZ_DENIED, REASON_AUTHZ_INSUFFICIENT_SCOPE):
|
||||||
|
code = REASON_AUTHZ_DENIED
|
||||||
|
return {
|
||||||
|
"reason_code": code,
|
||||||
|
"error_class": ERROR_CLASS_AUTHORIZATION,
|
||||||
|
"http_status": getattr(exc, "http_status", None) or 403,
|
||||||
|
"message": fixed_message(code),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
if isinstance(exc, gitea_auth.GiteaNetworkError):
|
||||||
|
return {
|
||||||
|
"reason_code": REASON_NETWORK_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_NETWORK,
|
||||||
|
"http_status": getattr(exc, "http_status", None),
|
||||||
|
"message": fixed_message(REASON_NETWORK_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
if isinstance(exc, gitea_auth.GiteaConfigError):
|
||||||
|
return {
|
||||||
|
"reason_code": REASON_CONFIG_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_CONFIGURATION,
|
||||||
|
"http_status": getattr(exc, "http_status", None),
|
||||||
|
"message": fixed_message(REASON_CONFIG_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
if isinstance(exc, gitea_auth.GiteaHttpError):
|
||||||
|
code = getattr(exc, "reason_code", None) or REASON_HTTP_ERROR
|
||||||
|
if code == REASON_UPSTREAM_UNAVAILABLE:
|
||||||
|
error_class = ERROR_CLASS_UPSTREAM
|
||||||
|
else:
|
||||||
|
error_class = ERROR_CLASS_INTERNAL
|
||||||
|
code = REASON_HTTP_ERROR
|
||||||
|
return {
|
||||||
|
"reason_code": code,
|
||||||
|
"error_class": error_class,
|
||||||
|
"http_status": getattr(exc, "http_status", None),
|
||||||
|
"message": fixed_message(code),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import gitea_config
|
||||||
|
|
||||||
|
if isinstance(exc, gitea_config.ConfigError):
|
||||||
|
return {
|
||||||
|
"reason_code": REASON_CONFIG_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_CONFIGURATION,
|
||||||
|
"http_status": None,
|
||||||
|
"message": fixed_message(REASON_CONFIG_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Unwrap FastMCP ToolError cause when the original was a typed failure.
|
||||||
|
cause = getattr(exc, "__cause__", None)
|
||||||
|
if cause is not None and cause is not exc and is_known_client_failure(cause):
|
||||||
|
return _classify_exception_impl(cause)
|
||||||
|
|
||||||
|
# No message-substring authentication heuristics (reviewer finding #3).
|
||||||
|
# Unexpected failure → internal_error, now carrying SAFE diagnostics so the
|
||||||
|
# failure is actionable: a type identifier + strictly-redacted detail +
|
||||||
|
# optional mutation-stage tag. The operator ``message`` stays the fixed
|
||||||
|
# constant; none of these fields carry secrets/bodies/paths/env.
|
||||||
|
result = {
|
||||||
|
"reason_code": REASON_INTERNAL_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_INTERNAL,
|
||||||
|
"http_status": None,
|
||||||
|
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
"exception_class": _safe_exception_class(exc),
|
||||||
|
"detail": _redact_detail(_safe_str(exc)),
|
||||||
|
}
|
||||||
|
stage = _safe_mutation_stage(exc)
|
||||||
|
if stage:
|
||||||
|
result["mutation_stage"] = stage
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_structured_error_payload(
|
||||||
|
classification: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tool_name: str | None = None,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""LLM-safe structured payload — fixed message + typed metadata only."""
|
||||||
|
try:
|
||||||
|
reason = str(classification.get("reason_code") or REASON_INTERNAL_ERROR)
|
||||||
|
message = fixed_message(reason)
|
||||||
|
# Refuse to emit any classification message that is not the fixed constant.
|
||||||
|
if classification.get("message") != message:
|
||||||
|
message = fixed_message(reason)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"success": False,
|
||||||
|
"isError": True,
|
||||||
|
"reason_code": reason if reason in FIXED_MESSAGES else REASON_INTERNAL_ERROR,
|
||||||
|
"error_class": classification.get("error_class") or ERROR_CLASS_INTERNAL,
|
||||||
|
"message": message,
|
||||||
|
"transport_survives": True,
|
||||||
|
"retryable": classification.get("error_class")
|
||||||
|
in {
|
||||||
|
ERROR_CLASS_AUTHENTICATION,
|
||||||
|
ERROR_CLASS_NETWORK,
|
||||||
|
ERROR_CLASS_CONFIGURATION,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
status = classification.get("http_status")
|
||||||
|
if isinstance(status, int):
|
||||||
|
payload["http_status"] = status
|
||||||
|
if tool_name and isinstance(tool_name, str):
|
||||||
|
# Tool names are identifiers, not secrets; bound length.
|
||||||
|
payload["tool"] = tool_name[:120]
|
||||||
|
if profile_name and isinstance(profile_name, str):
|
||||||
|
payload["profile"] = profile_name[:80]
|
||||||
|
# Safe diagnostics — internal_error path only. These make an otherwise
|
||||||
|
# opaque crash actionable without leaking secrets. Re-derived here from
|
||||||
|
# the fixed message gate above; typed reasons never carry them.
|
||||||
|
if payload["reason_code"] == REASON_INTERNAL_ERROR:
|
||||||
|
exc_cls = classification.get("exception_class")
|
||||||
|
if isinstance(exc_cls, str) and exc_cls:
|
||||||
|
payload["exception_class"] = exc_cls[:_CLASS_LIMIT]
|
||||||
|
detail = classification.get("detail")
|
||||||
|
if isinstance(detail, str):
|
||||||
|
payload["detail"] = _redact_detail(detail)
|
||||||
|
stage = classification.get("mutation_stage")
|
||||||
|
if isinstance(stage, str) and stage:
|
||||||
|
payload["mutation_stage"] = stage[:_STAGE_LIMIT]
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"isError": True,
|
||||||
|
"reason_code": REASON_INTERNAL_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_INTERNAL,
|
||||||
|
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
"retryable": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_sanitized_daemon_reason(
|
||||||
|
classification: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tool_name: str | None = None,
|
||||||
|
stream=None,
|
||||||
|
) -> None:
|
||||||
|
"""Daemon log: reason codes only — never exception text or response bodies."""
|
||||||
|
stream = stream if stream is not None else sys.stderr
|
||||||
|
try:
|
||||||
|
reason = classification.get("reason_code") or REASON_INTERNAL_ERROR
|
||||||
|
error_class = classification.get("error_class") or ERROR_CLASS_INTERNAL
|
||||||
|
# Only emit known tokens; never classification['message'] from callers
|
||||||
|
# that might have been poisoned.
|
||||||
|
if reason not in FIXED_MESSAGES:
|
||||||
|
reason = REASON_INTERNAL_ERROR
|
||||||
|
error_class = ERROR_CLASS_INTERNAL
|
||||||
|
parts = [
|
||||||
|
"mcp_tool_error",
|
||||||
|
f"reason_code={reason}",
|
||||||
|
f"error_class={error_class}",
|
||||||
|
]
|
||||||
|
if tool_name and isinstance(tool_name, str):
|
||||||
|
parts.append(f"tool={tool_name[:120]}")
|
||||||
|
status = classification.get("http_status")
|
||||||
|
if isinstance(status, int):
|
||||||
|
parts.append(f"http_status={status}")
|
||||||
|
# Safe diagnostics — internal_error path ONLY. Typed reasons still emit
|
||||||
|
# no detail= (secrets lived there). class/detail/stage are re-redacted
|
||||||
|
# here as defence in depth.
|
||||||
|
if reason == REASON_INTERNAL_ERROR:
|
||||||
|
exc_cls = classification.get("exception_class")
|
||||||
|
if isinstance(exc_cls, str) and exc_cls:
|
||||||
|
parts.append(f"exception_class={exc_cls[:_CLASS_LIMIT]}")
|
||||||
|
stage = classification.get("mutation_stage")
|
||||||
|
if isinstance(stage, str) and stage:
|
||||||
|
parts.append(
|
||||||
|
f"mutation_stage={_redact_detail(stage, limit=_STAGE_LIMIT)}"
|
||||||
|
)
|
||||||
|
detail = classification.get("detail")
|
||||||
|
if isinstance(detail, str) and detail:
|
||||||
|
parts.append(f"detail={_redact_detail(detail)}")
|
||||||
|
line = " ".join(parts)
|
||||||
|
stream.write(line + "\n")
|
||||||
|
if hasattr(stream, "flush"):
|
||||||
|
stream.flush()
|
||||||
|
logger.warning(line)
|
||||||
|
except Exception:
|
||||||
|
# Fail closed: never fall back to logging the exception.
|
||||||
|
try:
|
||||||
|
stream.write(
|
||||||
|
"mcp_tool_error reason_code=internal_error "
|
||||||
|
"error_class=internal\n"
|
||||||
|
)
|
||||||
|
if hasattr(stream, "flush"):
|
||||||
|
stream.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def to_call_tool_result(
|
||||||
|
exc: BaseException,
|
||||||
|
*,
|
||||||
|
tool_name: str | None = None,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
log: bool = True,
|
||||||
|
) -> Any:
|
||||||
|
"""Build a FastMCP ``CallToolResult`` with ``isError=True`` for *exc*."""
|
||||||
|
from mcp.types import CallToolResult, TextContent
|
||||||
|
|
||||||
|
try:
|
||||||
|
classification = classify_exception(exc)
|
||||||
|
if log:
|
||||||
|
log_sanitized_daemon_reason(classification, tool_name=tool_name)
|
||||||
|
payload = build_structured_error_payload(
|
||||||
|
classification, tool_name=tool_name, profile_name=profile_name
|
||||||
|
)
|
||||||
|
text = json.dumps(payload, indent=2, sort_keys=True)
|
||||||
|
return CallToolResult(
|
||||||
|
content=[TextContent(type="text", text=text)],
|
||||||
|
structuredContent=payload,
|
||||||
|
isError=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Absolute fail-closed path — no exception text.
|
||||||
|
fallback = {
|
||||||
|
"success": False,
|
||||||
|
"isError": True,
|
||||||
|
"reason_code": REASON_INTERNAL_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_INTERNAL,
|
||||||
|
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
"retryable": False,
|
||||||
|
}
|
||||||
|
return CallToolResult(
|
||||||
|
content=[
|
||||||
|
TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps(fallback, indent=2, sort_keys=True),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
structuredContent=fallback,
|
||||||
|
isError=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install_tool_run_boundary(Tool) -> bool:
|
||||||
|
"""Install a **narrow** error boundary around FastMCP ``Tool.run``.
|
||||||
|
|
||||||
|
Strategy (preserves framework semantics):
|
||||||
|
|
||||||
|
* Call the **original** ``Tool.run`` for the success path (async, return
|
||||||
|
types, convert_result, protocol behavior unchanged).
|
||||||
|
* Re-raise ``UrlElicitationRequiredError`` and other control-flow
|
||||||
|
exceptions without mapping to ``internal_error``.
|
||||||
|
* Map typed Gitea client failures (and ToolError whose ``__cause__`` is
|
||||||
|
typed) to structured ``CallToolResult(isError=True)``.
|
||||||
|
* Map remaining unexpected tool failures to fixed ``internal_error``
|
||||||
|
isError results (transport survival) without secret-bearing text.
|
||||||
|
* Idempotent: second install is a no-op and returns ``False``.
|
||||||
|
"""
|
||||||
|
if getattr(Tool.run, _INSTALL_FLAG, False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
from mcp.server.fastmcp.exceptions import ToolError
|
||||||
|
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||||
|
|
||||||
|
original_run = Tool.run
|
||||||
|
|
||||||
|
async def run_boundary(
|
||||||
|
self,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
context=None,
|
||||||
|
convert_result: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
try:
|
||||||
|
return await original_run(
|
||||||
|
self,
|
||||||
|
arguments,
|
||||||
|
context=context,
|
||||||
|
convert_result=convert_result,
|
||||||
|
)
|
||||||
|
except UrlElicitationRequiredError:
|
||||||
|
# Framework control-flow — must not become internal_error.
|
||||||
|
raise
|
||||||
|
except BaseException as exc:
|
||||||
|
if _is_framework_control_flow(exc):
|
||||||
|
raise
|
||||||
|
if not isinstance(exc, Exception):
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Prefer typed cause under FastMCP ToolError wrappers.
|
||||||
|
target: BaseException = exc
|
||||||
|
if isinstance(exc, ToolError) and exc.__cause__ is not None:
|
||||||
|
target = exc.__cause__
|
||||||
|
|
||||||
|
# Known client failures → structured isError with reason codes.
|
||||||
|
# Unexpected failures → fixed internal_error isError (no secrets).
|
||||||
|
profile_name = _safe_profile_name()
|
||||||
|
return to_call_tool_result(
|
||||||
|
target,
|
||||||
|
tool_name=getattr(self, "name", None),
|
||||||
|
profile_name=profile_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
setattr(run_boundary, _INSTALL_FLAG, True)
|
||||||
|
setattr(run_boundary, _ORIGINAL_ATTR, original_run)
|
||||||
|
Tool.run = run_boundary # type: ignore[method-assign]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def boundary_is_installed(Tool) -> bool:
|
||||||
|
"""True when the #699 boundary is active on *Tool.run*."""
|
||||||
|
return bool(getattr(Tool.run, _INSTALL_FLAG, False))
|
||||||
+43
-9
@@ -1,7 +1,8 @@
|
|||||||
"""Merge approval must pin the current PR head SHA (#471).
|
"""Merge approval must pin the current PR head SHA (#471 / #695).
|
||||||
|
|
||||||
Formal APPROVED reviews that predate the live PR head must not satisfy
|
Formal APPROVED reviews that predate the live PR head must not satisfy
|
||||||
``gitea_merge_pr`` eligibility. Pure assessment helpers are isolated here
|
``gitea_merge_pr`` eligibility. Contaminated / quarantined approvals (#695)
|
||||||
|
must not authorize merge either. Pure assessment helpers are isolated here
|
||||||
for hermetic unit tests apart from MCP HTTP calls.
|
for hermetic unit tests apart from MCP HTTP calls.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ def assess_merge_approval_head(
|
|||||||
*,
|
*,
|
||||||
current_head_sha: str | None,
|
current_head_sha: str | None,
|
||||||
latest_by_reviewer: dict,
|
latest_by_reviewer: dict,
|
||||||
|
quarantined_review_ids: set | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return whether a visible approval applies to the live PR head.
|
"""Return whether a visible approval applies to the live PR head.
|
||||||
|
|
||||||
@@ -19,18 +21,43 @@ def assess_merge_approval_head(
|
|||||||
current_head_sha: Current PR head commit SHA.
|
current_head_sha: Current PR head commit SHA.
|
||||||
latest_by_reviewer: Map of reviewer login → review entry dicts with
|
latest_by_reviewer: Map of reviewer login → review entry dicts with
|
||||||
``verdict``, ``dismissed``, and ``reviewed_head_sha`` keys.
|
``verdict``, ``dismissed``, and ``reviewed_head_sha`` keys.
|
||||||
|
Optional ``review_id`` / ``id`` used for quarantine checks (#695).
|
||||||
|
quarantined_review_ids: Optional set of review IDs that must not count
|
||||||
|
toward merge authorization (#695).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with ``approval_at_current_head``, ``latest_approved_head_sha``,
|
dict with ``approval_at_current_head``, ``latest_approved_head_sha``,
|
||||||
and ``stale_approval_block_reason`` (set when merge must fail closed).
|
and ``stale_approval_block_reason`` (set when merge must fail closed).
|
||||||
"""
|
"""
|
||||||
current = (current_head_sha or "").strip()
|
current = (current_head_sha or "").strip()
|
||||||
approved_entries = [
|
blocked_ids = {int(x) for x in (quarantined_review_ids or set()) if x is not None}
|
||||||
entry
|
|
||||||
for entry in (latest_by_reviewer or {}).values()
|
def _rid(entry: dict):
|
||||||
if (entry.get("verdict") or "").upper() == "APPROVED"
|
raw = entry.get("review_id", entry.get("id"))
|
||||||
and not entry.get("dismissed")
|
try:
|
||||||
]
|
return int(raw) if raw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
approved_entries = []
|
||||||
|
quarantined_at_head = []
|
||||||
|
for entry in (latest_by_reviewer or {}).values():
|
||||||
|
if (entry.get("verdict") or "").upper() != "APPROVED":
|
||||||
|
continue
|
||||||
|
if entry.get("dismissed"):
|
||||||
|
continue
|
||||||
|
rid = _rid(entry)
|
||||||
|
head = (entry.get("reviewed_head_sha") or "").strip()
|
||||||
|
if rid is not None and rid in blocked_ids:
|
||||||
|
if current and head == current:
|
||||||
|
quarantined_at_head.append(entry)
|
||||||
|
continue
|
||||||
|
if entry.get("quarantined"):
|
||||||
|
if current and head == current:
|
||||||
|
quarantined_at_head.append(entry)
|
||||||
|
continue
|
||||||
|
approved_entries.append(entry)
|
||||||
|
|
||||||
at_current = any(
|
at_current = any(
|
||||||
(entry.get("reviewed_head_sha") or "").strip() == current
|
(entry.get("reviewed_head_sha") or "").strip() == current
|
||||||
for entry in approved_entries
|
for entry in approved_entries
|
||||||
@@ -47,7 +74,13 @@ def assess_merge_approval_head(
|
|||||||
)[-1]
|
)[-1]
|
||||||
latest_approved = (latest_entry.get("reviewed_head_sha") or "").strip() or None
|
latest_approved = (latest_entry.get("reviewed_head_sha") or "").strip() or None
|
||||||
reason = None
|
reason = None
|
||||||
if approved_entries and not at_current:
|
if quarantined_at_head and not at_current:
|
||||||
|
reason = (
|
||||||
|
"contaminated/quarantined approval at current head is void for "
|
||||||
|
"merge authorization (#695); required next action: fresh native "
|
||||||
|
"MCP re-review after controller quarantine evidence is recorded"
|
||||||
|
)
|
||||||
|
elif approved_entries and not at_current:
|
||||||
reason = (
|
reason = (
|
||||||
f"stale approval: approved SHA '{latest_approved}' does not match "
|
f"stale approval: approved SHA '{latest_approved}' does not match "
|
||||||
f"current live PR head SHA '{current or '(unknown)'}' (fail closed); "
|
f"current live PR head SHA '{current or '(unknown)'}' (fail closed); "
|
||||||
@@ -58,4 +91,5 @@ def assess_merge_approval_head(
|
|||||||
"approval_at_current_head": at_current,
|
"approval_at_current_head": at_current,
|
||||||
"latest_approved_head_sha": latest_approved,
|
"latest_approved_head_sha": latest_approved,
|
||||||
"stale_approval_block_reason": reason,
|
"stale_approval_block_reason": reason,
|
||||||
|
"quarantined_approvals_at_current_head": len(quarantined_at_head),
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ after formal approval at the current PR head.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -17,16 +18,34 @@ DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
|
|||||||
|
|
||||||
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||||
|
SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease"
|
||||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||||
|
SOURCE_RELEASE_MERGER = "gitea_release_merger_pr_lease"
|
||||||
|
|
||||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||||
SOURCE_ADOPT,
|
SOURCE_ADOPT,
|
||||||
SOURCE_ACQUIRE,
|
SOURCE_ACQUIRE,
|
||||||
|
SOURCE_ACQUIRE_MERGER,
|
||||||
SOURCE_HEARTBEAT,
|
SOURCE_HEARTBEAT,
|
||||||
})
|
})
|
||||||
|
|
||||||
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
|
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
|
||||||
|
|
||||||
|
# #742: owner-session terminal finalization of a merger-held lease. Append-only
|
||||||
|
# marker; ledger history is never edited or deleted.
|
||||||
|
MERGER_FINALIZATION_MARKER = "<!-- mcp-merger-lease-final:v1 -->"
|
||||||
|
OUTCOME_RELEASED = "released"
|
||||||
|
OUTCOME_ABANDONED = "abandoned"
|
||||||
|
MERGER_FINALIZATION_OUTCOMES = frozenset({OUTCOME_RELEASED, OUTCOME_ABANDONED})
|
||||||
|
DEFAULT_MERGER_FINALIZATION_REASON = "merge-not-performed"
|
||||||
|
|
||||||
|
# Provenance sources whose in-session lease is merger-owned and therefore
|
||||||
|
# finalizable by its owning merger session.
|
||||||
|
MERGER_OWNED_PROVENANCE_SOURCES = frozenset({
|
||||||
|
SOURCE_ACQUIRE_MERGER,
|
||||||
|
SOURCE_ADOPT,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def format_adoption_body(
|
def format_adoption_body(
|
||||||
*,
|
*,
|
||||||
@@ -94,6 +113,7 @@ def build_lease_provenance(
|
|||||||
adopted_from_profile: str | None = None,
|
adopted_from_profile: str | None = None,
|
||||||
adopted_from_reviewer_identity: str | None = None,
|
adopted_from_reviewer_identity: str | None = None,
|
||||||
adoption_reason: str | None = None,
|
adoption_reason: str | None = None,
|
||||||
|
native_token_fingerprint: str | None = None,
|
||||||
recorded_at: datetime | None = None,
|
recorded_at: datetime | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
recorded_at = recorded_at or datetime.now(timezone.utc)
|
recorded_at = recorded_at or datetime.now(timezone.utc)
|
||||||
@@ -114,9 +134,76 @@ def build_lease_provenance(
|
|||||||
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
|
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
|
||||||
if adoption_reason:
|
if adoption_reason:
|
||||||
proof["adoption_reason"] = adoption_reason
|
proof["adoption_reason"] = adoption_reason
|
||||||
|
if native_token_fingerprint:
|
||||||
|
proof["native_token_fingerprint"] = native_token_fingerprint
|
||||||
return proof
|
return proof
|
||||||
|
|
||||||
|
|
||||||
|
def assess_acquired_merger_lease_integrity(
|
||||||
|
session: dict[str, Any] | None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Ownership/integrity reasons blocking a ``SOURCE_ACQUIRE_MERGER`` lease (#742).
|
||||||
|
|
||||||
|
A merger lease minted by ``gitea_acquire_merger_pr_lease`` authorizes an
|
||||||
|
irreversible merge, so it is sanctioned only when the in-session record is
|
||||||
|
complete and self-consistent: comment marker, exact session identity, merger
|
||||||
|
profile/role, repository, PR number, and pinned candidate head. Any missing
|
||||||
|
or contradictory field fails closed — an incomplete record is not proof.
|
||||||
|
"""
|
||||||
|
if not session:
|
||||||
|
return ["no in-session lease recorded"]
|
||||||
|
provenance = session.get("lease_provenance") or {}
|
||||||
|
if not isinstance(provenance, dict):
|
||||||
|
provenance = {}
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
provenance_comment_id = provenance.get("comment_id")
|
||||||
|
session_comment_id = session.get("comment_id")
|
||||||
|
if not (provenance_comment_id or session_comment_id):
|
||||||
|
reasons.append(
|
||||||
|
"acquired merger lease has no comment marker id (comment-backed "
|
||||||
|
"proof required)"
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
provenance_comment_id is not None
|
||||||
|
and session_comment_id is not None
|
||||||
|
and provenance_comment_id != session_comment_id
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
"acquired merger lease provenance comment_id does not match the "
|
||||||
|
"session lease comment_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (session.get("session_id") or "").strip():
|
||||||
|
reasons.append("acquired merger lease has no session_id")
|
||||||
|
if not (session.get("reviewer_identity") or "").strip():
|
||||||
|
reasons.append("acquired merger lease has no holder identity")
|
||||||
|
|
||||||
|
profile = (session.get("profile") or "").strip()
|
||||||
|
if not profile:
|
||||||
|
reasons.append("acquired merger lease has no profile")
|
||||||
|
elif "merger" not in profile.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"acquired merger lease profile '{profile}' is not a merger profile "
|
||||||
|
"(merger-only; fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (session.get("repo") or "").strip():
|
||||||
|
reasons.append("acquired merger lease has no repository")
|
||||||
|
|
||||||
|
pr_number = session.get("pr_number")
|
||||||
|
if not isinstance(pr_number, int) or isinstance(pr_number, bool) or pr_number <= 0:
|
||||||
|
reasons.append("acquired merger lease has no valid PR number")
|
||||||
|
|
||||||
|
if not leases._normalize_sha(session.get("candidate_head")):
|
||||||
|
reasons.append(
|
||||||
|
"acquired merger lease has no pinned candidate_head (exact-head "
|
||||||
|
"scoping required)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
||||||
if not session:
|
if not session:
|
||||||
return False
|
return False
|
||||||
@@ -128,11 +215,388 @@ def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
|||||||
return bool(provenance.get("comment_id")) and bool(
|
return bool(provenance.get("comment_id")) and bool(
|
||||||
provenance.get("adopted_from_session_id")
|
provenance.get("adopted_from_session_id")
|
||||||
)
|
)
|
||||||
|
if source == SOURCE_ACQUIRE_MERGER:
|
||||||
|
return not assess_acquired_merger_lease_integrity(session)
|
||||||
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
|
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
|
||||||
return bool(session.get("comment_id") or provenance.get("comment_id"))
|
return bool(session.get("comment_id") or provenance.get("comment_id"))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def describe_session_lease_proof(
|
||||||
|
session: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Canonical merge-report lease proof fields (#535).
|
||||||
|
|
||||||
|
Surfaces how the in-session lease was established so merge handoffs can
|
||||||
|
distinguish sanctioned MCP acquire/adopt paths from bare manual seeding.
|
||||||
|
"""
|
||||||
|
if not session:
|
||||||
|
return {
|
||||||
|
"lease_proof_source": None,
|
||||||
|
"lease_recovery_reason": None,
|
||||||
|
"lease_proof_kind": "none",
|
||||||
|
"lease_proof_sanctioned": False,
|
||||||
|
"lease_proof_comment_id": None,
|
||||||
|
"lease_adopted_from_session_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
provenance = session.get("lease_provenance") or {}
|
||||||
|
if not isinstance(provenance, dict):
|
||||||
|
provenance = {}
|
||||||
|
source = (provenance.get("source") or "").strip() or None
|
||||||
|
sanctioned = is_sanctioned_session_lease(session)
|
||||||
|
reason = provenance.get("adoption_reason")
|
||||||
|
if isinstance(reason, str):
|
||||||
|
reason = reason.strip() or None
|
||||||
|
else:
|
||||||
|
reason = None
|
||||||
|
|
||||||
|
if source == SOURCE_ADOPT and sanctioned:
|
||||||
|
kind = "sanctioned_adoption"
|
||||||
|
reason = reason or DEFAULT_ADOPTION_REASON
|
||||||
|
elif source == SOURCE_ACQUIRE_MERGER and sanctioned:
|
||||||
|
kind = "sanctioned_acquire_merger"
|
||||||
|
elif source == SOURCE_ACQUIRE and sanctioned:
|
||||||
|
kind = "sanctioned_acquire"
|
||||||
|
elif source == SOURCE_HEARTBEAT and sanctioned:
|
||||||
|
kind = "sanctioned_heartbeat"
|
||||||
|
elif source:
|
||||||
|
kind = "unsanctioned"
|
||||||
|
else:
|
||||||
|
# Session present without provenance = classic manual _SESSION_LEASE seed.
|
||||||
|
kind = "unsanctioned_manual_seed"
|
||||||
|
|
||||||
|
comment_id = provenance.get("comment_id")
|
||||||
|
if comment_id is None:
|
||||||
|
comment_id = session.get("comment_id")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"lease_proof_source": source,
|
||||||
|
"lease_recovery_reason": reason,
|
||||||
|
"lease_proof_kind": kind,
|
||||||
|
"lease_proof_sanctioned": sanctioned,
|
||||||
|
"lease_proof_comment_id": comment_id,
|
||||||
|
"lease_adopted_from_session_id": provenance.get("adopted_from_session_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Phrases that claim non-MCP / fabricated lease proof in handoffs (#535).
|
||||||
|
_UNSAFE_LEASE_PROOF_CLAIM_RE = re.compile(
|
||||||
|
r"(?is)\b("
|
||||||
|
r"manually\s+seeded\s+lease|"
|
||||||
|
r"manual(?:ly)?\s+seed(?:ed)?\s+(?:lease|proof)|"
|
||||||
|
r"seeded\s+lease\s+proof|"
|
||||||
|
r"injected\s+lease|"
|
||||||
|
r"lease\s+proof\s+injected|"
|
||||||
|
r"manual\s+_?SESSION_LEASE|"
|
||||||
|
r"_SESSION_LEASE\s+seed|"
|
||||||
|
r"unsanctioned\s+lease\s+proof"
|
||||||
|
r")\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Evidence that the report used the sanctioned MCP adoption/acquire path.
|
||||||
|
_SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
|
||||||
|
r"(?is)\b("
|
||||||
|
r"gitea_adopt_merger_pr_lease|"
|
||||||
|
r"gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"gitea_acquire_merger_pr_lease|"
|
||||||
|
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
||||||
|
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"lease_proof_source\s*[:=]\s*gitea_acquire_merger_pr_lease|"
|
||||||
|
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
||||||
|
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire_merger|"
|
||||||
|
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
||||||
|
r"adoption_comment_id\s*[:=]|"
|
||||||
|
r"sanctioned_adoption|"
|
||||||
|
r"mcp-review-lease-adoption"
|
||||||
|
r")\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_manual_lease_proof_handoff(report_text: str) -> dict[str, Any]:
|
||||||
|
"""Controller audit: block handoffs that claim unsafe lease seeding (#535).
|
||||||
|
|
||||||
|
Reports may discuss manual seeding as a blocked/historical anti-pattern only
|
||||||
|
when they also cite sanctioned MCP adoption/acquire evidence. Bare claims of
|
||||||
|
manual/seeded/injected lease proof without that evidence fail closed.
|
||||||
|
"""
|
||||||
|
text = report_text or ""
|
||||||
|
if not _UNSAFE_LEASE_PROOF_CLAIM_RE.search(text):
|
||||||
|
return {"proven": True, "block": False, "reasons": []}
|
||||||
|
if _SANCTIONED_LEASE_EVIDENCE_RE.search(text):
|
||||||
|
return {"proven": True, "block": False, "reasons": []}
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"reasons": [
|
||||||
|
"report claims manual/seeded/injected/unsanctioned lease proof without "
|
||||||
|
"sanctioned MCP adoption evidence (use gitea_adopt_merger_pr_lease / "
|
||||||
|
"gitea_acquire_reviewer_pr_lease and cite lease_proof_source; #535)"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_merger_finalization_body(
|
||||||
|
*,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
issue_number: int | None,
|
||||||
|
merger_identity: str,
|
||||||
|
merger_profile: str,
|
||||||
|
merger_session_id: str,
|
||||||
|
worktree: str,
|
||||||
|
candidate_head: str | None,
|
||||||
|
target_branch: str,
|
||||||
|
target_branch_sha: str | None,
|
||||||
|
outcome: str,
|
||||||
|
reason: str,
|
||||||
|
lease_comment_id: int | None,
|
||||||
|
finalized_at: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Render the append-only terminal marker for a merger-owned lease (#742).
|
||||||
|
|
||||||
|
The body carries a standard lease marker in a terminal phase, so the
|
||||||
|
existing newest-wins ledger (#577) ends the lease without editing or
|
||||||
|
deleting any prior comment.
|
||||||
|
"""
|
||||||
|
finalized_at = finalized_at or datetime.now(timezone.utc)
|
||||||
|
finalized_text = finalized_at.astimezone(timezone.utc).replace(
|
||||||
|
microsecond=0
|
||||||
|
).isoformat().replace("+00:00", "Z")
|
||||||
|
lease_body = leases.format_lease_body(
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
issue_number=issue_number,
|
||||||
|
reviewer_identity=merger_identity,
|
||||||
|
profile=merger_profile,
|
||||||
|
session_id=merger_session_id,
|
||||||
|
worktree=worktree,
|
||||||
|
phase=outcome,
|
||||||
|
candidate_head=candidate_head,
|
||||||
|
target_branch=target_branch,
|
||||||
|
target_branch_sha=target_branch_sha,
|
||||||
|
last_activity=finalized_at,
|
||||||
|
blocker=reason,
|
||||||
|
)
|
||||||
|
lines = [
|
||||||
|
MERGER_FINALIZATION_MARKER,
|
||||||
|
f"finalized_at: {finalized_text}",
|
||||||
|
f"finalized_by_identity: {merger_identity}",
|
||||||
|
f"finalized_by_profile: {merger_profile}",
|
||||||
|
f"finalized_by_session_id: {merger_session_id}",
|
||||||
|
f"finalization_outcome: {outcome}",
|
||||||
|
f"finalization_reason: {reason}",
|
||||||
|
f"finalized_lease_comment_id: {lease_comment_id or 'none'}",
|
||||||
|
lease_body,
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def is_merger_finalization_comment(body: str) -> bool:
|
||||||
|
return MERGER_FINALIZATION_MARKER in (body or "")
|
||||||
|
|
||||||
|
|
||||||
|
def assess_merger_lease_finalization(
|
||||||
|
comments: list[dict],
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
session: dict[str, Any] | None,
|
||||||
|
actor_identity: str,
|
||||||
|
actor_profile: str,
|
||||||
|
actor_session_id: str | None,
|
||||||
|
repo: str,
|
||||||
|
worktree: str,
|
||||||
|
candidate_head: str | None,
|
||||||
|
live_head_sha: str | None = None,
|
||||||
|
outcome: str = OUTCOME_RELEASED,
|
||||||
|
reason: str | None = None,
|
||||||
|
runtime_token_fingerprint: str | None = None,
|
||||||
|
issue_number: int | None = None,
|
||||||
|
target_branch: str = "master",
|
||||||
|
target_branch_sha: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Decide whether a merger may terminally finalize its own lease (#742).
|
||||||
|
|
||||||
|
Owner-session only: the caller must hold the exact comment-backed merger
|
||||||
|
lease it is finalizing. Foreign sessions, reviewer profiles, and mismatched
|
||||||
|
repository/PR/head/token-fingerprint callers fail closed. Already-terminal
|
||||||
|
leases return ``already_terminal`` so repeat calls are idempotent and post
|
||||||
|
no second marker.
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
reasons: list[str] = []
|
||||||
|
outcome = (outcome or "").strip().lower()
|
||||||
|
finalization_reason = (reason or "").strip() or DEFAULT_MERGER_FINALIZATION_REASON
|
||||||
|
|
||||||
|
if outcome not in MERGER_FINALIZATION_OUTCOMES:
|
||||||
|
reasons.append(
|
||||||
|
f"outcome '{outcome or 'none'}' is not a terminal merger "
|
||||||
|
f"finalization outcome ({sorted(MERGER_FINALIZATION_OUTCOMES)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "merger" not in (actor_profile or "").lower():
|
||||||
|
reasons.append(
|
||||||
|
f"profile '{actor_profile or 'unknown'}' is not a merger profile; "
|
||||||
|
"merger lease finalization is merger-only (fail closed). Reviewer "
|
||||||
|
"sessions use gitea_release_reviewer_pr_lease."
|
||||||
|
)
|
||||||
|
|
||||||
|
session = session or None
|
||||||
|
provenance = (session or {}).get("lease_provenance") or {}
|
||||||
|
if not isinstance(provenance, dict):
|
||||||
|
provenance = {}
|
||||||
|
source = (provenance.get("source") or "").strip()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
reasons.append(
|
||||||
|
"no in-session merger lease recorded; only the owning session may "
|
||||||
|
"finalize a merger lease"
|
||||||
|
)
|
||||||
|
elif source not in MERGER_OWNED_PROVENANCE_SOURCES:
|
||||||
|
reasons.append(
|
||||||
|
f"in-session lease provenance '{source or 'none'}' is not a "
|
||||||
|
"merger-owned lease; refusing to finalize"
|
||||||
|
)
|
||||||
|
elif not is_sanctioned_session_lease(session):
|
||||||
|
reasons.extend(
|
||||||
|
assess_acquired_merger_lease_integrity(session)
|
||||||
|
if source == SOURCE_ACQUIRE_MERGER
|
||||||
|
else ["in-session merger lease lacks sanctioned provenance"]
|
||||||
|
)
|
||||||
|
|
||||||
|
pinned = leases._normalize_sha(candidate_head)
|
||||||
|
live = leases._normalize_sha(live_head_sha)
|
||||||
|
if not pinned:
|
||||||
|
reasons.append(
|
||||||
|
"candidate_head is required for merger lease finalization "
|
||||||
|
"(exact-head scoping; fail closed)"
|
||||||
|
)
|
||||||
|
if live and pinned and live != pinned:
|
||||||
|
reasons.append(
|
||||||
|
"candidate_head does not match live PR head (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if session:
|
||||||
|
session_sid = (session.get("session_id") or "").strip()
|
||||||
|
actor_sid = (actor_session_id or "").strip()
|
||||||
|
if not actor_sid or session_sid != actor_sid:
|
||||||
|
reasons.append(
|
||||||
|
"session_id does not match the in-session merger lease owner; "
|
||||||
|
"foreign-session release is not permitted (fail closed)"
|
||||||
|
)
|
||||||
|
if session.get("pr_number") != pr_number:
|
||||||
|
reasons.append(
|
||||||
|
f"in-session merger lease is for PR #{session.get('pr_number')}, "
|
||||||
|
f"not #{pr_number}"
|
||||||
|
)
|
||||||
|
session_repo = (session.get("repo") or "").strip()
|
||||||
|
if session_repo and session_repo != (repo or "").strip():
|
||||||
|
reasons.append(
|
||||||
|
f"in-session merger lease repository '{session_repo}' does not "
|
||||||
|
f"match '{repo}' (fail closed)"
|
||||||
|
)
|
||||||
|
session_head = leases._normalize_sha(session.get("candidate_head"))
|
||||||
|
if pinned and session_head and session_head != pinned:
|
||||||
|
reasons.append(
|
||||||
|
"in-session merger lease candidate_head does not match the "
|
||||||
|
"supplied candidate_head (fail closed)"
|
||||||
|
)
|
||||||
|
session_identity = (session.get("reviewer_identity") or "").strip()
|
||||||
|
if session_identity and session_identity != (actor_identity or "").strip():
|
||||||
|
reasons.append(
|
||||||
|
"authenticated identity does not match the in-session merger "
|
||||||
|
"lease holder (fail closed)"
|
||||||
|
)
|
||||||
|
session_profile = (session.get("profile") or "").strip()
|
||||||
|
if session_profile and session_profile != (actor_profile or "").strip():
|
||||||
|
reasons.append(
|
||||||
|
"active profile does not match the in-session merger lease "
|
||||||
|
"profile (fail closed)"
|
||||||
|
)
|
||||||
|
recorded_fingerprint = (
|
||||||
|
provenance.get("native_token_fingerprint") or ""
|
||||||
|
).strip()
|
||||||
|
live_fingerprint = (runtime_token_fingerprint or "").strip()
|
||||||
|
if (
|
||||||
|
recorded_fingerprint
|
||||||
|
and live_fingerprint
|
||||||
|
and recorded_fingerprint != live_fingerprint
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
"native runtime token fingerprint does not match the one "
|
||||||
|
"recorded when the merger lease was acquired (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
lease_comment_id = (session or {}).get("comment_id") or provenance.get("comment_id")
|
||||||
|
entries = leases._lease_entries(comments, pr_number=pr_number)
|
||||||
|
if session and lease_comment_id is not None:
|
||||||
|
if not any(entry.get("comment_id") == lease_comment_id for entry in entries):
|
||||||
|
reasons.append(
|
||||||
|
f"lease marker comment {lease_comment_id} is not present on PR "
|
||||||
|
f"#{pr_number} (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
active = leases.find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||||
|
already_terminal = False
|
||||||
|
if active:
|
||||||
|
owner_session = (active.get("session_id") or "").strip()
|
||||||
|
if owner_session and owner_session != (actor_session_id or "").strip():
|
||||||
|
reasons.append(
|
||||||
|
f"active PR lease is owned by session_id={owner_session}; a "
|
||||||
|
"merger may only finalize its own lease (fail closed)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
newest = entries[-1] if entries else None
|
||||||
|
newest_phase = ((newest or {}).get("phase") or "").strip().lower()
|
||||||
|
if newest and newest_phase in leases._TERMINAL_PHASES:
|
||||||
|
already_terminal = True
|
||||||
|
elif not entries:
|
||||||
|
reasons.append(
|
||||||
|
f"no comment-backed lease marker found on PR #{pr_number}"
|
||||||
|
)
|
||||||
|
|
||||||
|
finalize_allowed = not reasons and not already_terminal
|
||||||
|
body = None
|
||||||
|
if finalize_allowed and session:
|
||||||
|
body = format_merger_finalization_body(
|
||||||
|
repo=(session.get("repo") or repo),
|
||||||
|
pr_number=pr_number,
|
||||||
|
issue_number=(
|
||||||
|
issue_number
|
||||||
|
if issue_number is not None
|
||||||
|
else session.get("issue_number")
|
||||||
|
),
|
||||||
|
merger_identity=actor_identity,
|
||||||
|
merger_profile=actor_profile,
|
||||||
|
merger_session_id=(actor_session_id or ""),
|
||||||
|
worktree=worktree or (session.get("worktree") or ""),
|
||||||
|
candidate_head=pinned,
|
||||||
|
target_branch=(
|
||||||
|
session.get("target_branch") or target_branch or "master"
|
||||||
|
),
|
||||||
|
target_branch_sha=(
|
||||||
|
session.get("target_branch_sha") or target_branch_sha
|
||||||
|
),
|
||||||
|
outcome=outcome,
|
||||||
|
reason=finalization_reason,
|
||||||
|
lease_comment_id=lease_comment_id,
|
||||||
|
finalized_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"finalize_allowed": finalize_allowed,
|
||||||
|
"already_terminal": already_terminal,
|
||||||
|
"reasons": reasons,
|
||||||
|
"outcome": outcome,
|
||||||
|
"finalization_reason": finalization_reason,
|
||||||
|
"active_lease": active,
|
||||||
|
"finalization_body": body,
|
||||||
|
"lease_comment_id": lease_comment_id,
|
||||||
|
"candidate_head": pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def assess_adopt_merger_lease(
|
def assess_adopt_merger_lease(
|
||||||
comments: list[dict],
|
comments: list[dict],
|
||||||
*,
|
*,
|
||||||
|
|||||||
+127
-8
@@ -20,12 +20,114 @@ if PROJECT_ROOT not in sys.path:
|
|||||||
import gitea_config
|
import gitea_config
|
||||||
|
|
||||||
|
|
||||||
AUTHOR_DEFAULT_ALLOWED = ["read", "branch", "commit", "push", "open_pr", "comment"]
|
# Defaults emit *canonical* operation names only. Shorthand that is not in
|
||||||
AUTHOR_DEFAULT_FORBIDDEN = ["approve", "request_changes", "merge"]
|
# gitea_config.GITEA_OPERATION_ALIASES (e.g. ``pr.close``, ``issue.close``)
|
||||||
REVIEWER_DEFAULT_ALLOWED = [
|
# is silently dropped by the production loader and must never appear here.
|
||||||
"read", "review", "comment", "approve", "request_changes", "merge"
|
AUTHOR_DEFAULT_ALLOWED = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
]
|
]
|
||||||
REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"]
|
AUTHOR_DEFAULT_FORBIDDEN = [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
]
|
||||||
|
REVIEWER_DEFAULT_ALLOWED = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
]
|
||||||
|
REVIEWER_DEFAULT_FORBIDDEN = [
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.create",
|
||||||
|
]
|
||||||
|
# Required reconciler ops (read + pr.close) plus recommended comment/close and
|
||||||
|
# branch.delete for guarded merged-PR cleanup. All names must normalize via
|
||||||
|
# gitea_config.normalize_operation without being dropped.
|
||||||
|
RECONCILER_DEFAULT_ALLOWED = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.close",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
]
|
||||||
|
RECONCILER_DEFAULT_FORBIDDEN = [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Migration-only expansions for common shorthands that are *not* in
|
||||||
|
# GITEA_OPERATION_ALIASES. Emitted output is always the canonical form so a
|
||||||
|
# second canonicalize pass is a no-op (idempotent).
|
||||||
|
_MIGRATION_ONLY_ALIASES = {
|
||||||
|
"pr.close": "gitea.pr.close",
|
||||||
|
"pr.comment": "gitea.pr.comment",
|
||||||
|
"issue.close": "gitea.issue.close",
|
||||||
|
"branch.delete": "gitea.branch.delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reconciler required ops that must survive migration (from reconciler_profile).
|
||||||
|
RECONCILER_REQUIRED_CANONICAL = ("gitea.read", "gitea.pr.close")
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_operation(op: str) -> str:
|
||||||
|
"""Return a canonical operation name accepted by the production loader.
|
||||||
|
|
||||||
|
Fail closed on unknown/ambiguous spellings so required permissions cannot
|
||||||
|
be silently dropped by ``check_operation`` later.
|
||||||
|
"""
|
||||||
|
if not isinstance(op, str) or not op.strip():
|
||||||
|
raise ValueError("operation must be a non-empty string (fail closed)")
|
||||||
|
op = op.strip()
|
||||||
|
try:
|
||||||
|
return gitea_config.normalize_operation(op)
|
||||||
|
except gitea_config.ConfigError:
|
||||||
|
pass
|
||||||
|
if op in _MIGRATION_ONLY_ALIASES:
|
||||||
|
return _MIGRATION_ONLY_ALIASES[op]
|
||||||
|
raise ValueError(
|
||||||
|
f"operation {op!r} cannot be canonicalized for migration "
|
||||||
|
"(unknown/ambiguous; fail closed — production loader would drop it)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_operations(ops, *, context: str = "operations") -> list[str]:
|
||||||
|
"""Canonicalize a list of operations; preserve order, drop duplicates."""
|
||||||
|
if not isinstance(ops, list):
|
||||||
|
raise ValueError(f"{context} must be a list (fail closed)")
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for entry in ops:
|
||||||
|
canon = canonicalize_operation(entry)
|
||||||
|
if canon not in seen:
|
||||||
|
seen.add(canon)
|
||||||
|
out.append(canon)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_reconciler_required_survive(allowed: list[str], profile_name: str) -> None:
|
||||||
|
"""Fail visibly when migration would leave a reconciler without required ops."""
|
||||||
|
missing = [op for op in RECONCILER_REQUIRED_CANONICAL if op not in set(allowed)]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"Profile '{profile_name}' (reconciler) is missing required "
|
||||||
|
f"operation(s) after migration: {missing}. Refusing to emit a "
|
||||||
|
"profile that would silently fail pr.close / read (fail closed)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def infer_role(name, execution_profile):
|
def infer_role(name, execution_profile):
|
||||||
@@ -90,9 +192,11 @@ def migrate_v1_to_v2(v1_data):
|
|||||||
ident_name = "reviewer"
|
ident_name = "reviewer"
|
||||||
elif role == "author":
|
elif role == "author":
|
||||||
ident_name = "author"
|
ident_name = "author"
|
||||||
|
elif role == "reconciler":
|
||||||
|
ident_name = "reconciler"
|
||||||
else:
|
else:
|
||||||
role = prof.get("role")
|
role = prof.get("role")
|
||||||
if role not in (None, "author", "reviewer"):
|
if role not in (None, "author", "reviewer", "reconciler"):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Profile '{name}' has unsupported role {role!r}"
|
f"Profile '{name}' has unsupported role {role!r}"
|
||||||
)
|
)
|
||||||
@@ -124,20 +228,35 @@ def migrate_v1_to_v2(v1_data):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Profile '{name}' operation fields must be lists"
|
f"Profile '{name}' operation fields must be lists"
|
||||||
)
|
)
|
||||||
identity_data["allowed_operations"] = list(allowed)
|
try:
|
||||||
identity_data["forbidden_operations"] = list(forbidden)
|
identity_data["allowed_operations"] = canonicalize_operations(
|
||||||
|
allowed, context=f"profile '{name}' allowed_operations"
|
||||||
|
)
|
||||||
|
identity_data["forbidden_operations"] = canonicalize_operations(
|
||||||
|
forbidden, context=f"profile '{name}' forbidden_operations"
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Profile '{name}': {exc}") from exc
|
||||||
elif role == "author":
|
elif role == "author":
|
||||||
identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED)
|
identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED)
|
||||||
identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN)
|
identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN)
|
||||||
elif role == "reviewer":
|
elif role == "reviewer":
|
||||||
identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED)
|
identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED)
|
||||||
identity_data["forbidden_operations"] = list(REVIEWER_DEFAULT_FORBIDDEN)
|
identity_data["forbidden_operations"] = list(REVIEWER_DEFAULT_FORBIDDEN)
|
||||||
|
elif role == "reconciler":
|
||||||
|
identity_data["allowed_operations"] = list(RECONCILER_DEFAULT_ALLOWED)
|
||||||
|
identity_data["forbidden_operations"] = list(RECONCILER_DEFAULT_FORBIDDEN)
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Profile '{name}' has no explicit operation lists and no "
|
f"Profile '{name}' has no explicit operation lists and no "
|
||||||
"unambiguous author/reviewer role marker (fail closed)"
|
"unambiguous author/reviewer role marker (fail closed)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if role == "reconciler":
|
||||||
|
_assert_reconciler_required_survive(
|
||||||
|
identity_data["allowed_operations"], name
|
||||||
|
)
|
||||||
|
|
||||||
# Nest inside environments/services structure
|
# Nest inside environments/services structure
|
||||||
env = environments.setdefault(env_name, {})
|
env = environments.setdefault(env_name, {})
|
||||||
services = env.setdefault("services", {})
|
services = env.setdefault("services", {})
|
||||||
|
|||||||
+198
-39
@@ -55,24 +55,86 @@ def resolve_namespace_workspace(
|
|||||||
process_project_root: str,
|
process_project_root: str,
|
||||||
env: dict[str, str] | os._Environ | None = None,
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
|
session_lock_worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
|
demotions: list[str] | None = None,
|
||||||
|
verify_paths: bool = False,
|
||||||
|
durable_author_result: dict | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
|
"""Return ``(resolved_path, binding_source)`` for *role_kind*.
|
||||||
|
|
||||||
|
With *verify_paths*, env-sourced candidates whose path no longer exists
|
||||||
|
are demoted (#702) for non-author roles: a binding to a deleted worktree
|
||||||
|
can never name a valid task workspace, so resolution falls through to the
|
||||||
|
next candidate. Explicit arguments are never demoted — a caller-declared
|
||||||
|
path must fail loudly downstream rather than silently rebind. Demotion
|
||||||
|
notes are appended to *demotions* when provided.
|
||||||
|
|
||||||
|
Author role (#618): never demotes a missing configured binding to the
|
||||||
|
control checkout. When *verify_paths* is true, resolution goes through
|
||||||
|
:func:`author_mutation_worktree.resolve_durable_author_worktree` so
|
||||||
|
mutations either use an explicit validated worktree, derive from the
|
||||||
|
active author issue lock, or fail closed. Runtime-context and mutation
|
||||||
|
guards resolve through :func:`resolve_namespace_mutation_context`, which
|
||||||
|
always verifies.
|
||||||
|
"""
|
||||||
env_map = env if env is not None else os.environ
|
env_map = env if env is not None else os.environ
|
||||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||||
|
|
||||||
for candidate, source in (
|
# #618: durable author resolution — no silent control/master fallback.
|
||||||
(worktree_path, "worktree_path argument"),
|
if role == "author" and verify_paths:
|
||||||
(worktree, "worktree argument"),
|
durable = durable_author_result
|
||||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
|
if durable is None:
|
||||||
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
|
durable = amw.resolve_durable_author_worktree(
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
worktree=worktree,
|
||||||
|
process_project_root=process_project_root,
|
||||||
|
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||||
|
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
||||||
|
session_lock_worktree=session_lock_worktree,
|
||||||
|
profile_name=profile_name,
|
||||||
|
# Path selection only here; full validation is re-run in
|
||||||
|
# resolve_namespace_mutation_context with the canonical root.
|
||||||
|
validate=False,
|
||||||
|
)
|
||||||
|
workspace = durable.get("workspace_path") or os.path.realpath(
|
||||||
|
process_project_root
|
||||||
|
)
|
||||||
|
source = durable.get("workspace_binding_source") or "no author worktree binding"
|
||||||
|
if demotions is not None and durable.get("bound_worktree_missing"):
|
||||||
|
demotions.append(
|
||||||
|
f"{source} '{workspace}' not demoted: {amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
||||||
|
)
|
||||||
|
return workspace, source
|
||||||
|
|
||||||
|
for candidate, source, env_sourced in (
|
||||||
|
(worktree_path, "worktree_path argument", False),
|
||||||
|
(worktree, "worktree argument", False),
|
||||||
|
(_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||||
|
f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
||||||
|
(_env_value(env_map, role_env_key),
|
||||||
|
f"{role_env_key} environment variable", True),
|
||||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||||
"reviewer PR lease worktree"),
|
"reviewer PR lease worktree", False),
|
||||||
|
# Author lock derivation is handled by the durable path above when
|
||||||
|
# verify_paths is true; when verify_paths is false, surface the lock
|
||||||
|
# path as a non-demoted candidate so tooling can inspect it.
|
||||||
|
(session_lock_worktree if role == "author" else None,
|
||||||
|
"active author issue lock worktree", False),
|
||||||
):
|
):
|
||||||
text = (candidate or "").strip()
|
text = (candidate or "").strip()
|
||||||
if text:
|
if not text:
|
||||||
return os.path.realpath(os.path.abspath(text)), source
|
continue
|
||||||
|
real = os.path.realpath(os.path.abspath(text))
|
||||||
|
if verify_paths and env_sourced and not os.path.isdir(real):
|
||||||
|
if demotions is not None:
|
||||||
|
demotions.append(
|
||||||
|
f"{source} '{real}' demoted: path no longer exists "
|
||||||
|
"(stale binding, #702)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return real, source
|
||||||
|
|
||||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||||
|
|
||||||
@@ -84,21 +146,67 @@ def resolve_namespace_mutation_context(
|
|||||||
process_project_root: str,
|
process_project_root: str,
|
||||||
env: dict[str, str] | os._Environ | None = None,
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
|
session_lock_worktree: str | None = None,
|
||||||
worktree: str | None = None,
|
worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
|
configured_canonical_root: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
"""Shared workspace resolution for runtime_context and mutation guards.
|
||||||
workspace, binding_source = resolve_namespace_workspace(
|
|
||||||
role_kind=role_kind,
|
When *configured_canonical_root* is supplied (a cross-repository namespace
|
||||||
worktree_path=worktree_path,
|
bound to an external target repository, #706), the canonical repository root
|
||||||
worktree=worktree,
|
is that configured target rather than the MCP install checkout. This keeps
|
||||||
process_project_root=process_project_root,
|
the branches-only / worktree-membership guards (#274) evaluating against the
|
||||||
env=env,
|
repository the namespace actually mutates. Without it the single-repo
|
||||||
session_lease_worktree=session_lease_worktree,
|
default is preserved: the canonical root follows the process checkout.
|
||||||
profile_name=profile_name,
|
|
||||||
)
|
Author role (#618): uses durable worktree resolution (explicit path, env,
|
||||||
|
or active issue lock) and never silently falls back to the control checkout.
|
||||||
|
"""
|
||||||
|
demotions: list[str] = []
|
||||||
|
env_map = env if env is not None else os.environ
|
||||||
process_root = os.path.realpath(process_project_root)
|
process_root = os.path.realpath(process_project_root)
|
||||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||||
|
configured = (configured_canonical_root or "").strip()
|
||||||
|
if configured:
|
||||||
|
canonical_root = os.path.realpath(configured)
|
||||||
|
else:
|
||||||
|
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||||
|
|
||||||
|
durable: dict | None = None
|
||||||
|
if role == "author":
|
||||||
|
durable = amw.resolve_durable_author_worktree(
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
worktree=worktree,
|
||||||
|
process_project_root=process_root,
|
||||||
|
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||||
|
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
||||||
|
session_lock_worktree=session_lock_worktree,
|
||||||
|
canonical_repo_root=canonical_root,
|
||||||
|
profile_name=profile_name,
|
||||||
|
validate=True,
|
||||||
|
)
|
||||||
|
workspace = durable["workspace_path"]
|
||||||
|
binding_source = durable["workspace_binding_source"]
|
||||||
|
if durable.get("bound_worktree_missing"):
|
||||||
|
demotions.append(
|
||||||
|
f"{binding_source} '{workspace}' not demoted: "
|
||||||
|
f"{amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
workspace, binding_source = resolve_namespace_workspace(
|
||||||
|
role_kind=role,
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
worktree=worktree,
|
||||||
|
process_project_root=process_project_root,
|
||||||
|
env=env,
|
||||||
|
session_lease_worktree=session_lease_worktree,
|
||||||
|
session_lock_worktree=session_lock_worktree,
|
||||||
|
profile_name=profile_name,
|
||||||
|
demotions=demotions,
|
||||||
|
verify_paths=True,
|
||||||
|
)
|
||||||
|
|
||||||
pollution = assess_foreign_role_worktree_pollution(
|
pollution = assess_foreign_role_worktree_pollution(
|
||||||
role_kind=role,
|
role_kind=role,
|
||||||
resolved_workspace=workspace,
|
resolved_workspace=workspace,
|
||||||
@@ -106,16 +214,26 @@ def resolve_namespace_mutation_context(
|
|||||||
env=env,
|
env=env,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
)
|
)
|
||||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
result = {
|
||||||
return {
|
|
||||||
"workspace_path": workspace,
|
"workspace_path": workspace,
|
||||||
"workspace_binding_source": binding_source,
|
"workspace_binding_source": binding_source,
|
||||||
"workspace_role_kind": role,
|
"workspace_role_kind": role,
|
||||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
|
||||||
"process_project_root": process_root,
|
"process_project_root": process_root,
|
||||||
"canonical_repo_root": canonical_root,
|
"canonical_repo_root": canonical_root,
|
||||||
"roots_aligned": canonical_root == process_root,
|
"roots_aligned": canonical_root == process_root,
|
||||||
}
|
}
|
||||||
|
if durable is not None:
|
||||||
|
result["author_worktree_resolution"] = durable
|
||||||
|
result["bound_worktree_missing"] = bool(durable.get("bound_worktree_missing"))
|
||||||
|
result["path_exists"] = durable.get("path_exists")
|
||||||
|
result["in_git_worktree_list"] = durable.get("in_git_worktree_list")
|
||||||
|
result["inspected_git_root"] = durable.get("inspected_git_root")
|
||||||
|
result["author_worktree_block"] = bool(durable.get("block"))
|
||||||
|
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
|
||||||
|
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
|
||||||
|
result["operator_recovery"] = durable.get("operator_recovery")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def assess_foreign_role_worktree_pollution(
|
def assess_foreign_role_worktree_pollution(
|
||||||
@@ -192,10 +310,29 @@ def format_namespace_workspace_binding_error(
|
|||||||
reasons: list[str] | None = None,
|
reasons: list[str] | None = None,
|
||||||
ignored_bindings: list[str] | None = None,
|
ignored_bindings: list[str] | None = None,
|
||||||
dirty_files: list[str] | None = None,
|
dirty_files: list[str] | None = None,
|
||||||
|
operator_recovery: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||||
role = normalize_role_kind(role_kind)
|
role = normalize_role_kind(role_kind)
|
||||||
workspace = os.path.realpath(workspace_path)
|
reason_list = list(reasons or [])
|
||||||
|
# #618: prefer the durable author missing-worktree message when present.
|
||||||
|
if role == "author" and any(
|
||||||
|
amw.BOUND_WORKTREE_MISSING_MESSAGE in r for r in reason_list
|
||||||
|
):
|
||||||
|
return amw.format_bound_worktree_missing_error(
|
||||||
|
{
|
||||||
|
"reasons": reason_list,
|
||||||
|
"binding_source": binding_source,
|
||||||
|
"configured_path": workspace_path,
|
||||||
|
"role_kind": role,
|
||||||
|
"operator_recovery": operator_recovery
|
||||||
|
or amw.OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
workspace = os.path.realpath(workspace_path)
|
||||||
|
except OSError:
|
||||||
|
workspace = workspace_path
|
||||||
parts = [
|
parts = [
|
||||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||||
f"resolved workspace '{workspace}' via {binding_source}."
|
f"resolved workspace '{workspace}' via {binding_source}."
|
||||||
@@ -210,15 +347,18 @@ def format_namespace_workspace_binding_error(
|
|||||||
+ ", ".join(dirty_files)
|
+ ", ".join(dirty_files)
|
||||||
+ "."
|
+ "."
|
||||||
)
|
)
|
||||||
if reasons:
|
if reason_list:
|
||||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
parts.append("Details: " + "; ".join(reason_list) + ".")
|
||||||
parts.append(
|
if operator_recovery:
|
||||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
parts.append(f"Operator recovery: {operator_recovery}")
|
||||||
f"branches/ {role} worktree, set "
|
else:
|
||||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
parts.append(
|
||||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||||
"reset foreign role worktrees to unblock this namespace."
|
f"branches/ {role} worktree, set "
|
||||||
)
|
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||||
|
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||||
|
"reset foreign role worktrees to unblock this namespace."
|
||||||
|
)
|
||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@@ -230,8 +370,10 @@ def assess_namespace_mutation_workspace(
|
|||||||
process_project_root: str,
|
process_project_root: str,
|
||||||
env: dict[str, str] | os._Environ | None = None,
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
|
session_lock_worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
current_branch: str | None = None,
|
current_branch: str | None = None,
|
||||||
|
configured_canonical_root: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||||
ctx = resolve_namespace_mutation_context(
|
ctx = resolve_namespace_mutation_context(
|
||||||
@@ -241,7 +383,9 @@ def assess_namespace_mutation_workspace(
|
|||||||
process_project_root=process_project_root,
|
process_project_root=process_project_root,
|
||||||
env=env,
|
env=env,
|
||||||
session_lease_worktree=session_lease_worktree,
|
session_lease_worktree=session_lease_worktree,
|
||||||
|
session_lock_worktree=session_lock_worktree,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
|
configured_canonical_root=configured_canonical_root,
|
||||||
)
|
)
|
||||||
mutation_workspace = ctx["workspace_path"]
|
mutation_workspace = ctx["workspace_path"]
|
||||||
binding_source = ctx["workspace_binding_source"]
|
binding_source = ctx["workspace_binding_source"]
|
||||||
@@ -264,14 +408,23 @@ def assess_namespace_mutation_workspace(
|
|||||||
)
|
)
|
||||||
|
|
||||||
reasons = list(metadata.get("reasons") or [])
|
reasons = list(metadata.get("reasons") or [])
|
||||||
|
operator_recovery = ctx.get("operator_recovery")
|
||||||
if role == "author":
|
if role == "author":
|
||||||
branches = amw.assess_author_mutation_worktree(
|
# #618 durable resolution already validated existence, membership,
|
||||||
workspace_path=mutation_workspace,
|
# branches/, lock ownership, and traversal safety when present.
|
||||||
project_root=ctx["canonical_repo_root"],
|
durable_reasons = list(ctx.get("author_worktree_reasons") or [])
|
||||||
current_branch=current_branch,
|
if durable_reasons:
|
||||||
)
|
reasons.extend(durable_reasons)
|
||||||
if branches["block"]:
|
elif ctx.get("author_worktree_block"):
|
||||||
reasons.extend(branches["reasons"])
|
reasons.append(amw.BOUND_WORKTREE_MISSING_MESSAGE)
|
||||||
|
else:
|
||||||
|
branches = amw.assess_author_mutation_worktree(
|
||||||
|
workspace_path=mutation_workspace,
|
||||||
|
project_root=ctx["canonical_repo_root"],
|
||||||
|
current_branch=current_branch,
|
||||||
|
)
|
||||||
|
if branches["block"]:
|
||||||
|
reasons.extend(branches["reasons"])
|
||||||
elif (
|
elif (
|
||||||
role == "reviewer"
|
role == "reviewer"
|
||||||
and mutation_workspace == process_root
|
and mutation_workspace == process_root
|
||||||
@@ -304,4 +457,10 @@ def assess_namespace_mutation_workspace(
|
|||||||
"metadata_only": metadata.get("metadata_only", False),
|
"metadata_only": metadata.get("metadata_only", False),
|
||||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||||
|
"bound_worktree_missing": bool(ctx.get("bound_worktree_missing")),
|
||||||
|
"path_exists": ctx.get("path_exists"),
|
||||||
|
"in_git_worktree_list": ctx.get("in_git_worktree_list"),
|
||||||
|
"inspected_git_root": ctx.get("inspected_git_root"),
|
||||||
|
"operator_recovery": operator_recovery,
|
||||||
|
"blocker_kind": ctx.get("author_worktree_blocker_kind"),
|
||||||
}
|
}
|
||||||
@@ -86,6 +86,22 @@ _WRONG_BRANCH_RE = re.compile(
|
|||||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
# #698: canonical reviewer lease lifecycle operations are NOT post-merge
|
||||||
|
# cleanup. Releasing a reviewer PR lease (or posting its terminal
|
||||||
|
# phase=released marker) happens after every review — merged or not — and
|
||||||
|
# must never trigger the post-merge branch/worktree cleanup checklist.
|
||||||
|
_LEASE_LIFECYCLE_RE = re.compile(
|
||||||
|
r"(?:gitea_release_reviewer_pr_lease|gitea_abandon_workflow_lease|"
|
||||||
|
r"release[d]? (?:the )?(?:reviewer|workflow) (?:pr )?lease|"
|
||||||
|
r"reviewer (?:pr )?lease release[d]?|"
|
||||||
|
r"lease (?:marker|comment).{0,40}phase\s*[:=]\s*released|"
|
||||||
|
r"phase\s*[:=]\s*released)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_CLEANUP_MUTATIONS_VALUE_RE = re.compile(
|
||||||
|
r"cleanup mutations\s*:\s*([^\n]+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _claims_remote_delete(text: str) -> bool:
|
def _claims_remote_delete(text: str) -> bool:
|
||||||
@@ -204,16 +220,19 @@ def assess_post_merge_cleanup_proof(
|
|||||||
for field in _worktree_cleanup_fields_present(text)
|
for field in _worktree_cleanup_fields_present(text)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not remote_delete and not worktree_remove:
|
if not remote_delete and not worktree_remove:
|
||||||
cleanup_mutations = re.search(
|
value_match = _CLEANUP_MUTATIONS_VALUE_RE.search(text)
|
||||||
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
value = (value_match.group(1).strip() if value_match else "")
|
||||||
text,
|
value_lower = value.lower()
|
||||||
re.IGNORECASE,
|
substantive = bool(value) and value_lower not in {
|
||||||
|
"none", "n/a", "not applicable",
|
||||||
|
}
|
||||||
|
# #698: reviewer lease release / terminal lease markers are lease
|
||||||
|
# lifecycle, not post-merge cleanup — no checklist owed.
|
||||||
|
lease_lifecycle_only = substantive and bool(
|
||||||
|
_LEASE_LIFECYCLE_RE.search(value)
|
||||||
)
|
)
|
||||||
if cleanup_mutations:
|
if substantive and not lease_lifecycle_only:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Reconciler authorization gate for post-merge moot-lease cleanup (#745).
|
||||||
|
|
||||||
|
``gitea_cleanup_post_merge_moot_lease`` (#515) posts a terminal ``phase:
|
||||||
|
released`` lease marker — a real, durable mutation of the PR lease ledger.
|
||||||
|
Before #745 it was gated on permissions alone (``gitea.read`` to enter,
|
||||||
|
``gitea.pr.comment`` to apply) with no canonical task and no role binding, so
|
||||||
|
any profile carrying ``gitea.pr.comment`` reached the mutation path while the
|
||||||
|
reconciler could not satisfy the operator-required resolve-exact-task ->
|
||||||
|
mutation sequence.
|
||||||
|
|
||||||
|
This module holds the pure half of that gate:
|
||||||
|
|
||||||
|
* the canonical task name and its tool-name alias;
|
||||||
|
* an **append-only** in-process ledger of read-only dry-run assessments;
|
||||||
|
* ``assess_apply_authorization``, which decides whether an apply may proceed.
|
||||||
|
|
||||||
|
Apply is authorized only when all of the following hold:
|
||||||
|
|
||||||
|
* the session resolved exactly the cleanup task (no other task substitutes);
|
||||||
|
* the active profile role is ``reconciler``;
|
||||||
|
* a prior dry run in this session recorded ``lease_moot`` and
|
||||||
|
``cleanup_allowed`` for the *same* repository, PR, lease session, candidate
|
||||||
|
head and lease marker id;
|
||||||
|
* the live assessment still agrees with that evidence, so a lease superseded
|
||||||
|
between the dry run and the apply fails closed;
|
||||||
|
* any caller-supplied expectations match the live lease exactly.
|
||||||
|
|
||||||
|
Everything else fails closed. The ledger is only ever appended to — a
|
||||||
|
superseded dry run stays visible as history instead of being rewritten — which
|
||||||
|
keeps the cleanup audit trail append-only end to end.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CLEANUP_TASK = "cleanup_post_merge_moot_lease"
|
||||||
|
CLEANUP_TOOL_ALIAS = "gitea_cleanup_post_merge_moot_lease"
|
||||||
|
REQUIRED_ROLE = "reconciler"
|
||||||
|
REQUIRED_PERMISSION = "gitea.pr.comment"
|
||||||
|
|
||||||
|
# The read-only assessment stays reachable under gitea.read for every role —
|
||||||
|
# the convention shared with cleanup_stale_review_decision_lock and
|
||||||
|
# cleanup_obsolete_reviewer_comment_lease — so any namespace can diagnose a
|
||||||
|
# stuck lease. Only the apply path demands CLEANUP_TASK + REQUIRED_ROLE.
|
||||||
|
ASSESSMENT_PERMISSION = "gitea.read"
|
||||||
|
|
||||||
|
_DRY_RUN_LEDGER: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_comment_id(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def record_dry_run(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
repository_slug: str | None,
|
||||||
|
lease_moot: bool,
|
||||||
|
cleanup_allowed: bool,
|
||||||
|
session_id: str | None,
|
||||||
|
candidate_head: str | None,
|
||||||
|
lease_comment_id: Any,
|
||||||
|
recorded_at: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Append one read-only assessment to the dry-run ledger.
|
||||||
|
|
||||||
|
Never rewrites or removes a prior entry: repeated dry runs accumulate and
|
||||||
|
``latest_dry_run`` returns the newest matching one.
|
||||||
|
"""
|
||||||
|
entry = {
|
||||||
|
"task": CLEANUP_TASK,
|
||||||
|
"pr_number": int(pr_number),
|
||||||
|
"repository_slug": _norm(repository_slug) or None,
|
||||||
|
"lease_moot": bool(lease_moot),
|
||||||
|
"cleanup_allowed": bool(cleanup_allowed),
|
||||||
|
"session_id": _norm(session_id) or None,
|
||||||
|
"candidate_head": _norm(candidate_head) or None,
|
||||||
|
"lease_comment_id": _norm_comment_id(lease_comment_id),
|
||||||
|
"recorded_at": (recorded_at or datetime.now(timezone.utc)).isoformat(),
|
||||||
|
}
|
||||||
|
_DRY_RUN_LEDGER.append(entry)
|
||||||
|
return dict(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def dry_run_history() -> tuple[dict[str, Any], ...]:
|
||||||
|
"""Immutable view of every recorded dry run, oldest first."""
|
||||||
|
return tuple(dict(entry) for entry in _DRY_RUN_LEDGER)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_dry_run(
|
||||||
|
*, pr_number: int, repository_slug: str | None
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Newest dry-run evidence for this repository + PR, or None."""
|
||||||
|
wanted_repo = _norm(repository_slug)
|
||||||
|
for entry in reversed(_DRY_RUN_LEDGER):
|
||||||
|
if entry["pr_number"] != int(pr_number):
|
||||||
|
continue
|
||||||
|
if _norm(entry.get("repository_slug")) != wanted_repo:
|
||||||
|
continue
|
||||||
|
return dict(entry)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_for_testing() -> None:
|
||||||
|
"""Drop ledger state between tests. Never called by production paths."""
|
||||||
|
_DRY_RUN_LEDGER.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def assess_apply_authorization(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
repository_slug: str | None,
|
||||||
|
resolved_task: str | None,
|
||||||
|
active_role_kind: str | None,
|
||||||
|
assessment: dict[str, Any],
|
||||||
|
evidence: dict[str, Any] | None,
|
||||||
|
expected_session_id: str | None = None,
|
||||||
|
expected_candidate_head: str | None = None,
|
||||||
|
expected_lease_comment_id: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Decide whether a moot-lease cleanup apply is authorized (fail closed).
|
||||||
|
|
||||||
|
Returns ``{"allowed", "reasons", "blocker_kind", "evidence_matched", ...}``.
|
||||||
|
``allowed`` is True only when every check passes; each failure contributes a
|
||||||
|
reason so the caller can report all of them together.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
blocker_kind: str | None = None
|
||||||
|
|
||||||
|
def _block(kind: str, reason: str) -> None:
|
||||||
|
nonlocal blocker_kind
|
||||||
|
reasons.append(reason)
|
||||||
|
if blocker_kind is None:
|
||||||
|
blocker_kind = kind
|
||||||
|
|
||||||
|
# 1. Exact resolved cleanup task. Resolving any other task — including a
|
||||||
|
# sibling reconciler task — does not authorize this mutation.
|
||||||
|
if _norm(resolved_task) != CLEANUP_TASK:
|
||||||
|
_block(
|
||||||
|
"unresolved_cleanup_task",
|
||||||
|
"post-merge moot-lease cleanup requires the session to resolve "
|
||||||
|
f"task '{CLEANUP_TASK}' immediately before apply; resolved task is "
|
||||||
|
f"{resolved_task!r} (fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Dedicated reconciler role, enforced independently of the permission.
|
||||||
|
if _norm(active_role_kind) != REQUIRED_ROLE:
|
||||||
|
_block(
|
||||||
|
"wrong_role",
|
||||||
|
f"profile role {active_role_kind!r} cannot apply post-merge "
|
||||||
|
f"moot-lease cleanup; required role is {REQUIRED_ROLE} even when "
|
||||||
|
f"{REQUIRED_PERMISSION} is present (fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Canonical repository identity must be established, never inferred from
|
||||||
|
# request parameters.
|
||||||
|
if not _norm(repository_slug):
|
||||||
|
_block(
|
||||||
|
"repository_binding",
|
||||||
|
"no canonical repository identity could be established for the "
|
||||||
|
"cleanup target (fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. The live safety assessment must still say the lease is moot/cleanable.
|
||||||
|
if not assessment.get("is_moot") or not assessment.get("cleanup_allowed"):
|
||||||
|
_block(
|
||||||
|
"lease_not_moot",
|
||||||
|
"live assessment does not report a moot, cleanable lease on PR "
|
||||||
|
f"#{pr_number} (lease_moot={bool(assessment.get('is_moot'))}, "
|
||||||
|
f"cleanup_allowed={bool(assessment.get('cleanup_allowed'))}) "
|
||||||
|
"(fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
live = assessment.get("active_lease") or {}
|
||||||
|
live_session = _norm(live.get("session_id"))
|
||||||
|
live_head = _norm(live.get("candidate_head"))
|
||||||
|
live_comment_id = _norm_comment_id(live.get("comment_id"))
|
||||||
|
|
||||||
|
# 5. A lease missing identifying fields is malformed and unsafe to act on.
|
||||||
|
if not live_session or not live_head or live_comment_id is None:
|
||||||
|
_block(
|
||||||
|
"malformed_lease",
|
||||||
|
"active lease is malformed: session_id / candidate_head / "
|
||||||
|
"comment_id must all be present to authorize cleanup "
|
||||||
|
f"(session_id={live.get('session_id')!r}, "
|
||||||
|
f"candidate_head={live.get('candidate_head')!r}, "
|
||||||
|
f"comment_id={live.get('comment_id')!r}) (fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Caller expectations, when supplied, must match the live lease exactly.
|
||||||
|
if expected_session_id is not None and _norm(expected_session_id) != live_session:
|
||||||
|
_block(
|
||||||
|
"lease_mismatch",
|
||||||
|
f"expected lease session {expected_session_id!r} does not match the "
|
||||||
|
f"live lease session {live.get('session_id')!r} (fail closed)",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
expected_candidate_head is not None
|
||||||
|
and _norm(expected_candidate_head) != live_head
|
||||||
|
):
|
||||||
|
_block(
|
||||||
|
"lease_mismatch",
|
||||||
|
f"expected candidate head {expected_candidate_head!r} does not "
|
||||||
|
f"match the live lease head {live.get('candidate_head')!r} "
|
||||||
|
"(fail closed)",
|
||||||
|
)
|
||||||
|
if expected_lease_comment_id is not None and (
|
||||||
|
_norm_comment_id(expected_lease_comment_id) != live_comment_id
|
||||||
|
):
|
||||||
|
_block(
|
||||||
|
"lease_mismatch",
|
||||||
|
f"expected lease marker {expected_lease_comment_id!r} does not "
|
||||||
|
f"match the live lease marker {live.get('comment_id')!r} "
|
||||||
|
"(fail closed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7. Matching dry-run evidence recorded earlier in this session.
|
||||||
|
evidence_matched = False
|
||||||
|
if evidence is None:
|
||||||
|
_block(
|
||||||
|
"missing_dry_run_evidence",
|
||||||
|
"no read-only dry run recorded for this repository and PR; run the "
|
||||||
|
"tool with apply=false and confirm lease_moot / cleanup_allowed "
|
||||||
|
"before applying (fail closed)",
|
||||||
|
)
|
||||||
|
elif not evidence.get("lease_moot") or not evidence.get("cleanup_allowed"):
|
||||||
|
_block(
|
||||||
|
"dry_run_not_allowed",
|
||||||
|
"recorded dry run did not report an allowed cleanup "
|
||||||
|
f"(lease_moot={bool(evidence.get('lease_moot'))}, "
|
||||||
|
f"cleanup_allowed={bool(evidence.get('cleanup_allowed'))}) "
|
||||||
|
"(fail closed)",
|
||||||
|
)
|
||||||
|
elif int(evidence.get("pr_number") or -1) != int(pr_number) or _norm(
|
||||||
|
evidence.get("repository_slug")
|
||||||
|
) != _norm(repository_slug):
|
||||||
|
_block(
|
||||||
|
"dry_run_mismatch",
|
||||||
|
"recorded dry run targets a different repository or PR "
|
||||||
|
f"({evidence.get('repository_slug')}#{evidence.get('pr_number')} vs "
|
||||||
|
f"{repository_slug}#{pr_number}) (fail closed)",
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
_norm(evidence.get("session_id")) != live_session
|
||||||
|
or _norm(evidence.get("candidate_head")) != live_head
|
||||||
|
or _norm_comment_id(evidence.get("lease_comment_id")) != live_comment_id
|
||||||
|
):
|
||||||
|
_block(
|
||||||
|
"superseded_lease",
|
||||||
|
"the lease changed after the recorded dry run (dry run: "
|
||||||
|
f"session={evidence.get('session_id')!r}, "
|
||||||
|
f"head={evidence.get('candidate_head')!r}, "
|
||||||
|
f"marker={evidence.get('lease_comment_id')!r}; live: "
|
||||||
|
f"session={live.get('session_id')!r}, "
|
||||||
|
f"head={live.get('candidate_head')!r}, "
|
||||||
|
f"marker={live.get('comment_id')!r}); re-run the dry run "
|
||||||
|
"(fail closed)",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
evidence_matched = True
|
||||||
|
|
||||||
|
return {
|
||||||
|
"allowed": not reasons,
|
||||||
|
"reasons": reasons,
|
||||||
|
"blocker_kind": blocker_kind,
|
||||||
|
"evidence_matched": evidence_matched,
|
||||||
|
"required_task": CLEANUP_TASK,
|
||||||
|
"required_role_kind": REQUIRED_ROLE,
|
||||||
|
"required_permission": REQUIRED_PERMISSION,
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Canonical post-merge (moot) validation outcome and wording gate (#529).
|
||||||
|
|
||||||
|
When a PR is already merged or closed *before* a reviewer submits their
|
||||||
|
verdict, an ordinary "approve" is misleading: the review never gated the
|
||||||
|
merge, so a normal active approval overstates controller confidence. The
|
||||||
|
sanctioned outcome for that case is a **post-merge moot validation** — the
|
||||||
|
reviewer records what validation found, but classifies it as moot rather than
|
||||||
|
an active approval.
|
||||||
|
|
||||||
|
This module defines the four canonical validation distinctions #529 requires
|
||||||
|
and enforces the post-merge case:
|
||||||
|
|
||||||
|
- ``CLEAN_PASS_OUTCOME`` — clean validation pass on an open, reviewable PR.
|
||||||
|
- ``BASELINE_ACCEPTED_OUTCOME`` — validation pass with a baseline-proven
|
||||||
|
unrelated failure (proof handled by :mod:`premerge_baseline_proof`).
|
||||||
|
- ``BLOCKED_OUTCOME`` — validation blocked by an unresolved failure.
|
||||||
|
- ``POST_MERGE_MOOT_OUTCOME`` — the PR was already merged/closed before review
|
||||||
|
submission; validation is recorded as post-merge moot, not an active
|
||||||
|
approval.
|
||||||
|
|
||||||
|
The gate blocks two mistakes:
|
||||||
|
|
||||||
|
1. Claiming an *active approval* on a PR that was already merged/closed before
|
||||||
|
review submission (must be recorded as post-merge moot validation instead).
|
||||||
|
2. Claiming post-merge moot validation without proof the PR is actually
|
||||||
|
merged/closed (a merged-state field plus a merge commit SHA).
|
||||||
|
|
||||||
|
Each outcome maps to a durable ``validation:*`` process-state label so PRs and
|
||||||
|
issues carry the distinction (#529 acceptance criterion).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CLEAN_PASS_OUTCOME = "clean validation pass"
|
||||||
|
BASELINE_ACCEPTED_OUTCOME = "validation pass with baseline-proven unrelated failure"
|
||||||
|
BLOCKED_OUTCOME = "validation blocked by unresolved failure"
|
||||||
|
POST_MERGE_MOOT_OUTCOME = "post-merge moot validation"
|
||||||
|
|
||||||
|
# Canonical validation status label a reviewer writes in the report body for the
|
||||||
|
# post-merge case; kept in sync with validation_status_vocabulary.
|
||||||
|
POST_MERGE_MOOT_STATUS = "post-merge moot validation"
|
||||||
|
|
||||||
|
# Durable process-state labels (registered in issue_workflow_labels).
|
||||||
|
LABEL_CLEAN_PASS = "validation:clean-pass"
|
||||||
|
LABEL_BASELINE_ACCEPTED = "validation:baseline-accepted"
|
||||||
|
LABEL_BLOCKED = "validation:blocked"
|
||||||
|
LABEL_POST_MERGE_MOOT = "validation:post-merge-moot"
|
||||||
|
|
||||||
|
_OUTCOME_TO_LABEL: dict[str, str] = {
|
||||||
|
CLEAN_PASS_OUTCOME: LABEL_CLEAN_PASS,
|
||||||
|
BASELINE_ACCEPTED_OUTCOME: LABEL_BASELINE_ACCEPTED,
|
||||||
|
BLOCKED_OUTCOME: LABEL_BLOCKED,
|
||||||
|
POST_MERGE_MOOT_OUTCOME: LABEL_POST_MERGE_MOOT,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The PR was already merged/closed before the review was submitted.
|
||||||
|
_MERGED_BEFORE_REVIEW_RE = re.compile(
|
||||||
|
r"(?:already[- ]merged"
|
||||||
|
r"|merged\s+before\s+review"
|
||||||
|
r"|closed\s+before\s+review"
|
||||||
|
r"|pr\s+(?:is|was)\s+(?:already\s+)?(?:merged|closed)"
|
||||||
|
r"|pr\s+state\s*[:=]\s*(?:merged|closed)"
|
||||||
|
r"|merged\s*/\s*closed)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# An active approval verdict is being claimed.
|
||||||
|
_ACTIVE_APPROVAL_RE = re.compile(
|
||||||
|
r"(?:review\s+decision\s*[:=]\s*approve"
|
||||||
|
r"|submitted\s+['\"]?approve['\"]?"
|
||||||
|
r"|active\s+approval"
|
||||||
|
r"|approving\s+the\s+pr"
|
||||||
|
r"|posting\s+an?\s+approval)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# The sanctioned post-merge moot validation wording.
|
||||||
|
_POST_MERGE_MOOT_RE = re.compile(
|
||||||
|
r"post[- ]merge\s+(?:moot\s+)?validation"
|
||||||
|
r"|post[- ]merge\s+moot"
|
||||||
|
r"|moot\s+validation",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Proof the PR is genuinely merged/closed.
|
||||||
|
_MERGED_STATE_PROOF_RE = re.compile(
|
||||||
|
r"(?:pr\s+state\s*[:=]\s*(?:merged|closed)"
|
||||||
|
r"|merged_at\s*[:=]\s*\S"
|
||||||
|
r"|closed_at\s*[:=]\s*\S"
|
||||||
|
r"|state\s*[:=]\s*(?:merged|closed))",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||||
|
r"merge\s+commit(?:\s+sha)?\s*[:=]\s*[0-9a-f]{7,40}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
POST_MERGE_VALIDATION_TEMPLATE = (
|
||||||
|
"Validation status: post-merge moot validation\n"
|
||||||
|
"PR state: merged\n"
|
||||||
|
"Merge commit sha: <40-hex merge commit>\n"
|
||||||
|
"Validation finding: <what the validation run showed, for the record>\n"
|
||||||
|
"Note: PR merged/closed before review submission; recorded as post-merge "
|
||||||
|
"moot validation, not an active approval."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_state_label(outcome: str) -> str | None:
|
||||||
|
"""Return the durable ``validation:*`` label for a canonical outcome."""
|
||||||
|
return _OUTCOME_TO_LABEL.get(outcome)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_post_merge_validation(
|
||||||
|
report_text: str,
|
||||||
|
*,
|
||||||
|
pr_merged_or_closed: bool | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess post-merge moot validation wording and proof (#529).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report_text: The reviewer final report text.
|
||||||
|
pr_merged_or_closed: Optional structured signal that the PR is already
|
||||||
|
merged/closed. When ``True`` it forces the merged-before-review
|
||||||
|
path even if the report text omits the phrasing; proof fields are
|
||||||
|
still required in the report body for durability.
|
||||||
|
|
||||||
|
Returns a dict with ``proven``, ``block``, ``outcome``,
|
||||||
|
``process_state_label``, ``reasons``, ``skipped`` and ``safe_next_action``.
|
||||||
|
Only blocks when the report either claims an active approval on a
|
||||||
|
merged/closed PR, or claims post-merge moot validation without proof.
|
||||||
|
"""
|
||||||
|
text = report_text or ""
|
||||||
|
|
||||||
|
merged_signal = bool(_MERGED_BEFORE_REVIEW_RE.search(text)) or bool(
|
||||||
|
pr_merged_or_closed
|
||||||
|
)
|
||||||
|
active_approval = bool(_ACTIVE_APPROVAL_RE.search(text))
|
||||||
|
moot_wording = bool(_POST_MERGE_MOOT_RE.search(text))
|
||||||
|
|
||||||
|
# Nothing about merged-state or moot validation — not applicable here.
|
||||||
|
if not merged_signal and not moot_wording:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"outcome": None,
|
||||||
|
"process_state_label": None,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": True,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# An active approval on a PR already merged/closed before review, without
|
||||||
|
# the sanctioned moot wording, overstates confidence.
|
||||||
|
if merged_signal and active_approval and not moot_wording:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": [
|
||||||
|
"PR was already merged/closed before review submission; an "
|
||||||
|
"active approval overstates confidence — record 'post-merge "
|
||||||
|
"moot validation' instead of a normal approval"
|
||||||
|
],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": (
|
||||||
|
"classify the outcome as post-merge moot validation (not an "
|
||||||
|
"active approval); cite PR state merged/closed and the merge "
|
||||||
|
"commit SHA. Template:\n" + POST_MERGE_VALIDATION_TEMPLATE
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Post-merge moot validation claimed — require merged-state + commit proof.
|
||||||
|
if moot_wording:
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not _MERGED_STATE_PROOF_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"post-merge moot validation claimed without merged/closed state "
|
||||||
|
"proof (state: merged/closed, or merged_at/closed_at)"
|
||||||
|
)
|
||||||
|
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"post-merge moot validation claimed without a merge commit SHA"
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": reasons,
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": (
|
||||||
|
"prove the PR is merged/closed: cite PR state and the merge "
|
||||||
|
"commit SHA. Template:\n" + POST_MERGE_VALIDATION_TEMPLATE
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merged signal present but no approval claim and no moot wording yet —
|
||||||
|
# guide toward the moot outcome without blocking.
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": (
|
||||||
|
"PR appears merged/closed; if submitting a verdict, record "
|
||||||
|
"post-merge moot validation rather than an active approval"
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,849 @@
|
|||||||
|
"""PR synchronization and conflict-remediation lifecycle (#PR-SYNC).
|
||||||
|
|
||||||
|
Pure assessment helpers for:
|
||||||
|
|
||||||
|
* ``gitea_assess_pr_sync_status`` — recommend the next sanctioned action for an
|
||||||
|
open PR when the base branch advances, approvals go stale, or conflicts appear.
|
||||||
|
* ``gitea_update_pr_branch_by_merge`` preflight — author-only, fail-closed pin
|
||||||
|
of both PR head and live base head; never rebase/force-push.
|
||||||
|
|
||||||
|
All recommendation logic is hermetic (no network) so unit tests cover every
|
||||||
|
acceptance path without Gitea credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Recommended next actions (controller / skill vocabulary).
|
||||||
|
ACTION_MERGE_NOW = "merge_now"
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE = "update_branch_by_merge"
|
||||||
|
ACTION_AUTHOR_CONFLICT_REMEDIATION = "author_conflict_remediation"
|
||||||
|
ACTION_FRESH_REVIEW_REQUIRED = "fresh_review_required"
|
||||||
|
ACTION_BLOCKED = "blocked"
|
||||||
|
|
||||||
|
_VALID_ACTIONS = frozenset({
|
||||||
|
ACTION_MERGE_NOW,
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||||
|
ACTION_AUTHOR_CONFLICT_REMEDIATION,
|
||||||
|
ACTION_FRESH_REVIEW_REQUIRED,
|
||||||
|
ACTION_BLOCKED,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Author-only mutation; reviewer/merger must never update author branches.
|
||||||
|
_AUTHOR_UPDATE_ROLES = frozenset({"author"})
|
||||||
|
_DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "limited"})
|
||||||
|
|
||||||
|
# Allowed Gitea update style — merge only (never rebase).
|
||||||
|
UPDATE_STYLE_MERGE = "merge"
|
||||||
|
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
||||||
|
|
||||||
|
# ── 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()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return text if _FULL_SHA.match(text) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_role(role: str | None) -> str:
|
||||||
|
return (role or "").strip().lower() or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def assess_pr_sync_status(
|
||||||
|
*,
|
||||||
|
host: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
pr_number: int,
|
||||||
|
pr_state: str | None = None,
|
||||||
|
source_branch: str | None = None,
|
||||||
|
pr_head_sha: str | None = None,
|
||||||
|
base_head_sha: str | None = None,
|
||||||
|
commits_behind: int | None = None,
|
||||||
|
mergeable: bool | None = None,
|
||||||
|
has_conflicts: bool | None = None,
|
||||||
|
branch_protection_requires_current_base: bool | None = None,
|
||||||
|
approval_at_current_head: bool | None = None,
|
||||||
|
checks_status: str | None = None,
|
||||||
|
active_author_lock: bool | None = None,
|
||||||
|
active_reviewer_lease: bool | None = None,
|
||||||
|
active_merger_lease: bool | None = None,
|
||||||
|
prepared_verdict_head_sha: str | None = None,
|
||||||
|
checks_required: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recommend the next sanctioned PR lifecycle action.
|
||||||
|
|
||||||
|
Decision order (fail closed):
|
||||||
|
|
||||||
|
1. Incomplete identity / open state / head SHAs → ``blocked``
|
||||||
|
2. Conflicts (or mergeable false with conflict signal) →
|
||||||
|
``author_conflict_remediation``
|
||||||
|
3. Head changed after approval / prepared verdict for former head →
|
||||||
|
``fresh_review_required`` (unless conflicts already routed author work)
|
||||||
|
4. Behind live base + branch protection requires current base +
|
||||||
|
auto-mergeable → ``update_branch_by_merge``
|
||||||
|
5. Approval at current head + mergeable + checks ok (+ update not
|
||||||
|
required) → ``merge_now``
|
||||||
|
6. Otherwise ``blocked`` with structured reasons
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
pr_head = _normalize_sha(pr_head_sha)
|
||||||
|
base_head = _normalize_sha(base_head_sha)
|
||||||
|
prepared_head = _normalize_sha(prepared_verdict_head_sha)
|
||||||
|
state = (pr_state or "").strip().lower()
|
||||||
|
checks = (checks_status or "unknown").strip().lower()
|
||||||
|
behind = commits_behind if isinstance(commits_behind, int) and commits_behind >= 0 else None
|
||||||
|
requires_current = bool(branch_protection_requires_current_base)
|
||||||
|
approval_ok = bool(approval_at_current_head)
|
||||||
|
|
||||||
|
# Derive conflict when caller only supplies mergeable=False.
|
||||||
|
if has_conflicts is None and mergeable is False:
|
||||||
|
has_conflicts = True
|
||||||
|
conflicts = bool(has_conflicts) if has_conflicts is not None else None
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"host": (host or "").strip() or None,
|
||||||
|
"org": (org or "").strip() or None,
|
||||||
|
"repo": (repo or "").strip() or None,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"pr_state": state or None,
|
||||||
|
"source_branch": (source_branch or "").strip() or None,
|
||||||
|
"pr_head_sha": pr_head,
|
||||||
|
"base_head_sha": base_head,
|
||||||
|
"commits_behind": behind,
|
||||||
|
"mergeable": mergeable,
|
||||||
|
"has_conflicts": conflicts,
|
||||||
|
"branch_protection_requires_current_base": requires_current,
|
||||||
|
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
||||||
|
"checks_status": checks,
|
||||||
|
"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,
|
||||||
|
"merger_lease": bool(active_merger_lease) if active_merger_lease is not None else None,
|
||||||
|
},
|
||||||
|
"prepared_verdict_head_sha": prepared_head,
|
||||||
|
"recommended_next_action": ACTION_BLOCKED,
|
||||||
|
"approval_valid_for_merge": False,
|
||||||
|
"stale_approval": False,
|
||||||
|
"stale_prepared_verdict": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
"performed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not isinstance(pr_number, int) or pr_number <= 0:
|
||||||
|
reasons.append("pr_number must be a positive integer (fail closed)")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if state and state not in ("open",):
|
||||||
|
reasons.append(f"PR is not open (state={state}); cannot synchronize or merge")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
if not state:
|
||||||
|
reasons.append("PR state missing (fail closed)")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if pr_head is None:
|
||||||
|
reasons.append(
|
||||||
|
"exact PR head SHA missing or not a full 40-char hex SHA (fail closed)"
|
||||||
|
)
|
||||||
|
if base_head is None:
|
||||||
|
reasons.append(
|
||||||
|
"exact live base head SHA missing or not a full 40-char hex SHA (fail closed)"
|
||||||
|
)
|
||||||
|
if behind is None:
|
||||||
|
reasons.append("commits_behind missing or invalid (fail closed)")
|
||||||
|
if mergeable is None:
|
||||||
|
reasons.append("mergeable signal missing (fail closed)")
|
||||||
|
if approval_at_current_head is None:
|
||||||
|
reasons.append("approval_at_current_head missing (fail closed)")
|
||||||
|
if branch_protection_requires_current_base is None:
|
||||||
|
reasons.append(
|
||||||
|
"branch_protection_requires_current_base missing (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Stale prepared verdict / approval across heads.
|
||||||
|
stale_prepared = bool(prepared_head and prepared_head != pr_head)
|
||||||
|
result["stale_prepared_verdict"] = stale_prepared
|
||||||
|
stale_approval = not approval_ok
|
||||||
|
result["stale_approval"] = stale_approval
|
||||||
|
result["approval_valid_for_merge"] = bool(approval_ok and not stale_prepared)
|
||||||
|
|
||||||
|
# ── Conflicts: author remediation in dedicated worktree ──────────────
|
||||||
|
if conflicts is True or mergeable is False:
|
||||||
|
if conflicts is True or mergeable is False:
|
||||||
|
# Distinguish pure non-mergeable without explicit conflict flag
|
||||||
|
# still routes author remediation (Gitea cannot auto-update).
|
||||||
|
reasons.append(
|
||||||
|
f"PR #{pr_number} has conflicts or is not mergeable at head "
|
||||||
|
f"{pr_head}; route author conflict remediation in the existing "
|
||||||
|
"issue/PR worktree under branches/ (never force-push or rebase)"
|
||||||
|
)
|
||||||
|
if stale_approval:
|
||||||
|
reasons.append(
|
||||||
|
"any approval at a former head is invalid; after remediation "
|
||||||
|
"require a fresh independent review at the new exact head"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_AUTHOR_CONFLICT_REMEDIATION
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Stale approval / prepared verdict at former head ─────────────────
|
||||||
|
if not approval_ok or stale_prepared:
|
||||||
|
if behind and behind > 0 and requires_current and mergeable is True:
|
||||||
|
# Must update first; update will also invalidate approval.
|
||||||
|
reasons.append(
|
||||||
|
f"PR is {behind} commit(s) behind live base and branch protection "
|
||||||
|
"requires current base; automatic merge-from-base is available"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"approval is not valid at the current head (or prepared verdict "
|
||||||
|
"is head-scoped to a former SHA); after update require fresh review"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
return result
|
||||||
|
reasons.append(
|
||||||
|
"approval is not valid at the exact current PR head "
|
||||||
|
f"({pr_head}); never reuse a former-head approval for merge"
|
||||||
|
)
|
||||||
|
if stale_prepared:
|
||||||
|
reasons.append(
|
||||||
|
f"prepared verdict pinned to former head {prepared_head} cannot "
|
||||||
|
f"authorize review/merge at current head {pr_head}"
|
||||||
|
)
|
||||||
|
if active_reviewer_lease:
|
||||||
|
reasons.append(
|
||||||
|
"active reviewer lease must be released or superseded for the "
|
||||||
|
"new head before a fresh independent review"
|
||||||
|
)
|
||||||
|
if active_merger_lease:
|
||||||
|
reasons.append(
|
||||||
|
"active merger lease cannot cross heads; release or re-acquire "
|
||||||
|
"at the new exact head"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Outdated + protection requires current base, auto-updatable ──────
|
||||||
|
if behind and behind > 0 and requires_current:
|
||||||
|
if mergeable is True and conflicts is not True:
|
||||||
|
reasons.append(
|
||||||
|
f"PR is {behind} commit(s) behind live base {base_head}; "
|
||||||
|
"branch protection requires the PR branch to contain current base; "
|
||||||
|
"Gitea can merge base into the PR branch without conflicts"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"route author-only update_branch_by_merge; never rebase or force-push; "
|
||||||
|
"new head invalidates prior approval and requires fresh independent review"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
# Approval today is at old head that will change — not mergeable yet
|
||||||
|
# for final merge until re-reviewed, but assess marks update path.
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
result["stale_approval"] = True # will become stale after update
|
||||||
|
return result
|
||||||
|
reasons.append(
|
||||||
|
"update required by branch protection but automatic merge is not available"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Checks gate for merge_now (#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})")
|
||||||
|
elif checks in _CTX_FAILURE:
|
||||||
|
reasons.append(f"required checks failed (status={checks})")
|
||||||
|
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)")
|
||||||
|
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.
|
||||||
|
if approval_ok and mergeable is True and conflicts is not True:
|
||||||
|
if behind and behind > 0 and not requires_current:
|
||||||
|
reasons.append(
|
||||||
|
f"PR is {behind} commit(s) behind live base but branch protection "
|
||||||
|
"does not require the latest base; preserve the approved head"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
"PR has a valid approval at the exact current head, is conflict-free "
|
||||||
|
"and mergeable; do not update the branch unnecessarily"
|
||||||
|
)
|
||||||
|
reasons.append("route directly to the sanctioned merger workflow (merge_now)")
|
||||||
|
result["recommended_next_action"] = ACTION_MERGE_NOW
|
||||||
|
result["approval_valid_for_merge"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
reasons.append("no sanctioned next action matched the observed PR state (fail closed)")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_update_pr_branch_preflight(
|
||||||
|
*,
|
||||||
|
role_kind: str | None,
|
||||||
|
expected_pr_head_sha: str | None,
|
||||||
|
live_pr_head_sha: str | None,
|
||||||
|
expected_base_head_sha: str | None,
|
||||||
|
live_base_head_sha: str | None,
|
||||||
|
has_conflicts: bool | None = None,
|
||||||
|
mergeable: bool | None = None,
|
||||||
|
style: str | None = UPDATE_STYLE_MERGE,
|
||||||
|
has_author_lock: bool | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
worktree_owns_source_branch: bool | None = None,
|
||||||
|
control_checkout_clean: bool | None = None,
|
||||||
|
master_parity_ok: bool | None = None,
|
||||||
|
force_push: bool = False,
|
||||||
|
rebase: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Author-only preflight for update-by-merge; fail closed on any race.
|
||||||
|
|
||||||
|
When conflicts exist, returns a structured author-remediation handoff and
|
||||||
|
sets ``mutation_allowed=False`` so no partial remote update is performed.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
role = _normalize_role(role_kind)
|
||||||
|
exp_pr = _normalize_sha(expected_pr_head_sha)
|
||||||
|
live_pr = _normalize_sha(live_pr_head_sha)
|
||||||
|
exp_base = _normalize_sha(expected_base_head_sha)
|
||||||
|
live_base = _normalize_sha(live_base_head_sha)
|
||||||
|
style_norm = (style or "").strip().lower() or UPDATE_STYLE_MERGE
|
||||||
|
|
||||||
|
conflicts = has_conflicts
|
||||||
|
if conflicts is None and mergeable is False:
|
||||||
|
conflicts = True
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"mutation_allowed": False,
|
||||||
|
"performed": False,
|
||||||
|
"style": style_norm,
|
||||||
|
"role_kind": role,
|
||||||
|
"expected_pr_head_sha": exp_pr,
|
||||||
|
"live_pr_head_sha": live_pr,
|
||||||
|
"expected_base_head_sha": exp_base,
|
||||||
|
"live_base_head_sha": live_base,
|
||||||
|
"head_race": False,
|
||||||
|
"base_race": False,
|
||||||
|
"has_conflicts": conflicts,
|
||||||
|
"author_remediation_handoff": False,
|
||||||
|
"approval_invalidated_for_former_head": False,
|
||||||
|
"force_push": bool(force_push),
|
||||||
|
"rebase": bool(rebase),
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Role gate — author only.
|
||||||
|
if role not in _AUTHOR_UPDATE_ROLES:
|
||||||
|
reasons.append(
|
||||||
|
f"role '{role}' cannot update an author PR branch "
|
||||||
|
"(author-only; reviewer/merger denial)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if force_push:
|
||||||
|
reasons.append("force-push is forbidden for PR branch update (fail closed)")
|
||||||
|
return result
|
||||||
|
if rebase or style_norm in _FORBIDDEN_UPDATE_STYLES:
|
||||||
|
reasons.append(
|
||||||
|
f"update style '{style_norm}' forbidden; only style=merge is allowed "
|
||||||
|
"(never rebase)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
if style_norm != UPDATE_STYLE_MERGE:
|
||||||
|
reasons.append(
|
||||||
|
f"unknown update style '{style_norm}'; expected '{UPDATE_STYLE_MERGE}'"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not exp_pr or not live_pr:
|
||||||
|
reasons.append(
|
||||||
|
"expected and live PR head SHAs must be full 40-char hex (fail closed)"
|
||||||
|
)
|
||||||
|
if not exp_base or not live_base:
|
||||||
|
reasons.append(
|
||||||
|
"expected and live base head SHAs must be full 40-char hex (fail closed)"
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
return result
|
||||||
|
|
||||||
|
if exp_pr != live_pr:
|
||||||
|
result["head_race"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"PR head race: expected {exp_pr} but live is {live_pr} "
|
||||||
|
"(fail closed; no partial mutation)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
if exp_base != live_base:
|
||||||
|
result["base_race"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"base head race: expected {exp_base} but live is {live_base} "
|
||||||
|
"(fail closed; no partial mutation)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if has_author_lock is not True:
|
||||||
|
reasons.append(
|
||||||
|
"existing issue/PR lock required before update_branch_by_merge (fail closed)"
|
||||||
|
)
|
||||||
|
wt = (worktree_path or "").strip()
|
||||||
|
if not wt:
|
||||||
|
reasons.append("existing PR worktree path required under branches/ (fail closed)")
|
||||||
|
else:
|
||||||
|
norm = wt.replace("\\", "/")
|
||||||
|
if "/branches/" not in norm and not norm.startswith("branches/"):
|
||||||
|
reasons.append(
|
||||||
|
f"worktree_path '{wt}' must be under branches/ (fail closed)"
|
||||||
|
)
|
||||||
|
if worktree_owns_source_branch is False:
|
||||||
|
reasons.append(
|
||||||
|
"worktree does not own the PR source branch (fail closed)"
|
||||||
|
)
|
||||||
|
if control_checkout_clean is False:
|
||||||
|
reasons.append("control checkout is not clean (fail closed)")
|
||||||
|
if master_parity_ok is False:
|
||||||
|
reasons.append("runtime/master parity failed (fail closed)")
|
||||||
|
|
||||||
|
if conflicts is True:
|
||||||
|
result["author_remediation_handoff"] = True
|
||||||
|
reasons.append(
|
||||||
|
"conflicts present: return structured author-remediation handoff "
|
||||||
|
"without creating a partial remote update"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"resolve conflicts explicitly in the existing dedicated worktree, "
|
||||||
|
"run focused and regression tests, commit/push via sanctioned author "
|
||||||
|
"workflow, then route the new exact head to independent review"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if mergeable is False and conflicts is not False:
|
||||||
|
result["author_remediation_handoff"] = True
|
||||||
|
reasons.append(
|
||||||
|
"PR is not mergeable; refuse automatic update and hand off to "
|
||||||
|
"author conflict remediation (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["mutation_allowed"] = True
|
||||||
|
reasons.append(
|
||||||
|
"preflight passed: author may merge live base into the PR branch via "
|
||||||
|
"native MCP update (style=merge only)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_post_update_head_transition(
|
||||||
|
*,
|
||||||
|
former_pr_head_sha: str | None,
|
||||||
|
new_pr_head_sha: str | None,
|
||||||
|
former_approval_head_sha: str | None = None,
|
||||||
|
former_reviewer_lease_head_sha: str | None = None,
|
||||||
|
former_merger_lease_head_sha: str | None = None,
|
||||||
|
prepared_verdict_head_sha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""After a successful update-by-merge, invalidate cross-head artifacts.
|
||||||
|
|
||||||
|
The new head never inherits approval, reviewer/merger leases, or prepared
|
||||||
|
verdicts from the former head.
|
||||||
|
"""
|
||||||
|
former = _normalize_sha(former_pr_head_sha)
|
||||||
|
new = _normalize_sha(new_pr_head_sha)
|
||||||
|
reasons: list[str] = []
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"former_pr_head_sha": former,
|
||||||
|
"new_pr_head_sha": new,
|
||||||
|
"head_changed": bool(former and new and former != new),
|
||||||
|
"approval_invalidated": False,
|
||||||
|
"reviewer_lease_superseded": False,
|
||||||
|
"merger_lease_superseded": False,
|
||||||
|
"prepared_verdict_invalidated": False,
|
||||||
|
"recommended_next_action": ACTION_BLOCKED,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not former or not new:
|
||||||
|
reasons.append("former and new PR head SHAs required (fail closed)")
|
||||||
|
return result
|
||||||
|
if former == new:
|
||||||
|
reasons.append(
|
||||||
|
"PR head unchanged after update; unexpected for merge-from-base"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["head_changed"] = True
|
||||||
|
# Approval at former head is always void at new head.
|
||||||
|
former_approval = _normalize_sha(former_approval_head_sha) or former
|
||||||
|
if former_approval != new:
|
||||||
|
result["approval_invalidated"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"approval for former head {former_approval} is invalid at new head "
|
||||||
|
f"{new}; never preserve approval across a head change"
|
||||||
|
)
|
||||||
|
|
||||||
|
rev_lease = _normalize_sha(former_reviewer_lease_head_sha)
|
||||||
|
if rev_lease and rev_lease != new:
|
||||||
|
result["reviewer_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"reviewer lease for head {rev_lease} cannot cross to {new}; "
|
||||||
|
"release or supersede before fresh review"
|
||||||
|
)
|
||||||
|
elif rev_lease is None:
|
||||||
|
# Unknown lease head still must not authorize at new head.
|
||||||
|
result["reviewer_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
"any reviewer lease scoped to the former head is superseded at the new head"
|
||||||
|
)
|
||||||
|
|
||||||
|
mer_lease = _normalize_sha(former_merger_lease_head_sha)
|
||||||
|
if mer_lease and mer_lease != new:
|
||||||
|
result["merger_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"merger lease for head {mer_lease} cannot cross to {new}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result["merger_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
"any merger lease scoped to the former head is superseded at the new head"
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared = _normalize_sha(prepared_verdict_head_sha)
|
||||||
|
if prepared and prepared != new:
|
||||||
|
result["prepared_verdict_invalidated"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"prepared verdict for head {prepared} cannot authorize the new head {new}"
|
||||||
|
)
|
||||||
|
elif prepared is None:
|
||||||
|
result["prepared_verdict_invalidated"] = True
|
||||||
|
reasons.append(
|
||||||
|
"prepared verdicts associated with the former head are invalidated"
|
||||||
|
)
|
||||||
|
|
||||||
|
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
reasons.append(
|
||||||
|
"route the new exact head to independent review "
|
||||||
|
f"({ACTION_FRESH_REVIEW_REQUIRED})"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_sequential_queue_step(
|
||||||
|
*,
|
||||||
|
just_merged: bool,
|
||||||
|
master_refreshed: bool,
|
||||||
|
next_pr_number: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Controller sequential processing: reassess only after merge + master refresh."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
if just_merged and not master_refreshed:
|
||||||
|
reasons.append(
|
||||||
|
"master must be refreshed after each merge before reassessing the next PR "
|
||||||
|
"(do not update every PR simultaneously)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"reassess_allowed": False,
|
||||||
|
"process_next": False,
|
||||||
|
"next_pr_number": next_pr_number,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
if not just_merged:
|
||||||
|
reasons.append("no merge completed; sequential step does not advance")
|
||||||
|
return {
|
||||||
|
"reassess_allowed": False,
|
||||||
|
"process_next": False,
|
||||||
|
"next_pr_number": next_pr_number,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
if next_pr_number:
|
||||||
|
reasons.append(
|
||||||
|
"merge completed and live master refreshed; reassess the next PR "
|
||||||
|
f"#{next_pr_number}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
"merge completed and live master refreshed; reassess the next PR"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"reassess_allowed": True,
|
||||||
|
"process_next": True,
|
||||||
|
"next_pr_number": next_pr_number,
|
||||||
|
"post_merge_cleanup_handoff": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_approval_usable_at_head(
|
||||||
|
*,
|
||||||
|
current_head_sha: str | None,
|
||||||
|
approved_head_sha: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Stale approval cannot authorize merge (head-scoped only)."""
|
||||||
|
current = _normalize_sha(current_head_sha)
|
||||||
|
approved = _normalize_sha(approved_head_sha)
|
||||||
|
ok = bool(current and approved and current == approved)
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not ok:
|
||||||
|
reasons.append(
|
||||||
|
f"stale approval: approved head '{approved or '(none)'}' does not "
|
||||||
|
f"match current head '{current or '(none)'}'; cannot authorize merge"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"approval_usable": ok,
|
||||||
|
"current_head_sha": current,
|
||||||
|
"approved_head_sha": approved,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lease_usable_at_head(
|
||||||
|
*,
|
||||||
|
lease_kind: str,
|
||||||
|
lease_head_sha: str | None,
|
||||||
|
current_head_sha: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Reviewer/merger leases cannot cross heads."""
|
||||||
|
current = _normalize_sha(current_head_sha)
|
||||||
|
lease_head = _normalize_sha(lease_head_sha)
|
||||||
|
kind = (lease_kind or "").strip().lower() or "unknown"
|
||||||
|
ok = bool(current and lease_head and current == lease_head)
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not ok:
|
||||||
|
reasons.append(
|
||||||
|
f"stale {kind} lease: lease head '{lease_head or '(none)'}' cannot "
|
||||||
|
f"authorize work at current head '{current or '(none)'}'"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"lease_usable": ok,
|
||||||
|
"lease_kind": kind,
|
||||||
|
"lease_head_sha": lease_head,
|
||||||
|
"current_head_sha": current,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
+123
-10
@@ -20,7 +20,11 @@ _FIELD_RE = re.compile(
|
|||||||
re.IGNORECASE | re.MULTILINE,
|
re.IGNORECASE | re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"})
|
# Must mirror reviewer_pr_lease._TERMINAL_PHASES: both modules read the same
|
||||||
|
# append-only lease markers, so a phase that is terminal in one and active in
|
||||||
|
# the other yields two conflicting truths for the same comment (#742 review
|
||||||
|
# 460). "abandoned" is the owner-session merger finalization outcome.
|
||||||
|
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked", "abandoned"})
|
||||||
_ACTIVE_REVIEWER_PHASES = frozenset({
|
_ACTIVE_REVIEWER_PHASES = frozenset({
|
||||||
"claimed",
|
"claimed",
|
||||||
"validating",
|
"validating",
|
||||||
@@ -32,7 +36,8 @@ _TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
|
|||||||
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
||||||
|
|
||||||
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
||||||
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
|
# The reviewer/merger PR-lease TTL lives in reviewer_pr_lease.LEASE_TTL_MINUTES
|
||||||
|
# (#747). A second copy here had no readers and could only drift out of sync.
|
||||||
|
|
||||||
|
|
||||||
def _parse_timestamp(value: str | None) -> datetime | None:
|
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||||
@@ -153,25 +158,72 @@ def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool:
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewer_chain_key(lease: dict) -> tuple | None:
|
||||||
|
"""Identity of the lease chain a reviewer marker belongs to (#742).
|
||||||
|
|
||||||
|
A chain is one session's claim → heartbeat → terminal sequence, keyed by
|
||||||
|
repository, PR, candidate head, session id, identity, and profile. Returns
|
||||||
|
None when any component is missing: an incomplete or malformed marker has
|
||||||
|
no provable chain, so it can neither be cancelled by nor cancel anything.
|
||||||
|
"""
|
||||||
|
raw = lease.get("raw_fields") or {}
|
||||||
|
repo = (raw.get("repo") or "").strip().lower()
|
||||||
|
session_id = (lease.get("session_id") or "").strip()
|
||||||
|
identity = (lease.get("reviewer_identity") or "").strip()
|
||||||
|
profile = (lease.get("profile") or "").strip()
|
||||||
|
head = lease.get("candidate_head")
|
||||||
|
pr_number = lease.get("pr_number")
|
||||||
|
if not (repo and session_id and identity and profile and head and pr_number):
|
||||||
|
return None
|
||||||
|
return (repo, pr_number, head, session_id, identity, profile)
|
||||||
|
|
||||||
|
|
||||||
|
def _chain_terminated_after(entries: list[dict], index: int) -> bool:
|
||||||
|
"""True when a later marker terminates the chain of ``entries[index]``.
|
||||||
|
|
||||||
|
Append-only newest-wins (#577 semantics, chain-scoped for #742): a terminal
|
||||||
|
marker ends only its *own* claim, so a foreign, forged, or malformed
|
||||||
|
terminal marker cannot cancel another session's valid active lease.
|
||||||
|
"""
|
||||||
|
key = _reviewer_chain_key(entries[index])
|
||||||
|
if key is None:
|
||||||
|
return False
|
||||||
|
for later in entries[index + 1:]:
|
||||||
|
if (later.get("phase") or "").strip().lower() not in _TERMINAL_REVIEWER_PHASES:
|
||||||
|
continue
|
||||||
|
if _reviewer_chain_key(later) == key:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def find_active_reviewer_lease(
|
def find_active_reviewer_lease(
|
||||||
comments: list[dict],
|
comments: list[dict],
|
||||||
*,
|
*,
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Return the newest unexpired reviewer lease for *pr_number*, if any."""
|
"""Return the newest unexpired, non-terminated reviewer lease for *pr_number*.
|
||||||
|
|
||||||
|
Walking backward past a terminal marker used to resurrect the older claim of
|
||||||
|
the very chain that marker ended, so a released/abandoned finalization still
|
||||||
|
read as an active lease here while ``reviewer_pr_lease`` reported it ended
|
||||||
|
(#742). A claim is now skipped when a later marker terminates its own chain.
|
||||||
|
"""
|
||||||
now = now or datetime.now(timezone.utc)
|
now = now or datetime.now(timezone.utc)
|
||||||
candidates = [
|
candidates = [
|
||||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||||
if entry.get("lease_kind") == "reviewer"
|
if entry.get("lease_kind") == "reviewer"
|
||||||
]
|
]
|
||||||
for lease in reversed(candidates):
|
for index in range(len(candidates) - 1, -1, -1):
|
||||||
|
lease = candidates[index]
|
||||||
if _lease_expired(lease, now=now):
|
if _lease_expired(lease, now=now):
|
||||||
continue
|
continue
|
||||||
phase = (lease.get("phase") or "").strip().lower()
|
phase = (lease.get("phase") or "").strip().lower()
|
||||||
if phase in _TERMINAL_REVIEWER_PHASES:
|
if phase in _TERMINAL_REVIEWER_PHASES:
|
||||||
continue
|
continue
|
||||||
if phase in _ACTIVE_REVIEWER_PHASES or phase:
|
if phase in _ACTIVE_REVIEWER_PHASES or phase:
|
||||||
|
if _chain_terminated_after(candidates, index):
|
||||||
|
continue
|
||||||
return lease
|
return lease
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -403,10 +455,37 @@ _REVIEWER_ACTIVE_RE = re.compile(
|
|||||||
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
|
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
# #698 phase detection for phase-specific head proofs.
|
||||||
|
_NO_REVIEWED_HEAD_RE = re.compile(
|
||||||
|
r"(?:reviewed head sha|candidate head sha)\s*:\s*none\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_VERDICT_RECORDED_RE = re.compile(
|
||||||
|
r"review decision\s*:\s*(?:approve[d]?|request[_ ]changes)\b"
|
||||||
|
r"|review_status\s*:\s*(?:approved|request_changes)\b"
|
||||||
|
r"|terminal review mutation\s*:\s*(?!none\b)\S",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_MERGE_ATTEMPTED_RE = re.compile(
|
||||||
|
r"merge result\s*:\s*(?:merged|success|performed|failed|attempted)\b"
|
||||||
|
r"|merge mutations\s*:\s*(?!none\b|not applicable\b)\S",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_VALIDATION_STARTED_RE = re.compile(
|
||||||
|
r"validation\s*:\s*(?!none\b|not run\b|not applicable\b|not started\b)"
|
||||||
|
r"[^\n]*(?:pass|fail|ran|executed|\d+\s+passed)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
|
def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
|
||||||
"""Final-report proof for reviewed vs live head SHAs (#399 AC 6)."""
|
"""Final-report proof for reviewed vs live head SHAs (#399 AC 6).
|
||||||
|
|
||||||
|
#698: head proofs are phase-specific. A legitimately blocked run that
|
||||||
|
never began validation (no reviewed head, no formal verdict, no merge)
|
||||||
|
owes none of them; approval-time and merge-time live-head proofs are
|
||||||
|
owed only once the corresponding phase actually begins.
|
||||||
|
"""
|
||||||
text = report_text or ""
|
text = report_text or ""
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None)
|
reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None)
|
||||||
@@ -422,15 +501,49 @@ def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
push_during = _PUSH_DURING_VALIDATION_RE.search(text)
|
push_during = _PUSH_DURING_VALIDATION_RE.search(text)
|
||||||
|
|
||||||
if not reviewed:
|
# Phase detection from the report's own claims.
|
||||||
|
no_head_stated = bool(_NO_REVIEWED_HEAD_RE.search(text))
|
||||||
|
verdict_recorded = bool(_VERDICT_RECORDED_RE.search(text))
|
||||||
|
merge_attempted = bool(_MERGE_ATTEMPTED_RE.search(text))
|
||||||
|
validation_started = bool(reviewed) or bool(_VALIDATION_STARTED_RE.search(text))
|
||||||
|
blocked_before_validation = (
|
||||||
|
no_head_stated
|
||||||
|
and not reviewed
|
||||||
|
and not verdict_recorded
|
||||||
|
and not merge_attempted
|
||||||
|
and not validation_started
|
||||||
|
)
|
||||||
|
|
||||||
|
if blocked_before_validation:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"reasons": [],
|
||||||
|
"reviewed_head_sha": None,
|
||||||
|
"live_head_sha_before_approval": None,
|
||||||
|
"live_head_sha_before_merge": None,
|
||||||
|
"push_during_validation": (
|
||||||
|
push_during.group(1).lower() if push_during else None
|
||||||
|
),
|
||||||
|
"phase": "blocked_before_validation",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not reviewed and not no_head_stated:
|
||||||
|
# The head must always be STATED — either a SHA or an explicit
|
||||||
|
# 'none'. Silence is not a phase claim and fails closed.
|
||||||
|
reasons.append(
|
||||||
|
"reviewed head SHA not stated in final report "
|
||||||
|
"(state the SHA or an explicit 'none')"
|
||||||
|
)
|
||||||
|
elif not reviewed and (validation_started or verdict_recorded or merge_attempted):
|
||||||
reasons.append("reviewed head SHA not stated in final report")
|
reasons.append("reviewed head SHA not stated in final report")
|
||||||
if not live_approval:
|
if verdict_recorded and not live_approval:
|
||||||
reasons.append("final live head SHA before approval not stated")
|
reasons.append("final live head SHA before approval not stated")
|
||||||
if not live_merge:
|
if merge_attempted and not live_merge:
|
||||||
reasons.append("final live head SHA before merge not stated")
|
reasons.append("final live head SHA before merge not stated")
|
||||||
if not push_during:
|
if validation_started and not push_during:
|
||||||
reasons.append("whether push occurred during validation not stated")
|
reasons.append("whether push occurred during validation not stated")
|
||||||
elif reviewed and live_approval and reviewed != live_approval:
|
if reviewed and live_approval and reviewed != live_approval:
|
||||||
reasons.append("live head before approval differs from reviewed head SHA")
|
reasons.append("live head before approval differs from reviewed head SHA")
|
||||||
elif reviewed and live_merge and reviewed != live_merge:
|
elif reviewed and live_merge and reviewed != live_merge:
|
||||||
reasons.append("live head before merge differs from reviewed head SHA")
|
reasons.append("live head before merge differs from reviewed head SHA")
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ RECONCILER_RECOMMENDED_OPERATIONS = (
|
|||||||
"gitea.pr.comment",
|
"gitea.pr.comment",
|
||||||
"gitea.issue.comment",
|
"gitea.issue.comment",
|
||||||
"gitea.issue.close",
|
"gitea.issue.close",
|
||||||
|
# Merged-branch cleanup is reconciler-owned (task_capability_map maps
|
||||||
|
# cleanup_merged_pr_branch -> reconciler). The permission is only
|
||||||
|
# exercisable through the guarded gitea_cleanup_merged_pr_branch path
|
||||||
|
# (#514): merged proof, protected-branch refusal, explicit confirmation.
|
||||||
|
"gitea.branch.delete",
|
||||||
)
|
)
|
||||||
|
|
||||||
RECONCILER_FORBIDDEN_OPERATIONS = (
|
RECONCILER_FORBIDDEN_OPERATIONS = (
|
||||||
|
|||||||
+26
-2
@@ -16,11 +16,35 @@ or the local remote URL is unavailable (best-effort corroboration only).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
REMEDIATION = (
|
REMEDIATION = (
|
||||||
"Pass explicit org= and repo= matching the local git remote, "
|
"Pass explicit org= and repo= matching the local git remote on tools that "
|
||||||
"e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools."
|
"accept those parameters (including mcp_get_control_plane_guide), "
|
||||||
|
"e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools. "
|
||||||
|
"Do not pass org/repo to tools whose schema does not list them."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# https://host/org/repo.git or git@host:org/repo.git
|
||||||
|
_REMOTE_URL_SLUG_RE = re.compile(
|
||||||
|
r"(?:[:/])(?P<org>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_org_repo_from_remote_url(remote_url: str | None) -> tuple[str, str] | None:
|
||||||
|
"""Best-effort parse of org/repo from a git remote URL."""
|
||||||
|
url = (remote_url or "").strip()
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
match = _REMOTE_URL_SLUG_RE.search(url)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
org = (match.group("org") or "").strip()
|
||||||
|
repo = (match.group("repo") or "").strip()
|
||||||
|
if not org or not repo:
|
||||||
|
return None
|
||||||
|
return org, repo
|
||||||
|
|
||||||
|
|
||||||
def assess_remote_repo_match(
|
def assess_remote_repo_match(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ python-multipart==0.0.32
|
|||||||
referencing==0.37.0
|
referencing==0.37.0
|
||||||
rich==15.0.0
|
rich==15.0.0
|
||||||
rpds-py==2026.5.1
|
rpds-py==2026.5.1
|
||||||
|
sentry-sdk==2.20.0
|
||||||
shellingham==1.5.4
|
shellingham==1.5.4
|
||||||
sse-starlette==3.4.5
|
sse-starlette==3.4.5
|
||||||
starlette==1.3.1
|
starlette==1.3.1
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ _REVIEWED_HEAD_RE = re.compile(
|
|||||||
r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})",
|
r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
# #698: validation pass proof appears in several legitimate shapes —
|
||||||
|
# "Validation: pass", "Validation: focused 50 passed; full 2665 passed",
|
||||||
|
# or structured "validation_status: pass". Accept pass evidence anywhere in
|
||||||
|
# the Validation field's value, not only as its first token.
|
||||||
_VALIDATION_PASS_RE = re.compile(
|
_VALIDATION_PASS_RE = re.compile(
|
||||||
r"validation\s*:\s*(?:pass|passed|strong|ok|green)",
|
r"validation(?:_status)?\s*:[^\n]{0,300}?"
|
||||||
|
r"(?:\bpass(?:ed)?\b|\bstrong\b|\bok\b|\bgreen\b|\d+\s+passed)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_MERGED_CLAIM_RE = re.compile(
|
_MERGED_CLAIM_RE = re.compile(
|
||||||
|
|||||||
@@ -93,8 +93,16 @@ def assess_workflow_blockers(
|
|||||||
capability_blocked: bool = False,
|
capability_blocked: bool = False,
|
||||||
mcp_reconnect_failed: bool = False,
|
mcp_reconnect_failed: bool = False,
|
||||||
stale_capability_state: bool = False,
|
stale_capability_state: bool = False,
|
||||||
|
live_namespace_broken: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return hard blockers that forbid all PR queue work (#290 AC3)."""
|
"""Return hard blockers that forbid all PR queue work (#290 AC3).
|
||||||
|
|
||||||
|
``live_namespace_broken`` fails the merge/review path closed when the live
|
||||||
|
MCP namespace call path is unusable (e.g. ``client is closing: EOF``) even
|
||||||
|
though the tool is registered in FastMCP (#543 AC5). Feed it the
|
||||||
|
``blocks_merge_workflow`` verdict from
|
||||||
|
``mcp_namespace_health.classify_namespace_probe``.
|
||||||
|
"""
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if infra_stop:
|
if infra_stop:
|
||||||
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
|
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
|
||||||
@@ -104,6 +112,12 @@ def assess_workflow_blockers(
|
|||||||
reasons.append("MCP reconnect failed; stale session state cannot be reused")
|
reasons.append("MCP reconnect failed; stale session state cannot be reused")
|
||||||
if stale_capability_state:
|
if stale_capability_state:
|
||||||
reasons.append("stale MCP capability state detected after reconnect failure")
|
reasons.append("stale MCP capability state detected after reconnect failure")
|
||||||
|
if live_namespace_broken:
|
||||||
|
reasons.append(
|
||||||
|
"live MCP namespace call path is broken (registered in FastMCP but "
|
||||||
|
"not callable through the namespace); repair the namespace before "
|
||||||
|
"review/merge"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"block": bool(reasons),
|
"block": bool(reasons),
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
@@ -118,16 +132,19 @@ def assess_state_advancement(
|
|||||||
state_completion: dict[str, bool] | None,
|
state_completion: dict[str, bool] | None,
|
||||||
*,
|
*,
|
||||||
target_state: str,
|
target_state: str,
|
||||||
infra_stop: bool = False,
|
**blocker_kwargs,
|
||||||
capability_blocked: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Fail closed when *target_state* is requested before upstream gates pass."""
|
"""Fail closed when *target_state* is requested before upstream gates pass.
|
||||||
|
|
||||||
|
Forwards every blocker flag (``infra_stop``, ``capability_blocked``,
|
||||||
|
``mcp_reconnect_failed``, ``stale_capability_state``,
|
||||||
|
``live_namespace_broken``) to :func:`assess_workflow_blockers` so
|
||||||
|
``can_approve``/``can_merge`` honor the full blocker set, not just
|
||||||
|
infra_stop/capability_blocked (#543 AC5).
|
||||||
|
"""
|
||||||
completion = dict(state_completion or {})
|
completion = dict(state_completion or {})
|
||||||
target = _clean(target_state).upper()
|
target = _clean(target_state).upper()
|
||||||
blockers = assess_workflow_blockers(
|
blockers = assess_workflow_blockers(**blocker_kwargs)
|
||||||
infra_stop=infra_stop,
|
|
||||||
capability_blocked=capability_blocked,
|
|
||||||
)
|
|
||||||
reasons = list(blockers["reasons"])
|
reasons = list(blockers["reasons"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+203
-9
@@ -858,9 +858,16 @@ _WALKTHROUGH_ARTIFACT_RE = re.compile(r"walkthrough\.md", re.I)
|
|||||||
|
|
||||||
|
|
||||||
def _performed_file_mutations(action_log: list[dict] | None) -> list[dict]:
|
def _performed_file_mutations(action_log: list[dict] | None) -> list[dict]:
|
||||||
"""Return performed local file mutations, excluding gated rejections."""
|
"""Return performed local file mutations, excluding gated rejections.
|
||||||
|
|
||||||
|
Non-dict entries (malformed JSON, LLM mistakes) are ignored instead of
|
||||||
|
raising ``AttributeError`` (#698): a malformed ledger entry can never be
|
||||||
|
authoritative mutation evidence.
|
||||||
|
"""
|
||||||
performed: list[dict] = []
|
performed: list[dict] = []
|
||||||
for entry in action_log or []:
|
for entry in action_log or []:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
if entry.get("gated_rejected") or entry.get("performed") is False:
|
if entry.get("gated_rejected") or entry.get("performed") is False:
|
||||||
continue
|
continue
|
||||||
action = (entry.get("action") or "").strip().lower()
|
action = (entry.get("action") or "").strip().lower()
|
||||||
@@ -2228,24 +2235,31 @@ HANDOFF_REVIEW_MUTATION_FIELDS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
HANDOFF_ROLE_FIELDS = {
|
HANDOFF_ROLE_FIELDS = {
|
||||||
|
# #698: the review/merger required-field sets must stay aligned with the
|
||||||
|
# canonical schema (skills/llm-project-workflow/schemas/
|
||||||
|
# review-merge-final-report.md). The schema explicitly FORBIDS the legacy
|
||||||
|
# fields 'Pinned reviewed head', 'Scratch worktree used', and 'Workspace
|
||||||
|
# mutations' — a validator must never demand a field the schema bans.
|
||||||
"review": (
|
"review": (
|
||||||
("Selected PR", ("selected pr",)),
|
("Selected PR", ("selected pr",)),
|
||||||
("Reviewer eligibility", ("reviewer eligibility", "eligibility")),
|
("Reviewer eligibility", ("reviewer eligibility", "eligibility")),
|
||||||
("Pinned reviewed head", ("pinned reviewed head", "pinned head")),
|
("Reviewed head SHA", ("reviewed head sha", "candidate head sha")),
|
||||||
("Worktree path", ("worktree path", "starting worktree path")),
|
("Review worktree path", ("review worktree path", "worktree path",
|
||||||
("Worktree dirty", ("worktree dirty", "whether worktree was dirty")),
|
"starting worktree path")),
|
||||||
("Scratch worktree used", ("scratch worktree used", "scratch clone used",
|
("Review worktree dirty", ("review worktree dirty", "worktree dirty",
|
||||||
"scratch worktree")),
|
"whether worktree was dirty")),
|
||||||
("Unrelated local mutations", ("unrelated local mutations",
|
("Unrelated local mutations", ("unrelated local mutations",
|
||||||
"unrelated files modified")),
|
"unrelated files modified",
|
||||||
|
"file edits by reviewer")),
|
||||||
("Review decision", ("review decision", "decision")),
|
("Review decision", ("review decision", "decision")),
|
||||||
("Merge result", ("merge result",)),
|
("Merge result", ("merge result",)),
|
||||||
("Linked issue status", ("linked issue status", "linked issue")),
|
("Linked issue status", ("linked issue status", "linked issue")),
|
||||||
("Cleanup status", ("cleanup status", "cleanup")),
|
("Cleanup status", ("cleanup status", "cleanup")),
|
||||||
|
("Safe next action", ("safe next action", "next")),
|
||||||
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
||||||
"merger": (
|
"merger": (
|
||||||
("Selected PR", ("selected pr",)),
|
("Selected PR", ("selected pr",)),
|
||||||
("Pinned reviewed head", ("pinned reviewed head", "pinned head")),
|
("Reviewed head SHA", ("reviewed head sha", "candidate head sha")),
|
||||||
("Active profile", ("active profile",)),
|
("Active profile", ("active profile",)),
|
||||||
("Role kind", ("role kind",)),
|
("Role kind", ("role kind",)),
|
||||||
("Merge capability source", ("merge capability source",)),
|
("Merge capability source", ("merge capability source",)),
|
||||||
@@ -2433,9 +2447,22 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
|
|||||||
# Issue #320: reviewer and merger handoffs use the precise mutation categories
|
# Issue #320: reviewer and merger handoffs use the precise mutation categories
|
||||||
# in HANDOFF_REVIEW_MUTATION_FIELDS instead of the legacy ambiguous
|
# in HANDOFF_REVIEW_MUTATION_FIELDS instead of the legacy ambiguous
|
||||||
# "Workspace mutations" field, which is rejected below.
|
# "Workspace mutations" field, which is rejected below.
|
||||||
|
# Issue #698: the canonical review-merge schema has no 'Mutations',
|
||||||
|
# 'Next', 'Issue/PR', 'Branch/SHA', or 'Files changed' fields — their
|
||||||
|
# content lives in the precise mutation categories, 'Safe next
|
||||||
|
# action', 'Selected PR'/'Linked issue', head-SHA fields, and 'Files
|
||||||
|
# reviewed'. Requiring the legacy names rejects canonical reports.
|
||||||
|
_non_canonical_for_review = {
|
||||||
|
"Workspace mutations",
|
||||||
|
"Mutations",
|
||||||
|
"Next",
|
||||||
|
"Issue/PR",
|
||||||
|
"Branch/SHA",
|
||||||
|
"Files changed",
|
||||||
|
}
|
||||||
required = [
|
required = [
|
||||||
field for field in required
|
field for field in required
|
||||||
if field[0] != "Workspace mutations"
|
if field[0] not in _non_canonical_for_review
|
||||||
]
|
]
|
||||||
if any(label.startswith("workspace mutations") for label in labels):
|
if any(label.startswith("workspace mutations") for label in labels):
|
||||||
return {
|
return {
|
||||||
@@ -4842,6 +4869,22 @@ RECONCILER_CLOSE_REPORT_FIELDS = (
|
|||||||
"no review/merge confirmation",
|
"no review/merge confirmation",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RECONCILER_SUPERSESSION_REPORT_FIELDS = (
|
||||||
|
"identity/profile",
|
||||||
|
"supersession close capability proof",
|
||||||
|
"target PR live state",
|
||||||
|
"target PR independent-work proof",
|
||||||
|
"superseding PR merged state",
|
||||||
|
"superseding merge commit SHA",
|
||||||
|
"target branch SHA",
|
||||||
|
"superseding ancestry proof",
|
||||||
|
"canonical close comment",
|
||||||
|
"linked issue status",
|
||||||
|
"PR close result",
|
||||||
|
"issue close result",
|
||||||
|
"no review/merge confirmation",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def assess_reconciler_close_gate(
|
def assess_reconciler_close_gate(
|
||||||
*,
|
*,
|
||||||
@@ -4988,6 +5031,157 @@ def assess_reconciler_close_gate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_reconciler_supersession_close_gate(
|
||||||
|
*,
|
||||||
|
target_pr_number: int | None,
|
||||||
|
target_pr_state: str | None,
|
||||||
|
target_pr_mergeable: bool | None,
|
||||||
|
target_independently_required: bool | None,
|
||||||
|
superseding_pr_number: int | None,
|
||||||
|
superseding_pr_state: str | None,
|
||||||
|
superseding_pr_merged: bool,
|
||||||
|
superseding_merge_commit_sha: str | None,
|
||||||
|
superseding_head_sha: str | None,
|
||||||
|
target_branch: str | None,
|
||||||
|
target_branch_sha: str | None,
|
||||||
|
superseding_head_is_ancestor_of_target: bool | None,
|
||||||
|
canonical_comment_valid: bool,
|
||||||
|
canonical_comment_mentions_superseding_pr: bool,
|
||||||
|
canonical_comment_mentions_merge_commit: bool,
|
||||||
|
close_pr_capability: bool,
|
||||||
|
close_issue_capability: bool = False,
|
||||||
|
target_issue_state: str | None = None,
|
||||||
|
issue_satisfied_by_superseding: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Gate reconciler closure of PRs/issues superseded by a merged PR.
|
||||||
|
|
||||||
|
This is stricter than already-landed cleanup: the target PR may be closed
|
||||||
|
only after a named superseding PR is live-verified merged, its head is on a
|
||||||
|
freshly fetched target branch, the target PR is proven not independently
|
||||||
|
required, and a canonical close comment cites the superseding PR and merge
|
||||||
|
commit.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not isinstance(target_pr_number, int) or target_pr_number <= 0:
|
||||||
|
reasons.append("target PR number missing or invalid (#525)")
|
||||||
|
if not isinstance(superseding_pr_number, int) or superseding_pr_number <= 0:
|
||||||
|
reasons.append("superseding PR number missing or invalid (#525)")
|
||||||
|
if target_pr_number and superseding_pr_number and (
|
||||||
|
target_pr_number == superseding_pr_number
|
||||||
|
):
|
||||||
|
reasons.append("target PR and superseding PR must differ (#525)")
|
||||||
|
if (target_pr_state or "").strip().lower() != "open":
|
||||||
|
reasons.append("target PR must be live-verified open before close (#525)")
|
||||||
|
if target_pr_mergeable is True:
|
||||||
|
reasons.append(
|
||||||
|
"target PR is still mergeable; prove it is superseded before close (#525)"
|
||||||
|
)
|
||||||
|
if target_independently_required is not False:
|
||||||
|
reasons.append(
|
||||||
|
"target PR independent-work proof missing; close requires explicit "
|
||||||
|
"proof that no required work remains (#525)"
|
||||||
|
)
|
||||||
|
if (superseding_pr_state or "").strip().lower() != "closed":
|
||||||
|
reasons.append("superseding PR must be live-verified closed (#525)")
|
||||||
|
if superseding_pr_merged is not True:
|
||||||
|
reasons.append("superseding PR must be live-verified merged (#525)")
|
||||||
|
if not _FULL_SHA.match((superseding_merge_commit_sha or "").strip()):
|
||||||
|
reasons.append("superseding merge commit SHA missing or invalid (#525)")
|
||||||
|
if not _FULL_SHA.match((superseding_head_sha or "").strip()):
|
||||||
|
reasons.append("superseding head SHA missing or invalid (#525)")
|
||||||
|
if not (target_branch or "").strip():
|
||||||
|
reasons.append("target branch missing (#525)")
|
||||||
|
if not _FULL_SHA.match((target_branch_sha or "").strip()):
|
||||||
|
reasons.append("target branch SHA missing or invalid (#525)")
|
||||||
|
if superseding_head_is_ancestor_of_target is None:
|
||||||
|
reasons.append("superseding ancestry proof not checked (#525)")
|
||||||
|
if not canonical_comment_valid:
|
||||||
|
reasons.append("canonical close comment failed validation (#525)")
|
||||||
|
if not canonical_comment_mentions_superseding_pr:
|
||||||
|
reasons.append("canonical comment must cite superseding PR (#525)")
|
||||||
|
if not canonical_comment_mentions_merge_commit:
|
||||||
|
reasons.append("canonical comment must cite merge commit SHA (#525)")
|
||||||
|
|
||||||
|
never_allowed = {
|
||||||
|
"review_allowed": False,
|
||||||
|
"approve_allowed": False,
|
||||||
|
"request_changes_allowed": False,
|
||||||
|
"merge_allowed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return {
|
||||||
|
"outcome": "GATE_NOT_PROVEN",
|
||||||
|
"pr_close_allowed": False,
|
||||||
|
"issue_close_allowed": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": (
|
||||||
|
"prove superseding PR state, target branch ancestry, target "
|
||||||
|
"supersession, and canonical comment before closing"
|
||||||
|
),
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
if superseding_head_is_ancestor_of_target is False:
|
||||||
|
return {
|
||||||
|
"outcome": "SUPERSEDING_PR_NOT_ON_TARGET",
|
||||||
|
"pr_close_allowed": False,
|
||||||
|
"issue_close_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
f"superseding PR #{superseding_pr_number} head is not an "
|
||||||
|
f"ancestor of {target_branch}; supersession close denied (#525)"
|
||||||
|
],
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": "re-fetch target branch and re-check ancestry",
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
if close_pr_capability is not True:
|
||||||
|
return {
|
||||||
|
"outcome": "RECOVERY_HANDOFF_REQUIRED",
|
||||||
|
"pr_close_allowed": False,
|
||||||
|
"issue_close_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
"exact gitea.pr.close capability not proven; produce a "
|
||||||
|
"recovery handoff instead of closing (#525)"
|
||||||
|
],
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": (
|
||||||
|
"launch a reconciler profile with gitea.pr.close and replay "
|
||||||
|
"the live-state proof"
|
||||||
|
),
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
issue_close_allowed = (
|
||||||
|
(target_issue_state or "").strip().lower() == "open"
|
||||||
|
and issue_satisfied_by_superseding is True
|
||||||
|
and close_issue_capability is True
|
||||||
|
)
|
||||||
|
issue_reasons = []
|
||||||
|
if (target_issue_state or "").strip().lower() == "closed":
|
||||||
|
issue_reasons.append("linked issue already closed; no issue close attempted (#525)")
|
||||||
|
elif target_issue_state and not issue_close_allowed:
|
||||||
|
issue_reasons.append(
|
||||||
|
"issue close requires an open issue satisfied by the superseding "
|
||||||
|
"merged PR and exact gitea.issue.close capability (#525)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"outcome": "SUPERSESSION_CLOSE_ALLOWED",
|
||||||
|
"pr_close_allowed": True,
|
||||||
|
"issue_close_allowed": issue_close_allowed,
|
||||||
|
"reasons": issue_reasons,
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": (
|
||||||
|
"post the canonical comment, close the superseded PR, and close "
|
||||||
|
"the linked issue only when issue_close_allowed is true"
|
||||||
|
),
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Identity disclosure (#305)
|
# Identity disclosure (#305)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""Controller quarantine of contaminated formal reviews (#695).
|
||||||
|
|
||||||
|
Quarantine records are durable under the MCP session-state root and are only
|
||||||
|
writable when the caller holds a **native** MCP transport runtime. Untrusted
|
||||||
|
local scripts that import this module cannot establish a valid quarantine
|
||||||
|
(writes fail closed). Live server-side gates (eligibility, merge, feedback)
|
||||||
|
honor quarantine by review_id + PR + head.
|
||||||
|
|
||||||
|
Forensic evidence (Gitea review objects, lease comments) is never deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import mcp_daemon_guard
|
||||||
|
import mcp_session_state
|
||||||
|
|
||||||
|
KIND_QUARANTINE = "review_quarantine"
|
||||||
|
CONFIRMATION_PREFIX = "QUARANTINE CONTAMINATED REVIEW"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def quarantine_state_path(
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
review_id: int,
|
||||||
|
) -> str:
|
||||||
|
# Dedicated subdir under session state root (mode 0o700).
|
||||||
|
root = os.path.join(mcp_session_state.default_state_dir(), "quarantine")
|
||||||
|
os.makedirs(root, mode=0o700, exist_ok=True)
|
||||||
|
# Explicit multi-segment key so remote/org/repo/pr/review cannot collide.
|
||||||
|
segs = [
|
||||||
|
KIND_QUARANTINE,
|
||||||
|
mcp_session_state._sanitize_segment(remote),
|
||||||
|
mcp_session_state._sanitize_segment(org),
|
||||||
|
mcp_session_state._sanitize_segment(repo),
|
||||||
|
f"pr{int(pr_number)}",
|
||||||
|
f"rev{int(review_id)}",
|
||||||
|
]
|
||||||
|
return os.path.join(root, "-".join(segs) + ".json")
|
||||||
|
|
||||||
|
|
||||||
|
def build_quarantine_record(
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
review_id: int,
|
||||||
|
reviewed_head_sha: str,
|
||||||
|
reason: str,
|
||||||
|
actor_username: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
incident_issue: int | None = None,
|
||||||
|
forensic_comment_ids: list[int] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
provenance = mcp_daemon_guard.mutation_provenance_fields()
|
||||||
|
return {
|
||||||
|
"kind": KIND_QUARANTINE,
|
||||||
|
"issue_ref": "#695",
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"pr_number": int(pr_number),
|
||||||
|
"review_id": int(review_id),
|
||||||
|
"reviewed_head_sha": (reviewed_head_sha or "").strip().lower(),
|
||||||
|
"reason": (reason or "").strip(),
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"incident_issue": incident_issue,
|
||||||
|
"forensic_comment_ids": list(forensic_comment_ids or []),
|
||||||
|
"created_at": _now(),
|
||||||
|
"native_provenance": provenance,
|
||||||
|
"merge_authorization": "void",
|
||||||
|
"retain_forensic_evidence": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_quarantine_write(
|
||||||
|
*,
|
||||||
|
confirmation: str,
|
||||||
|
pr_number: int,
|
||||||
|
review_id: int,
|
||||||
|
reason: str,
|
||||||
|
native_required: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Pure assessment of whether a quarantine write may proceed."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
expected = f"{CONFIRMATION_PREFIX} {int(review_id)} PR {int(pr_number)}"
|
||||||
|
if (confirmation or "").strip() != expected:
|
||||||
|
reasons.append(
|
||||||
|
f"confirmation must equal exactly '{expected}' (fail closed, #695)"
|
||||||
|
)
|
||||||
|
if not (reason or "").strip():
|
||||||
|
reasons.append("quarantine reason is required (fail closed, #695)")
|
||||||
|
if native_required and not mcp_daemon_guard.is_native_mcp_transport():
|
||||||
|
if not mcp_daemon_guard.is_pytest_runtime():
|
||||||
|
reasons.append(
|
||||||
|
"quarantine write requires native MCP transport; untrusted "
|
||||||
|
"local code cannot create quarantine records (#695)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"allowed": not reasons,
|
||||||
|
"expected_confirmation": expected,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_quarantine_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Persist quarantine record; fail closed outside native/pytest runtime."""
|
||||||
|
if not mcp_daemon_guard.is_native_mcp_transport():
|
||||||
|
if not mcp_daemon_guard.is_pytest_runtime():
|
||||||
|
raise mcp_daemon_guard.UnsanctionedRuntimeError(
|
||||||
|
"quarantine write blocked: non-native runtime (#695)"
|
||||||
|
)
|
||||||
|
path = quarantine_state_path(
|
||||||
|
remote=str(record["remote"]),
|
||||||
|
org=str(record["org"]),
|
||||||
|
repo=str(record["repo"]),
|
||||||
|
pr_number=int(record["pr_number"]),
|
||||||
|
review_id=int(record["review_id"]),
|
||||||
|
)
|
||||||
|
# Refuse overwriting with weaker provenance from untrusted caller by
|
||||||
|
# requiring native fields present.
|
||||||
|
if not (record.get("native_provenance") or {}).get("native_mcp_transport"):
|
||||||
|
if not mcp_daemon_guard.is_pytest_runtime():
|
||||||
|
raise mcp_daemon_guard.UnsanctionedRuntimeError(
|
||||||
|
"quarantine record missing native provenance (#695)"
|
||||||
|
)
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
data = json.dumps(record, indent=2, sort_keys=True)
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
return {"path": path, "written": True, "record": record}
|
||||||
|
|
||||||
|
|
||||||
|
def load_quarantine_record(
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
review_id: int,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
path = quarantine_state_path(
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
review_id=review_id,
|
||||||
|
)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def is_review_quarantined(
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
review_id: int | None,
|
||||||
|
reviewed_head_sha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Whether a formal review is quarantined for merge authorization (#695)."""
|
||||||
|
if review_id is None:
|
||||||
|
return {"quarantined": False, "record": None, "reasons": []}
|
||||||
|
rec = load_quarantine_record(
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
review_id=int(review_id),
|
||||||
|
)
|
||||||
|
if not rec:
|
||||||
|
return {"quarantined": False, "record": None, "reasons": []}
|
||||||
|
reasons = [
|
||||||
|
f"formal review_id {review_id} on PR #{pr_number} is quarantined "
|
||||||
|
f"(#695; incident issue {rec.get('incident_issue')}); "
|
||||||
|
"merge authorization is void; forensic evidence retained"
|
||||||
|
]
|
||||||
|
want_head = (reviewed_head_sha or "").strip().lower()
|
||||||
|
rec_head = (rec.get("reviewed_head_sha") or "").strip().lower()
|
||||||
|
if want_head and rec_head and want_head != rec_head:
|
||||||
|
# Still quarantined by review_id; note head mismatch for operators.
|
||||||
|
reasons.append(
|
||||||
|
f"quarantine head {rec_head[:12]}… differs from assessed head "
|
||||||
|
f"{want_head[:12]}… (still void by review_id)"
|
||||||
|
)
|
||||||
|
return {"quarantined": True, "record": rec, "reasons": reasons}
|
||||||
|
|
||||||
|
|
||||||
|
def filter_approvals_for_merge(
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
current_head_sha: str | None,
|
||||||
|
reviews: list[dict],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Split reviews into merge-usable vs quarantined contaminated approvals."""
|
||||||
|
current = (current_head_sha or "").strip().lower()
|
||||||
|
usable: list[dict] = []
|
||||||
|
quarantined: list[dict] = []
|
||||||
|
for rev in reviews or []:
|
||||||
|
if not isinstance(rev, dict):
|
||||||
|
continue
|
||||||
|
verdict = (rev.get("verdict") or rev.get("state") or "").upper()
|
||||||
|
if verdict not in {"APPROVED", "APPROVE"}:
|
||||||
|
continue
|
||||||
|
if rev.get("dismissed"):
|
||||||
|
continue
|
||||||
|
rid = rev.get("review_id") or rev.get("id")
|
||||||
|
head = (
|
||||||
|
rev.get("reviewed_head_sha")
|
||||||
|
or rev.get("commit_id")
|
||||||
|
or rev.get("head_sha")
|
||||||
|
or ""
|
||||||
|
).strip().lower()
|
||||||
|
q = is_review_quarantined(
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
review_id=int(rid) if rid is not None else None,
|
||||||
|
reviewed_head_sha=head or current,
|
||||||
|
)
|
||||||
|
entry = {**rev, "review_id": rid, "reviewed_head_sha": head}
|
||||||
|
if q["quarantined"]:
|
||||||
|
entry["quarantined"] = True
|
||||||
|
entry["quarantine_reasons"] = q["reasons"]
|
||||||
|
quarantined.append(entry)
|
||||||
|
else:
|
||||||
|
entry["quarantined"] = False
|
||||||
|
usable.append(entry)
|
||||||
|
usable_at_head = [
|
||||||
|
e
|
||||||
|
for e in usable
|
||||||
|
if current and (e.get("reviewed_head_sha") or "") == current
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"usable_approvals": usable,
|
||||||
|
"usable_approvals_at_current_head": usable_at_head,
|
||||||
|
"quarantined_approvals": quarantined,
|
||||||
|
"approval_visible_for_merge": bool(usable_at_head),
|
||||||
|
"has_quarantined_approval_at_head": any(
|
||||||
|
current
|
||||||
|
and (e.get("reviewed_head_sha") or "") == current
|
||||||
|
for e in quarantined
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_quarantine_audit_comment(record: dict[str, Any]) -> str:
|
||||||
|
"""Append-only forensic audit comment body (does not delete evidence)."""
|
||||||
|
lines = [
|
||||||
|
"## Contaminated formal review quarantine (#695)",
|
||||||
|
"",
|
||||||
|
"Status: **QUARANTINED — merge authorization VOID**",
|
||||||
|
"",
|
||||||
|
f"- review_id: `{record.get('review_id')}`",
|
||||||
|
f"- pr: `#{record.get('pr_number')}`",
|
||||||
|
f"- reviewed_head_sha: `{record.get('reviewed_head_sha')}`",
|
||||||
|
f"- actor: `{record.get('actor_username')}`",
|
||||||
|
f"- profile: `{record.get('profile_name')}`",
|
||||||
|
f"- incident_issue: `#{record.get('incident_issue')}`",
|
||||||
|
f"- reason: {record.get('reason')}",
|
||||||
|
f"- created_at: `{record.get('created_at')}`",
|
||||||
|
f"- native_transport: "
|
||||||
|
f"`{(record.get('native_provenance') or {}).get('native_mcp_transport')}`",
|
||||||
|
f"- native_token_fingerprint: "
|
||||||
|
f"`{(record.get('native_provenance') or {}).get('native_token_fingerprint')}`",
|
||||||
|
f"- forensic_comment_ids retained: "
|
||||||
|
f"`{record.get('forensic_comment_ids')}`",
|
||||||
|
"",
|
||||||
|
"This record does **not** delete Gitea reviews or historical comments. "
|
||||||
|
"Fresh native-MCP re-review is required before merge.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_untrusted_canonical_approval_claim(body: str) -> list[str]:
|
||||||
|
"""Reasons to reject canonical comments that claim approved/merge-ready.
|
||||||
|
|
||||||
|
Used when the comment asserts approval without native review proof (#695 AC7).
|
||||||
|
False "official workflow" claims that cite offline/import paths are always
|
||||||
|
rejected even when a NATIVE_REVIEW_PROOF line is present.
|
||||||
|
"""
|
||||||
|
text = body or ""
|
||||||
|
lower = text.lower()
|
||||||
|
claims_approved = (
|
||||||
|
"state:\napproved" in lower
|
||||||
|
or "state: approved" in lower
|
||||||
|
or "who_is_next:\nmerger" in lower
|
||||||
|
or "who_is_next: merger" in lower
|
||||||
|
or "merge_ready: true" in lower
|
||||||
|
or "merge_ready:\ntrue" in lower
|
||||||
|
or "ready-to-merge" in lower
|
||||||
|
)
|
||||||
|
if not claims_approved:
|
||||||
|
return []
|
||||||
|
# Reject explicit offline/import "official workflow" spoofing (#695 AC9).
|
||||||
|
offline_spoof = (
|
||||||
|
"offline_mcp" in lower
|
||||||
|
or "offline import" in lower
|
||||||
|
or "direct import" in lower
|
||||||
|
or "import gitea_mcp_server" in lower
|
||||||
|
or "run_quarantine.py" in lower
|
||||||
|
or "offline_mcp_helper" in lower
|
||||||
|
or "offline_mcp_runner" in lower
|
||||||
|
)
|
||||||
|
has_proof = (
|
||||||
|
"NATIVE_REVIEW_PROOF:" in text
|
||||||
|
or "native_review_proof:" in lower
|
||||||
|
)
|
||||||
|
if offline_spoof:
|
||||||
|
return [
|
||||||
|
"canonical approval claim cites offline/import/helper path; "
|
||||||
|
"NATIVE_REVIEW_PROOF rejected (#695 AC7/AC9); stop after native "
|
||||||
|
"MCP failure — do not construct offline mutation fallbacks"
|
||||||
|
]
|
||||||
|
if has_proof:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
"canonical comment claims approved/merge-ready state without "
|
||||||
|
"NATIVE_REVIEW_PROOF from a native MCP review mutation (#695 AC7); "
|
||||||
|
"contaminated or untrusted approvals cannot certify merger handoff"
|
||||||
|
]
|
||||||
+1137
-9
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,20 @@ _READONLY_REVIEWER_GIT = re.compile(
|
|||||||
_GIT_INVOCATION = re.compile(r"\bgit\b", re.IGNORECASE)
|
_GIT_INVOCATION = re.compile(r"\bgit\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
# #673: Canonical pattern for identifying review worktrees under branches/.
|
||||||
|
REVIEW_WORKTREE_RE = re.compile(
|
||||||
|
r"branches/(?:review-pr\d+[\w/-]*|merge-simulation-pr\d+|review-[\w-]+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_review_worktree_path(path: str) -> bool:
|
||||||
|
"""True when the path belongs to a reviewer or simulation worktree."""
|
||||||
|
normalized = (path or "").replace("\\", "/")
|
||||||
|
return bool(REVIEW_WORKTREE_RE.search(normalized))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def parse_dirty_tracked_files(porcelain: str) -> list[str]:
|
def parse_dirty_tracked_files(porcelain: str) -> list[str]:
|
||||||
"""Return tracked paths with local modifications from ``git status --porcelain``.
|
"""Return tracked paths with local modifications from ``git status --porcelain``.
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_SESSION_OWNED_RE = re.compile(
|
from reviewer_worktree import REVIEW_WORKTREE_RE
|
||||||
r"branches/(?:review-pr\d+[\w/-]*|review-[\w-]+)",
|
|
||||||
re.IGNORECASE,
|
_SESSION_OWNED_RE = REVIEW_WORKTREE_RE
|
||||||
)
|
|
||||||
_WORKTREE_PATH_RE = re.compile(
|
_WORKTREE_PATH_RE = re.compile(
|
||||||
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
|
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|||||||
+45
-4
@@ -55,7 +55,6 @@ def skip_python_scan_walk_root(project_root: str, walk_root: str) -> bool:
|
|||||||
|
|
||||||
REVIEWER_TASKS = frozenset({
|
REVIEWER_TASKS = frozenset({
|
||||||
"review_pr",
|
"review_pr",
|
||||||
"merge_pr",
|
|
||||||
"blind_pr_queue_review",
|
"blind_pr_queue_review",
|
||||||
"pr_queue_cleanup",
|
"pr_queue_cleanup",
|
||||||
"pr-queue-cleanup",
|
"pr-queue-cleanup",
|
||||||
@@ -63,6 +62,10 @@ REVIEWER_TASKS = frozenset({
|
|||||||
"approve_pr",
|
"approve_pr",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
MERGER_TASKS = frozenset({
|
||||||
|
"merge_pr",
|
||||||
|
})
|
||||||
|
|
||||||
AUTHOR_TASKS = frozenset({
|
AUTHOR_TASKS = frozenset({
|
||||||
"create_issue",
|
"create_issue",
|
||||||
"comment_issue",
|
"comment_issue",
|
||||||
@@ -73,7 +76,6 @@ AUTHOR_TASKS = frozenset({
|
|||||||
"create_pr",
|
"create_pr",
|
||||||
"comment_pr",
|
"comment_pr",
|
||||||
"address_pr_change_requests",
|
"address_pr_change_requests",
|
||||||
"delete_branch",
|
|
||||||
"work_issue",
|
"work_issue",
|
||||||
"work-issue",
|
"work-issue",
|
||||||
"reconcile_landed_pr",
|
"reconcile_landed_pr",
|
||||||
@@ -81,6 +83,15 @@ AUTHOR_TASKS = frozenset({
|
|||||||
|
|
||||||
RECONCILER_TASKS = frozenset({
|
RECONCILER_TASKS = frozenset({
|
||||||
"cleanup_merged_pr_branch",
|
"cleanup_merged_pr_branch",
|
||||||
|
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
|
||||||
|
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
|
||||||
|
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
|
||||||
|
"delete_branch",
|
||||||
|
# #745: post-merge moot reviewer-lease cleanup is reconciler-owned; the
|
||||||
|
# apply path posts a terminal lease marker. Kept in step with
|
||||||
|
# task_capability_map so map and router cannot disagree (#723 defect A).
|
||||||
|
"cleanup_post_merge_moot_lease",
|
||||||
|
"gitea_cleanup_post_merge_moot_lease",
|
||||||
"reconcile_already_landed_pr",
|
"reconcile_already_landed_pr",
|
||||||
"reconcile_already_landed",
|
"reconcile_already_landed",
|
||||||
"reconcile-landed-pr",
|
"reconcile-landed-pr",
|
||||||
@@ -96,9 +107,10 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"create_pr": "author",
|
"create_pr": "author",
|
||||||
"comment_pr": "author",
|
"comment_pr": "author",
|
||||||
"address_pr_change_requests": "author",
|
"address_pr_change_requests": "author",
|
||||||
"delete_branch": "author",
|
# #729: reconciler-owned; see RECONCILER_TASKS and task_capability_map.
|
||||||
|
"delete_branch": "reconciler",
|
||||||
"review_pr": "reviewer",
|
"review_pr": "reviewer",
|
||||||
"merge_pr": "reviewer",
|
"merge_pr": "merger",
|
||||||
"blind_pr_queue_review": "reviewer",
|
"blind_pr_queue_review": "reviewer",
|
||||||
"pr_queue_cleanup": "reviewer",
|
"pr_queue_cleanup": "reviewer",
|
||||||
"pr-queue-cleanup": "reviewer",
|
"pr-queue-cleanup": "reviewer",
|
||||||
@@ -111,9 +123,15 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"reconcile_already_landed": "reconciler",
|
"reconcile_already_landed": "reconciler",
|
||||||
"reconcile-landed-pr": "reconciler",
|
"reconcile-landed-pr": "reconciler",
|
||||||
"cleanup_merged_pr_branch": "reconciler",
|
"cleanup_merged_pr_branch": "reconciler",
|
||||||
|
# #745: post-merge moot reviewer-lease cleanup (canonical task + tool alias).
|
||||||
|
"cleanup_post_merge_moot_lease": "reconciler",
|
||||||
|
"gitea_cleanup_post_merge_moot_lease": "reconciler",
|
||||||
# #309: reconciler tasks close already-landed PRs/issues only.
|
# #309: reconciler tasks close already-landed PRs/issues only.
|
||||||
"reconcile_close_landed_pr": "reconciler",
|
"reconcile_close_landed_pr": "reconciler",
|
||||||
"reconcile_close_landed_issue": "reconciler",
|
"reconcile_close_landed_issue": "reconciler",
|
||||||
|
"reconcile_close_superseded_pr": "reconciler",
|
||||||
|
"reconcile_close_satisfied_issue": "reconciler",
|
||||||
|
"reconcile_create_followup_issue": "reconciler",
|
||||||
}
|
}
|
||||||
|
|
||||||
WRONG_ROLE_REVIEWER_MSG = (
|
WRONG_ROLE_REVIEWER_MSG = (
|
||||||
@@ -125,6 +143,10 @@ WRONG_ROLE_RECONCILER_MSG = (
|
|||||||
"MCP namespace/profile with exact close capability."
|
"MCP namespace/profile with exact close capability."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
WRONG_ROLE_MERGER_MSG = (
|
||||||
|
"Wrong role/session for merger task. Launch merger MCP namespace."
|
||||||
|
)
|
||||||
|
|
||||||
_session_last_route: dict | None = None
|
_session_last_route: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -240,6 +262,25 @@ def route_task_session(
|
|||||||
_record_route(result)
|
_record_route(result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if required_role == "merger":
|
||||||
|
result = {
|
||||||
|
"task_type": task_type,
|
||||||
|
"required_role": required_role,
|
||||||
|
"active_role": active_role_kind,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"route_result": ROUTE_WRONG_ROLE,
|
||||||
|
"downstream_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
WRONG_ROLE_MERGER_MSG,
|
||||||
|
"Merger tasks cannot run in author or reviewer sessions.",
|
||||||
|
],
|
||||||
|
"message": WRONG_ROLE_MERGER_MSG,
|
||||||
|
"runtime_switching_supported": runtime_switching_supported,
|
||||||
|
"profile_switch_blocked": not runtime_switching_supported,
|
||||||
|
}
|
||||||
|
_record_route(result)
|
||||||
|
return result
|
||||||
|
|
||||||
if required_role == "author":
|
if required_role == "author":
|
||||||
route = ROUTE_TO_AUTHOR
|
route = ROUTE_TO_AUTHOR
|
||||||
message = (
|
message = (
|
||||||
|
|||||||
Executable
+171
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: scripts/promote-stable-runtime [--root <path>] [--promoted <sha>] \
|
||||||
|
[--source-branch <branch>] [--source-pr <n>] \
|
||||||
|
[--restart-method <text>] [--rollback <text>] \
|
||||||
|
[--health-check-proof <text>] \
|
||||||
|
[--identity-proof <text>] [--profile-proof <text>] \
|
||||||
|
[--workspace-proof <text>] \
|
||||||
|
[--mutation-capability-proof <text>]
|
||||||
|
|
||||||
|
Emit and validate a stable-control-runtime promotion record (#615).
|
||||||
|
|
||||||
|
This helper is READ-ONLY. It never fetches, merges, restarts, reloads, or kills
|
||||||
|
anything: promotion itself is an operator action documented in
|
||||||
|
docs/stable-runtime-promotion-runbook.md. The helper reads the current runtime
|
||||||
|
state, assembles the required record, validates it with
|
||||||
|
stable_control_runtime.assess_promotion_record(), and prints it for the operator
|
||||||
|
to act on and archive.
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
--root the repository root containing this script
|
||||||
|
--promoted HEAD of that root
|
||||||
|
|
||||||
|
Exit status is non-zero when the assembled record is incomplete, so a promotion
|
||||||
|
cannot be recorded without its proof fields.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
scripts/promote-stable-runtime \
|
||||||
|
--source-branch feat/issue-615-runtime-mode-enforcement \
|
||||||
|
--source-pr 770 \
|
||||||
|
--restart-method "IDE client reconnect (/mcp)" \
|
||||||
|
--rollback "git -C <root> merge --ff-only <previous-sha>; reconnect client" \
|
||||||
|
--health-check-proof "gitea_assess_mcp_namespace_health: all four healthy" \
|
||||||
|
--identity-proof "gitea_whoami per namespace" \
|
||||||
|
--profile-proof "gitea_get_runtime_context per namespace" \
|
||||||
|
--workspace-proof "process root == canonical root; clean" \
|
||||||
|
--mutation-capability-proof "gitea_resolve_task_capability: allowed"
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
ROOT=""
|
||||||
|
PROMOTED=""
|
||||||
|
SOURCE_BRANCH=""
|
||||||
|
SOURCE_PR=""
|
||||||
|
RESTART_METHOD=""
|
||||||
|
ROLLBACK=""
|
||||||
|
HEALTH_PROOF=""
|
||||||
|
IDENTITY_PROOF=""
|
||||||
|
PROFILE_PROOF=""
|
||||||
|
WORKSPACE_PROOF=""
|
||||||
|
CAPABILITY_PROOF=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--root) ROOT="${2:-}"; shift 2 ;;
|
||||||
|
--promoted) PROMOTED="${2:-}"; shift 2 ;;
|
||||||
|
--source-branch) SOURCE_BRANCH="${2:-}"; shift 2 ;;
|
||||||
|
--source-pr) SOURCE_PR="${2:-}"; shift 2 ;;
|
||||||
|
--restart-method) RESTART_METHOD="${2:-}"; shift 2 ;;
|
||||||
|
--rollback) ROLLBACK="${2:-}"; shift 2 ;;
|
||||||
|
--health-check-proof) HEALTH_PROOF="${2:-}"; shift 2 ;;
|
||||||
|
--identity-proof) IDENTITY_PROOF="${2:-}"; shift 2 ;;
|
||||||
|
--profile-proof) PROFILE_PROOF="${2:-}"; shift 2 ;;
|
||||||
|
--workspace-proof) WORKSPACE_PROOF="${2:-}"; shift 2 ;;
|
||||||
|
--mutation-capability-proof) CAPABILITY_PROOF="${2:-}"; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="${ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
||||||
|
|
||||||
|
if ! git -C "$ROOT" rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
|
echo "error: '$ROOT' is not a git checkout; cannot read runtime SHAs" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PREVIOUS="${GITEA_MCP_PREVIOUS_RUNTIME_SHA:-}"
|
||||||
|
if [[ -z "$PREVIOUS" ]]; then
|
||||||
|
# The runtime the operator is replacing. Best-effort: the commit master
|
||||||
|
# pointed at before the fast-forward, recorded in the reflog.
|
||||||
|
PREVIOUS="$(git -C "$ROOT" rev-parse 'master@{1}' 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
PROMOTED="${PROMOTED:-$(git -C "$ROOT" rev-parse HEAD)}"
|
||||||
|
BRANCH="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
|
||||||
|
DIRTY="$(git -C "$ROOT" status --porcelain | wc -l | tr -d ' ')"
|
||||||
|
STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
|
if [[ -z "$WORKSPACE_PROOF" ]]; then
|
||||||
|
WORKSPACE_PROOF="root=$ROOT branch=$BRANCH dirty_files=$DIRTY"
|
||||||
|
fi
|
||||||
|
if [[ -z "$ROLLBACK" && -n "$PREVIOUS" ]]; then
|
||||||
|
ROLLBACK="git -C $ROOT merge --ff-only $PREVIOUS (or checkout $PREVIOUS), then reload the runtime by the same sanctioned method and re-prove every namespace"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
# Stable control runtime promotion record (#615)
|
||||||
|
# Generated $STAMP by scripts/promote-stable-runtime (read-only)
|
||||||
|
|
||||||
|
previous_runtime_sha: ${PREVIOUS:-<MISSING: record the SHA the runtime served before promotion>}
|
||||||
|
promoted_runtime_sha: ${PROMOTED}
|
||||||
|
source_branch: ${SOURCE_BRANCH:-<MISSING: pass --source-branch>}
|
||||||
|
source_pr: ${SOURCE_PR:-<MISSING: pass --source-pr>}
|
||||||
|
restart_method: ${RESTART_METHOD:-<MISSING: pass --restart-method>}
|
||||||
|
health_check_proof: ${HEALTH_PROOF:-<MISSING: pass --health-check-proof>}
|
||||||
|
identity_proof: ${IDENTITY_PROOF:-<MISSING: pass --identity-proof>}
|
||||||
|
profile_proof: ${PROFILE_PROOF:-<MISSING: pass --profile-proof>}
|
||||||
|
workspace_proof: ${WORKSPACE_PROOF}
|
||||||
|
mutation_capability_proof: ${CAPABILITY_PROOF:-<MISSING: pass --mutation-capability-proof>}
|
||||||
|
rollback_instructions: ${ROLLBACK:-<MISSING: pass --rollback>}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [[ "$DIRTY" != "0" ]]; then
|
||||||
|
{
|
||||||
|
echo
|
||||||
|
echo "WARNING: the stable checkout has $DIRTY dirty file(s); a dirty stable"
|
||||||
|
echo " runtime is itself a mutation blocker (dirty_stable_runtime_checkout)."
|
||||||
|
} >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||||
|
RECORD_JSON="$(
|
||||||
|
ROOT="$ROOT" \
|
||||||
|
PREVIOUS="$PREVIOUS" PROMOTED="$PROMOTED" \
|
||||||
|
SOURCE_BRANCH="$SOURCE_BRANCH" SOURCE_PR="$SOURCE_PR" \
|
||||||
|
RESTART_METHOD="$RESTART_METHOD" HEALTH_PROOF="$HEALTH_PROOF" \
|
||||||
|
IDENTITY_PROOF="$IDENTITY_PROOF" PROFILE_PROOF="$PROFILE_PROOF" \
|
||||||
|
WORKSPACE_PROOF="$WORKSPACE_PROOF" CAPABILITY_PROOF="$CAPABILITY_PROOF" \
|
||||||
|
ROLLBACK="$ROLLBACK" \
|
||||||
|
"$PYTHON_BIN" -c '
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"previous_runtime_sha": os.environ.get("PREVIOUS", ""),
|
||||||
|
"promoted_runtime_sha": os.environ.get("PROMOTED", ""),
|
||||||
|
"source_branch": os.environ.get("SOURCE_BRANCH", ""),
|
||||||
|
"source_pr": os.environ.get("SOURCE_PR", ""),
|
||||||
|
"restart_method": os.environ.get("RESTART_METHOD", ""),
|
||||||
|
"health_check_proof": os.environ.get("HEALTH_PROOF", ""),
|
||||||
|
"identity_proof": os.environ.get("IDENTITY_PROOF", ""),
|
||||||
|
"profile_proof": os.environ.get("PROFILE_PROOF", ""),
|
||||||
|
"workspace_proof": os.environ.get("WORKSPACE_PROOF", ""),
|
||||||
|
"mutation_capability_proof": os.environ.get("CAPABILITY_PROOF", ""),
|
||||||
|
"rollback_instructions": os.environ.get("ROLLBACK", ""),
|
||||||
|
}))
|
||||||
|
'
|
||||||
|
)"
|
||||||
|
|
||||||
|
RECORD_JSON="$RECORD_JSON" ROOT="$ROOT" "$PYTHON_BIN" -c '
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.environ["ROOT"])
|
||||||
|
import stable_control_runtime as scr
|
||||||
|
|
||||||
|
record = json.loads(os.environ["RECORD_JSON"])
|
||||||
|
assessment = scr.assess_promotion_record(record)
|
||||||
|
print()
|
||||||
|
print("validation:", json.dumps(assessment, indent=2))
|
||||||
|
print()
|
||||||
|
if not assessment["valid"]:
|
||||||
|
print("Promotion record is INCOMPLETE - do not archive it as a promotion.")
|
||||||
|
sys.exit(1)
|
||||||
|
print("Promotion record is complete. Archive it on the tracking issue.")
|
||||||
|
'
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
"""Sentry → Gitea incident bridge (#607).
|
||||||
|
|
||||||
|
Reads unresolved issues/events from a **self-hosted** Sentry, normalizes them
|
||||||
|
into #612 observations, and reconciles them into durable Gitea issues.
|
||||||
|
|
||||||
|
Hard rules (inherited from #612 and restated here):
|
||||||
|
* Gitea owns workflow state; Sentry is observability **input only**.
|
||||||
|
* Raw Sentry incidents are never assignable control-plane ``work_items``.
|
||||||
|
* Dedupe/link/create is delegated to :mod:`incident_bridge` — this module
|
||||||
|
never invents a second linking substrate.
|
||||||
|
* Tokens, DSNs, and raw headers never appear in returns, bodies, or logs.
|
||||||
|
* The watchdog defaults to dry-run; ``apply`` is explicit.
|
||||||
|
|
||||||
|
Network access is injected as ``http_fn`` so the whole surface is testable
|
||||||
|
without a live Sentry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Sequence
|
||||||
|
|
||||||
|
import incident_bridge
|
||||||
|
import sentry_observability
|
||||||
|
|
||||||
|
PROVIDER = "sentry"
|
||||||
|
|
||||||
|
ENV_BASE_URL = "SENTRY_BASE_URL"
|
||||||
|
ENV_AUTH_TOKEN = "SENTRY_AUTH_TOKEN"
|
||||||
|
ENV_ORG = "SENTRY_ORG"
|
||||||
|
ENV_PROJECT = "SENTRY_PROJECT"
|
||||||
|
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
|
||||||
|
ENV_BRIDGE_ENABLED = "MCP_SENTRY_ISSUE_BRIDGE_ENABLED"
|
||||||
|
ENV_MIN_EVENTS = "MCP_SENTRY_MIN_EVENTS_FOR_ISSUE"
|
||||||
|
ENV_LOOKBACK = "MCP_SENTRY_LOOKBACK"
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "https://sentry.prgs.cc"
|
||||||
|
DEFAULT_LOOKBACK = "24h"
|
||||||
|
DEFAULT_MIN_EVENTS = 2
|
||||||
|
DEFAULT_TIMEOUT = 15.0
|
||||||
|
DEFAULT_PAGE_SIZE = 25
|
||||||
|
MAX_PAGE_SIZE = 100
|
||||||
|
DEFAULT_MAX_PAGES = 10
|
||||||
|
|
||||||
|
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||||
|
# Absolute local paths embedded in free text. Mirrors the shape matched by
|
||||||
|
# sentry_observability's internal path detector; each hit is replaced by the
|
||||||
|
# coarse category from sentry_observability.sanitize_path.
|
||||||
|
_ABS_PATH_RE = re.compile(
|
||||||
|
r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*"
|
||||||
|
)
|
||||||
|
_LOOKBACK_RE = re.compile(r"^\d+[mhd]$")
|
||||||
|
_CURSOR_RE = re.compile(r'cursor="([^"]+)"')
|
||||||
|
_RESULTS_RE = re.compile(r'results="([^"]+)"')
|
||||||
|
_REL_RE = re.compile(r'rel="([^"]+)"')
|
||||||
|
|
||||||
|
# Error kinds surfaced to callers (stable strings; safe to branch on).
|
||||||
|
ERROR_NOT_CONFIGURED = "not_configured"
|
||||||
|
ERROR_MISSING_TOKEN = "missing_token"
|
||||||
|
ERROR_UNAVAILABLE = "sentry_unavailable"
|
||||||
|
ERROR_HTTP = "sentry_http_error"
|
||||||
|
ERROR_INVALID_RESPONSE = "invalid_response"
|
||||||
|
ERROR_BRIDGE_DISABLED = "bridge_disabled"
|
||||||
|
|
||||||
|
# Watchdog per-issue dispositions.
|
||||||
|
ACTION_RECONCILED = "reconciled"
|
||||||
|
ACTION_SKIPPED_THRESHOLD = "skipped_below_event_threshold"
|
||||||
|
ACTION_SKIPPED_STATUS = "skipped_not_unresolved"
|
||||||
|
ACTION_FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class SentryApiError(RuntimeError):
|
||||||
|
"""Sentry read failure with a stable, redacted classification."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, kind: str, status: int | None = None):
|
||||||
|
super().__init__(incident_bridge.redact_text(message))
|
||||||
|
self.kind = kind
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"error_kind": self.kind,
|
||||||
|
"status": self.status,
|
||||||
|
"message": str(self),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SentryBridgeConfig:
|
||||||
|
"""Resolved bridge configuration. Never carries the auth token."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
org: str
|
||||||
|
project: str
|
||||||
|
environment: str | None = None
|
||||||
|
lookback: str = DEFAULT_LOOKBACK
|
||||||
|
min_events_for_issue: int = DEFAULT_MIN_EVENTS
|
||||||
|
bridge_enabled: bool = False
|
||||||
|
timeout: float = DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
def issues_path(self) -> str:
|
||||||
|
return f"/api/0/projects/{self.org}/{self.project}/issues/"
|
||||||
|
|
||||||
|
def issue_events_path(self, issue_id: str) -> str:
|
||||||
|
return f"/api/0/issues/{issue_id}/events/"
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
"""Safe projection. The auth token is never included by construction."""
|
||||||
|
return {
|
||||||
|
"base_url": self.base_url,
|
||||||
|
"org": self.org,
|
||||||
|
"project": self.project,
|
||||||
|
"environment": self.environment,
|
||||||
|
"lookback": self.lookback,
|
||||||
|
"min_events_for_issue": self.min_events_for_issue,
|
||||||
|
"bridge_enabled": self.bridge_enabled,
|
||||||
|
"self_hosted": not self.base_url.rstrip("/").endswith("sentry.io"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, env: dict[str, str], default: bool = False) -> bool:
|
||||||
|
raw = (env.get(name) or "").strip().lower()
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
return raw in _TRUTHY
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, env: dict[str, str], default: int) -> int:
|
||||||
|
raw = (env.get(name) or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return value if value >= 1 else default
|
||||||
|
|
||||||
|
|
||||||
|
def load_bridge_config(env: dict[str, str] | None = None) -> SentryBridgeConfig:
|
||||||
|
"""Build config from environment. Never reads or returns the token value."""
|
||||||
|
source = dict(env if env is not None else os.environ)
|
||||||
|
base_url = (source.get(ENV_BASE_URL) or DEFAULT_BASE_URL).strip().rstrip("/")
|
||||||
|
lookback = (source.get(ENV_LOOKBACK) or DEFAULT_LOOKBACK).strip()
|
||||||
|
if not _LOOKBACK_RE.match(lookback):
|
||||||
|
lookback = DEFAULT_LOOKBACK
|
||||||
|
environment = (source.get(ENV_ENVIRONMENT) or "").strip() or None
|
||||||
|
return SentryBridgeConfig(
|
||||||
|
base_url=base_url,
|
||||||
|
org=(source.get(ENV_ORG) or "").strip(),
|
||||||
|
project=(source.get(ENV_PROJECT) or "").strip(),
|
||||||
|
environment=environment,
|
||||||
|
lookback=lookback,
|
||||||
|
min_events_for_issue=_env_int(ENV_MIN_EVENTS, source, DEFAULT_MIN_EVENTS),
|
||||||
|
bridge_enabled=_env_bool(ENV_BRIDGE_ENABLED, source, False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def config_with_overrides(
|
||||||
|
config: SentryBridgeConfig,
|
||||||
|
*,
|
||||||
|
base_url: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
project: str | None = None,
|
||||||
|
lookback: str | None = None,
|
||||||
|
min_events_for_issue: int | None = None,
|
||||||
|
) -> SentryBridgeConfig:
|
||||||
|
"""Return *config* with explicit per-call overrides applied."""
|
||||||
|
overrides: dict[str, Any] = {}
|
||||||
|
if base_url:
|
||||||
|
overrides["base_url"] = str(base_url).strip().rstrip("/")
|
||||||
|
if org:
|
||||||
|
overrides["org"] = str(org).strip()
|
||||||
|
if project:
|
||||||
|
overrides["project"] = str(project).strip()
|
||||||
|
if lookback:
|
||||||
|
candidate = str(lookback).strip()
|
||||||
|
overrides["lookback"] = candidate if _LOOKBACK_RE.match(candidate) else config.lookback
|
||||||
|
if min_events_for_issue is not None:
|
||||||
|
overrides["min_events_for_issue"] = max(1, int(min_events_for_issue))
|
||||||
|
return dataclasses.replace(config, **overrides) if overrides else config
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_token(env: dict[str, str] | None = None) -> str:
|
||||||
|
"""Return the Sentry auth token from env only (never logged or returned)."""
|
||||||
|
source = env if env is not None else os.environ
|
||||||
|
return (source.get(ENV_AUTH_TOKEN) or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def assert_configured(config: SentryBridgeConfig, token: str) -> None:
|
||||||
|
"""Fail closed before any network call."""
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
(ENV_BASE_URL, config.base_url),
|
||||||
|
(ENV_ORG, config.org),
|
||||||
|
(ENV_PROJECT, config.project),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise SentryApiError(
|
||||||
|
"Sentry bridge is not configured; missing " + ", ".join(sorted(missing)),
|
||||||
|
kind=ERROR_NOT_CONFIGURED,
|
||||||
|
)
|
||||||
|
if not token:
|
||||||
|
raise SentryApiError(
|
||||||
|
f"{ENV_AUTH_TOKEN} is not set; refusing to call Sentry (fail closed)",
|
||||||
|
kind=ERROR_MISSING_TOKEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# HTTP layer (injectable)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# http_fn(url, headers, timeout) -> (status, body_bytes, response_headers)
|
||||||
|
HttpFn = Callable[[str, dict[str, str], float], "tuple[int, bytes, dict[str, str]]"]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_http_fn(
|
||||||
|
url: str, headers: dict[str, str], timeout: float
|
||||||
|
) -> tuple[int, bytes, dict[str, str]]:
|
||||||
|
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
return (
|
||||||
|
int(response.status),
|
||||||
|
response.read(),
|
||||||
|
{k.lower(): v for k, v in response.headers.items()},
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc: # status is meaningful
|
||||||
|
try:
|
||||||
|
body = exc.read()
|
||||||
|
except Exception: # noqa: BLE001 - body is best-effort only
|
||||||
|
body = b""
|
||||||
|
return (
|
||||||
|
int(exc.code),
|
||||||
|
body,
|
||||||
|
{k.lower(): v for k, v in (exc.headers or {}).items()},
|
||||||
|
)
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise SentryApiError(
|
||||||
|
f"Sentry unreachable: {exc.reason}", kind=ERROR_UNAVAILABLE
|
||||||
|
) from exc
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise SentryApiError("Sentry request timed out", kind=ERROR_UNAVAILABLE) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_next_cursor(link_header: str | None) -> str | None:
|
||||||
|
"""Extract the ``rel="next"`` cursor when more results exist."""
|
||||||
|
if not link_header:
|
||||||
|
return None
|
||||||
|
for part in link_header.split(","):
|
||||||
|
rel = _REL_RE.search(part)
|
||||||
|
if not rel or rel.group(1) != "next":
|
||||||
|
continue
|
||||||
|
results = _RESULTS_RE.search(part)
|
||||||
|
if results and results.group(1).lower() != "true":
|
||||||
|
return None
|
||||||
|
cursor = _CURSOR_RE.search(part)
|
||||||
|
if cursor:
|
||||||
|
return cursor.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(
|
||||||
|
config: SentryBridgeConfig,
|
||||||
|
path: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
http_fn: HttpFn | None = None,
|
||||||
|
) -> tuple[Any, dict[str, str]]:
|
||||||
|
caller = http_fn or _default_http_fn
|
||||||
|
query = urllib.parse.urlencode(
|
||||||
|
{k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
)
|
||||||
|
url = f"{config.base_url}{path}"
|
||||||
|
if query:
|
||||||
|
url = f"{url}?{query}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "gitea-tools-sentry-bridge/1.0",
|
||||||
|
}
|
||||||
|
status, body, response_headers = caller(url, headers, config.timeout)
|
||||||
|
if status in (401, 403):
|
||||||
|
raise SentryApiError(
|
||||||
|
"Sentry rejected the auth token (unauthorized)",
|
||||||
|
kind=ERROR_MISSING_TOKEN,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
if status >= 500:
|
||||||
|
raise SentryApiError(
|
||||||
|
f"Sentry server error (HTTP {status})",
|
||||||
|
kind=ERROR_UNAVAILABLE,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
if status >= 400:
|
||||||
|
raise SentryApiError(
|
||||||
|
f"Sentry request failed (HTTP {status})", kind=ERROR_HTTP, status=status
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = json.loads(body.decode("utf-8") or "null")
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise SentryApiError(
|
||||||
|
f"Sentry returned an unparseable response: {exc}",
|
||||||
|
kind=ERROR_INVALID_RESPONSE,
|
||||||
|
status=status,
|
||||||
|
) from exc
|
||||||
|
return payload, response_headers
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Sanitization
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str:
|
||||||
|
"""Redact secrets, then replace embedded local paths with a category token.
|
||||||
|
|
||||||
|
``sentry_observability.sanitize_path`` categorizes a string that *is* a
|
||||||
|
path; it must never be applied to whole free-text fields (it would collapse
|
||||||
|
a title or timestamp to ``"other"``). Here it is applied only to substrings
|
||||||
|
that actually match an absolute path.
|
||||||
|
"""
|
||||||
|
text = incident_bridge.redact_text(value)
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return _ABS_PATH_RE.sub(
|
||||||
|
lambda m: f"[path:{sentry_observability.sanitize_path(m.group(0))}]", text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_issue(raw: Any) -> dict[str, Any]:
|
||||||
|
"""Project one raw Sentry issue into a sanitized, LLM-safe summary."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise SentryApiError(
|
||||||
|
"Sentry issue payload is not an object", kind=ERROR_INVALID_RESPONSE
|
||||||
|
)
|
||||||
|
issue_id = raw.get("id")
|
||||||
|
if issue_id is None or str(issue_id).strip() == "":
|
||||||
|
raise SentryApiError(
|
||||||
|
"Sentry issue payload is missing 'id'", kind=ERROR_INVALID_RESPONSE
|
||||||
|
)
|
||||||
|
metadata = raw.get("metadata") if isinstance(raw.get("metadata"), dict) else {}
|
||||||
|
try:
|
||||||
|
count = int(raw.get("count"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
count = None
|
||||||
|
permalink = _clean(raw.get("permalink"))
|
||||||
|
if "[REDACTED]" in permalink:
|
||||||
|
permalink = ""
|
||||||
|
user_count = raw.get("userCount")
|
||||||
|
return {
|
||||||
|
"id": str(issue_id).strip(),
|
||||||
|
"short_id": _clean(raw.get("shortId")) or None,
|
||||||
|
"title": _clean(raw.get("title"))[:200],
|
||||||
|
"culprit": _clean(raw.get("culprit")) or None,
|
||||||
|
"level": _clean(raw.get("level")) or None,
|
||||||
|
"status": str(raw.get("status") or "unresolved").strip().lower() or "unresolved",
|
||||||
|
"count": count,
|
||||||
|
"user_count": user_count if isinstance(user_count, int) else None,
|
||||||
|
"first_seen": _clean(raw.get("firstSeen")) or None,
|
||||||
|
"last_seen": _clean(raw.get("lastSeen")) or None,
|
||||||
|
"permalink": permalink or None,
|
||||||
|
"metadata_value": _clean(metadata.get("value"))[:500] or None,
|
||||||
|
"metadata_type": _clean(metadata.get("type")) or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_event(raw: Any) -> dict[str, Any]:
|
||||||
|
"""Project one raw Sentry event into a sanitized summary."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise SentryApiError(
|
||||||
|
"Sentry event payload is not an object", kind=ERROR_INVALID_RESPONSE
|
||||||
|
)
|
||||||
|
tags: dict[str, str] = {}
|
||||||
|
raw_tags = raw.get("tags")
|
||||||
|
if isinstance(raw_tags, list):
|
||||||
|
# Sentry events return tags as [{"key": ..., "value": ...}, ...]
|
||||||
|
tags = incident_bridge.sanitize_tags(
|
||||||
|
{
|
||||||
|
t.get("key"): t.get("value")
|
||||||
|
for t in raw_tags
|
||||||
|
if isinstance(t, dict) and t.get("key")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(raw_tags, dict):
|
||||||
|
tags = incident_bridge.sanitize_tags(raw_tags)
|
||||||
|
return {
|
||||||
|
"event_id": _clean(raw.get("eventID") or raw.get("id")) or None,
|
||||||
|
"message": _clean(raw.get("message") or raw.get("title"))[:2000] or None,
|
||||||
|
"date_created": _clean(raw.get("dateCreated")) or None,
|
||||||
|
"platform": _clean(raw.get("platform")) or None,
|
||||||
|
"environment": _clean(raw.get("environment")) or None,
|
||||||
|
"release": _clean(raw.get("release")) or None,
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Reads
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def list_issues(
|
||||||
|
config: SentryBridgeConfig,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
query: str = "is:unresolved",
|
||||||
|
limit: int = DEFAULT_PAGE_SIZE,
|
||||||
|
max_pages: int = DEFAULT_MAX_PAGES,
|
||||||
|
cursor: str | None = None,
|
||||||
|
environment: str | None = None,
|
||||||
|
http_fn: HttpFn | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List sanitized unresolved Sentry issues, following ``Link`` pagination."""
|
||||||
|
assert_configured(config, token)
|
||||||
|
page_size = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE))
|
||||||
|
pages_allowed = max(1, int(max_pages or 1))
|
||||||
|
|
||||||
|
issues: list[dict[str, Any]] = []
|
||||||
|
next_cursor = cursor
|
||||||
|
pages_fetched = 0
|
||||||
|
for _ in range(pages_allowed):
|
||||||
|
payload, headers = _get_json(
|
||||||
|
config,
|
||||||
|
config.issues_path(),
|
||||||
|
{
|
||||||
|
"query": query,
|
||||||
|
"statsPeriod": config.lookback,
|
||||||
|
"limit": page_size,
|
||||||
|
"cursor": next_cursor,
|
||||||
|
"environment": environment or config.environment,
|
||||||
|
},
|
||||||
|
token=token,
|
||||||
|
http_fn=http_fn,
|
||||||
|
)
|
||||||
|
pages_fetched += 1
|
||||||
|
if payload is None:
|
||||||
|
payload = []
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise SentryApiError(
|
||||||
|
"Sentry issue list response was not a JSON array",
|
||||||
|
kind=ERROR_INVALID_RESPONSE,
|
||||||
|
)
|
||||||
|
issues.extend(sanitize_issue(item) for item in payload)
|
||||||
|
next_cursor = parse_next_cursor(headers.get("link"))
|
||||||
|
if not next_cursor:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"issues": issues,
|
||||||
|
"count": len(issues),
|
||||||
|
"pages_fetched": pages_fetched,
|
||||||
|
"next_cursor": next_cursor,
|
||||||
|
"inventory_complete": next_cursor is None,
|
||||||
|
"config": config.as_dict(),
|
||||||
|
"query": query,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_issue_events(
|
||||||
|
config: SentryBridgeConfig,
|
||||||
|
issue_id: str,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
limit: int = 10,
|
||||||
|
http_fn: HttpFn | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fetch sanitized recent events plus the latest event for one issue."""
|
||||||
|
assert_configured(config, token)
|
||||||
|
if not str(issue_id or "").strip():
|
||||||
|
raise SentryApiError("issue_id is required", kind=ERROR_INVALID_RESPONSE)
|
||||||
|
issue_key = str(issue_id).strip()
|
||||||
|
|
||||||
|
payload, _ = _get_json(
|
||||||
|
config,
|
||||||
|
config.issue_events_path(issue_key),
|
||||||
|
{"limit": max(1, min(int(limit or 10), MAX_PAGE_SIZE))},
|
||||||
|
token=token,
|
||||||
|
http_fn=http_fn,
|
||||||
|
)
|
||||||
|
if payload is None:
|
||||||
|
payload = []
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise SentryApiError(
|
||||||
|
"Sentry event list response was not a JSON array",
|
||||||
|
kind=ERROR_INVALID_RESPONSE,
|
||||||
|
)
|
||||||
|
events = [sanitize_event(item) for item in payload]
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"issue_id": issue_key,
|
||||||
|
"events": events,
|
||||||
|
"count": len(events),
|
||||||
|
"latest_event": events[0] if events else None,
|
||||||
|
"config": config.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Observation mapping + policy
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def observation_from_issue(
|
||||||
|
issue: dict[str, Any],
|
||||||
|
config: SentryBridgeConfig,
|
||||||
|
*,
|
||||||
|
gitea_org: str | None = None,
|
||||||
|
gitea_repo: str | None = None,
|
||||||
|
latest_event: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Convert a sanitized Sentry issue into a #612 observation dict."""
|
||||||
|
tags = dict(latest_event.get("tags") or {}) if isinstance(latest_event, dict) else {}
|
||||||
|
environment = None
|
||||||
|
if isinstance(latest_event, dict):
|
||||||
|
environment = latest_event.get("environment")
|
||||||
|
environment = environment or config.environment
|
||||||
|
|
||||||
|
observation: dict[str, Any] = {
|
||||||
|
"provider": PROVIDER,
|
||||||
|
"provider_base_url": config.base_url,
|
||||||
|
"provider_org": config.org,
|
||||||
|
"provider_project": config.project,
|
||||||
|
"provider_issue_id": issue.get("id"),
|
||||||
|
"provider_short_id": issue.get("short_id"),
|
||||||
|
"provider_permalink": issue.get("permalink"),
|
||||||
|
"title": issue.get("title"),
|
||||||
|
"culprit": issue.get("culprit"),
|
||||||
|
"summary": issue.get("metadata_value") or issue.get("title"),
|
||||||
|
"level": issue.get("level"),
|
||||||
|
"status": issue.get("status") or "unresolved",
|
||||||
|
"event_count": issue.get("count"),
|
||||||
|
"first_seen": issue.get("first_seen"),
|
||||||
|
"last_seen": issue.get("last_seen"),
|
||||||
|
"environment": environment,
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
if gitea_org:
|
||||||
|
observation["gitea_org"] = gitea_org
|
||||||
|
if gitea_repo:
|
||||||
|
observation["gitea_repo"] = gitea_repo
|
||||||
|
return observation
|
||||||
|
|
||||||
|
|
||||||
|
def should_bridge_issue(
|
||||||
|
issue: dict[str, Any], config: SentryBridgeConfig
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Policy gate: is this Sentry issue worth a durable Gitea issue?"""
|
||||||
|
status = str(issue.get("status") or "").strip().lower()
|
||||||
|
if status and status != "unresolved":
|
||||||
|
return False, f"status '{status}' is not unresolved"
|
||||||
|
count = issue.get("count")
|
||||||
|
threshold = int(config.min_events_for_issue or 1)
|
||||||
|
if isinstance(count, int) and count < threshold:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"event count {count} below {ENV_MIN_EVENTS} threshold {threshold}",
|
||||||
|
)
|
||||||
|
return True, "meets bridge policy"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Watchdog
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def watchdog(
|
||||||
|
db: Any,
|
||||||
|
config: SentryBridgeConfig,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
apply: bool = False,
|
||||||
|
mappings: Sequence[Any] | None = None,
|
||||||
|
gitea_org: str | None = None,
|
||||||
|
gitea_repo: str | None = None,
|
||||||
|
query: str = "is:unresolved",
|
||||||
|
limit: int = DEFAULT_PAGE_SIZE,
|
||||||
|
max_pages: int = DEFAULT_MAX_PAGES,
|
||||||
|
http_fn: HttpFn | None = None,
|
||||||
|
create_issue_fn: Any = None,
|
||||||
|
comment_issue_fn: Any = None,
|
||||||
|
reconcile_fn: Callable[..., dict[str, Any]] | None = None,
|
||||||
|
fetch_events: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Scan Sentry and reconcile active incidents into Gitea issues.
|
||||||
|
|
||||||
|
Dry-run by default. ``apply=True`` additionally requires the bridge to be
|
||||||
|
explicitly enabled via ``MCP_SENTRY_ISSUE_BRIDGE_ENABLED``.
|
||||||
|
|
||||||
|
``comment_issue_fn`` carries the sanctioned issue-comment route used for
|
||||||
|
AC4 recurrence comments on already-linked issues; dry runs never comment.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"success": False,
|
||||||
|
"apply": bool(apply),
|
||||||
|
"scanned": 0,
|
||||||
|
"reconciled": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"results": [],
|
||||||
|
"reasons": [],
|
||||||
|
"config": config.as_dict(),
|
||||||
|
"raw_incident_assignable": False,
|
||||||
|
"durable_work_system": "gitea_issues",
|
||||||
|
}
|
||||||
|
|
||||||
|
if apply and not config.bridge_enabled:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"{ENV_BRIDGE_ENABLED} is not enabled; apply refused (fail closed)"
|
||||||
|
)
|
||||||
|
result["error_kind"] = ERROR_BRIDGE_DISABLED
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
listing = list_issues(
|
||||||
|
config,
|
||||||
|
token=token,
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
max_pages=max_pages,
|
||||||
|
http_fn=http_fn,
|
||||||
|
)
|
||||||
|
except SentryApiError as exc:
|
||||||
|
result["reasons"].append(str(exc))
|
||||||
|
result.update(exc.as_dict())
|
||||||
|
return result
|
||||||
|
|
||||||
|
reconciler = reconcile_fn or incident_bridge.reconcile_incident
|
||||||
|
result["inventory_complete"] = listing.get("inventory_complete", False)
|
||||||
|
result["pages_fetched"] = listing.get("pages_fetched", 0)
|
||||||
|
|
||||||
|
for issue in listing.get("issues", []):
|
||||||
|
result["scanned"] += 1
|
||||||
|
eligible, reason = should_bridge_issue(issue, config)
|
||||||
|
if not eligible:
|
||||||
|
result["skipped"] += 1
|
||||||
|
result["results"].append(
|
||||||
|
{
|
||||||
|
"sentry_issue_id": issue.get("id"),
|
||||||
|
"action": (
|
||||||
|
ACTION_SKIPPED_THRESHOLD
|
||||||
|
if "threshold" in reason
|
||||||
|
else ACTION_SKIPPED_STATUS
|
||||||
|
),
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
latest_event = None
|
||||||
|
if fetch_events:
|
||||||
|
try:
|
||||||
|
events = get_issue_events(
|
||||||
|
config, issue["id"], token=token, limit=1, http_fn=http_fn
|
||||||
|
)
|
||||||
|
latest_event = events.get("latest_event")
|
||||||
|
except SentryApiError as exc:
|
||||||
|
# Event enrichment is best-effort; the issue itself still bridges.
|
||||||
|
result["reasons"].append(
|
||||||
|
f"event fetch failed for {issue.get('id')}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
observation = observation_from_issue(
|
||||||
|
issue,
|
||||||
|
config,
|
||||||
|
gitea_org=gitea_org,
|
||||||
|
gitea_repo=gitea_repo,
|
||||||
|
latest_event=latest_event,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reconciled = reconciler(
|
||||||
|
db,
|
||||||
|
observation=observation,
|
||||||
|
mappings=list(mappings or []),
|
||||||
|
apply=bool(apply),
|
||||||
|
create_issue_fn=create_issue_fn,
|
||||||
|
comment_issue_fn=comment_issue_fn,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - one bad issue must not abort the scan
|
||||||
|
result["failed"] += 1
|
||||||
|
result["results"].append(
|
||||||
|
{
|
||||||
|
"sentry_issue_id": issue.get("id"),
|
||||||
|
"action": ACTION_FAILED,
|
||||||
|
"reason": incident_bridge.redact_text(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result["reconciled"] += 1
|
||||||
|
result["results"].append(
|
||||||
|
{
|
||||||
|
"sentry_issue_id": issue.get("id"),
|
||||||
|
"action": ACTION_RECONCILED,
|
||||||
|
"outcome": reconciled.get("outcome"),
|
||||||
|
"gitea_issue": reconciled.get("gitea_issue"),
|
||||||
|
"existing_link": reconciled.get("existing_link"),
|
||||||
|
"recurrence_comment": reconciled.get("recurrence_comment"),
|
||||||
|
"reasons": reconciled.get("reasons"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result["success"] = result["failed"] == 0
|
||||||
|
if not result["results"]:
|
||||||
|
result["reasons"].append("no Sentry issues matched the scan window/policy")
|
||||||
|
return result
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
"""Optional self-hosted Sentry observability for the Gitea MCP server (#606).
|
||||||
|
|
||||||
|
Adds env-var-gated Sentry SDK instrumentation so runtime errors, fail-closed
|
||||||
|
workflow blockers, lease/terminal-lock/stale-runtime collisions, and recurring
|
||||||
|
watchdog check-ins are visible in a *self-hosted* Sentry at
|
||||||
|
``https://sentry.prgs.cc/`` — never Sentry Cloud, and never as the workflow
|
||||||
|
source of truth (Gitea stays canonical).
|
||||||
|
|
||||||
|
Design constraints (mirror ``gitea_audit`` and the #612 incident bridge):
|
||||||
|
|
||||||
|
- **Off by default.** With ``MCP_SENTRY_ENABLED`` false/unset *or* ``SENTRY_DSN``
|
||||||
|
empty, ``init_sentry`` is a no-op and no events are ever sent — existing tool
|
||||||
|
behaviour and API-call patterns are unchanged (acceptance criterion 1).
|
||||||
|
- **Fail *open* for observability.** A Sentry outage, a missing ``sentry_sdk``
|
||||||
|
package, or any capture error must never break an MCP tool success path. Every
|
||||||
|
public entry point swallows its own exceptions.
|
||||||
|
- **Fail *closed* for redaction.** If a field cannot be proven safe it is dropped
|
||||||
|
rather than sent. Tokens, passwords, keychain IDs, DSNs, private config, raw
|
||||||
|
session-state, full prompt bodies, and full filesystem paths never leave here.
|
||||||
|
- **No hard dependency.** ``sentry_sdk`` is imported lazily; the module is fully
|
||||||
|
importable and testable without it installed.
|
||||||
|
|
||||||
|
Sentry is observe-only: it must not approve, merge, close, or otherwise mutate
|
||||||
|
Gitea workflow state, nor bypass leases, #332, or MCP gates. Alerts may only feed
|
||||||
|
the sanctioned Gitea issue/comment path via the #612 incident bridge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Reuse the most comprehensive existing scrubber so redaction stays consistent
|
||||||
|
# with the #612 incident bridge (tokens, DSNs, cookies, bearer/basic, keychain
|
||||||
|
# ids, session ids, user:pass@host).
|
||||||
|
from incident_bridge import redact_text as _redact_text
|
||||||
|
|
||||||
|
# Second, complementary scrubber: catches bare ``token <value>`` /
|
||||||
|
# ``Bearer <value>`` / ``Basic <value>`` prefixes and raw URLs that the
|
||||||
|
# incident-bridge delimiter patterns miss.
|
||||||
|
from gitea_audit import _redact_str as _redact_prefixes
|
||||||
|
|
||||||
|
# ── Optional SDK (lazy, never a hard dependency) ────────────────────────────
|
||||||
|
try: # pragma: no cover - trivial import guard
|
||||||
|
import sentry_sdk # type: ignore
|
||||||
|
except Exception: # pragma: no cover - absence is a supported state
|
||||||
|
sentry_sdk = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
# ── Env var names (single source of truth) ──────────────────────────────────
|
||||||
|
ENV_ENABLED = "MCP_SENTRY_ENABLED"
|
||||||
|
ENV_DSN = "SENTRY_DSN"
|
||||||
|
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
|
||||||
|
ENV_RELEASE = "SENTRY_RELEASE"
|
||||||
|
ENV_TRACES_SAMPLE_RATE = "MCP_SENTRY_TRACES_SAMPLE_RATE"
|
||||||
|
ENV_ENABLE_LOGS = "MCP_SENTRY_ENABLE_LOGS"
|
||||||
|
|
||||||
|
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||||
|
|
||||||
|
REDACTED = "[REDACTED]"
|
||||||
|
REDACTED_PATH = "[REDACTED_PATH]"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cron / watchdog monitor slugs (acceptance criterion 6) ──────────────────
|
||||||
|
# Stable slugs for the recurring/watchdog jobs #606 wants check-ins for. The
|
||||||
|
# slug is the durable monitor identity in Sentry; the wiring call sites pass one
|
||||||
|
# of these keys (or an explicit slug) to ``monitor_checkin``.
|
||||||
|
MONITOR_SLUGS: dict[str, str] = {
|
||||||
|
"stale_lease_scan": "gitea-mcp-stale-lease-scan",
|
||||||
|
"terminal_lock_scan": "gitea-mcp-terminal-lock-scan",
|
||||||
|
"allocator_health": "gitea-mcp-allocator-health",
|
||||||
|
"namespace_health": "gitea-mcp-namespace-health",
|
||||||
|
"dashboard_freshness": "gitea-mcp-dashboard-freshness",
|
||||||
|
"reconciler_cleanup": "gitea-mcp-reconciler-cleanup",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CHECKIN_STATUSES = frozenset({"in_progress", "ok", "error"})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tag allowlist (issue "Suggested Sentry tags/context") ───────────────────
|
||||||
|
# Only these keys are ever attached as Sentry tags. Anything else is dropped so
|
||||||
|
# a caller cannot accidentally leak a sensitive value through a tag.
|
||||||
|
ALLOWED_TAG_KEYS = frozenset({
|
||||||
|
"role",
|
||||||
|
"profile",
|
||||||
|
"namespace",
|
||||||
|
"repo",
|
||||||
|
"org",
|
||||||
|
"issue_number",
|
||||||
|
"pr_number",
|
||||||
|
"blocker_type",
|
||||||
|
"workflow_hash",
|
||||||
|
"session_id_hash", # hash only — never the raw session id
|
||||||
|
"pid",
|
||||||
|
"worktree_category", # category, never the full sensitive path
|
||||||
|
"lease_comment_id",
|
||||||
|
"expected_head_sha",
|
||||||
|
"current_head_sha",
|
||||||
|
"terminal_lock_state",
|
||||||
|
"capability",
|
||||||
|
"mutation_tool",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Absolute-path shapes that must never be sent verbatim (macOS/Linux + temp).
|
||||||
|
_PATH_RE = re.compile(r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*")
|
||||||
|
|
||||||
|
# ``extra`` keys whose *full* contents are forbidden by the redaction rules
|
||||||
|
# (raw session-state, full prompt/comment bodies, private config blobs, raw
|
||||||
|
# headers).
|
||||||
|
_FORBIDDEN_EXTRA_KEYS = frozenset({
|
||||||
|
"prompt",
|
||||||
|
"prompt_body",
|
||||||
|
"next_prompt",
|
||||||
|
"body",
|
||||||
|
"raw_body",
|
||||||
|
"session_state",
|
||||||
|
"session_state_contents",
|
||||||
|
"config",
|
||||||
|
"config_contents",
|
||||||
|
"private_config",
|
||||||
|
"headers",
|
||||||
|
"authorization",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Configuration ───────────────────────────────────────────────────────────
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SentryConfig:
|
||||||
|
"""Immutable snapshot of the Sentry env configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
dsn: str | None = None
|
||||||
|
environment: str = "development"
|
||||||
|
release: str | None = None
|
||||||
|
traces_sample_rate: float = 0.0
|
||||||
|
enable_logs: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
"""True only when the operator both opted in *and* supplied a DSN.
|
||||||
|
|
||||||
|
This is the single gate that keeps the feature off by default: enabling
|
||||||
|
the flag without a DSN (or vice versa) sends nothing.
|
||||||
|
"""
|
||||||
|
return bool(self.enabled and self.dsn)
|
||||||
|
|
||||||
|
def safe_summary(self) -> dict[str, Any]:
|
||||||
|
"""Operator-facing status with **no** DSN value (only presence)."""
|
||||||
|
return {
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"dsn_present": bool(self.dsn),
|
||||||
|
"environment": self.environment,
|
||||||
|
"release": self.release,
|
||||||
|
"traces_sample_rate": self.traces_sample_rate,
|
||||||
|
"enable_logs": self.enable_logs,
|
||||||
|
"active": self.active,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, env: dict[str, str]) -> bool:
|
||||||
|
return (env.get(name) or "").strip().lower() in _TRUTHY
|
||||||
|
|
||||||
|
|
||||||
|
def _env_float(name: str, default: float, env: dict[str, str]) -> float:
|
||||||
|
raw = (env.get(name) or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
val = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
# Clamp to Sentry's valid [0.0, 1.0] sample-rate range.
|
||||||
|
if val < 0.0:
|
||||||
|
return 0.0
|
||||||
|
if val > 1.0:
|
||||||
|
return 1.0
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(env: dict[str, str] | None = None) -> SentryConfig:
|
||||||
|
"""Build a :class:`SentryConfig` from the environment (read at call time)."""
|
||||||
|
env = dict(os.environ if env is None else env)
|
||||||
|
dsn = (env.get(ENV_DSN) or "").strip() or None
|
||||||
|
return SentryConfig(
|
||||||
|
enabled=_env_bool(ENV_ENABLED, env),
|
||||||
|
dsn=dsn,
|
||||||
|
environment=(env.get(ENV_ENVIRONMENT) or "").strip() or "development",
|
||||||
|
release=(env.get(ENV_RELEASE) or "").strip() or None,
|
||||||
|
traces_sample_rate=_env_float(ENV_TRACES_SAMPLE_RATE, 0.0, env),
|
||||||
|
enable_logs=_env_bool(ENV_ENABLE_LOGS, env),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sdk_available() -> bool:
|
||||||
|
"""True when the optional ``sentry_sdk`` package is importable."""
|
||||||
|
return sentry_sdk is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Redaction (fail closed) ─────────────────────────────────────────────────
|
||||||
|
def sanitize_path(value: Any) -> str:
|
||||||
|
"""Reduce a filesystem path to a non-sensitive *category* token.
|
||||||
|
|
||||||
|
Full local paths must never be sent. We keep only a coarse worktree
|
||||||
|
category derived from the path shape (author/reviewer/merger/reconciler/
|
||||||
|
branches/root/other).
|
||||||
|
"""
|
||||||
|
text = "" if value is None else str(value)
|
||||||
|
low = text.lower()
|
||||||
|
if not text:
|
||||||
|
return "unknown"
|
||||||
|
# Order matters: more specific role markers before the generic "branches".
|
||||||
|
if "reconcile" in low:
|
||||||
|
return "reconciler"
|
||||||
|
if "review" in low:
|
||||||
|
return "reviewer"
|
||||||
|
if "merge" in low or "merger" in low:
|
||||||
|
return "merger"
|
||||||
|
if "author" in low or re.search(r"/branches/(?:feat|fix|docs|chore|issue)", low):
|
||||||
|
return "author"
|
||||||
|
if "/branches/" in low:
|
||||||
|
return "branches"
|
||||||
|
if low.rstrip("/").endswith("gitea-tools"):
|
||||||
|
return "root"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def redact_value(value: Any) -> Any:
|
||||||
|
"""Recursively redact a JSON-able value: secret text, absolute paths, and
|
||||||
|
known-sensitive dict keys are removed. Fail closed — any error drops the
|
||||||
|
value entirely rather than risk leaking it."""
|
||||||
|
try:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for k, v in value.items():
|
||||||
|
key = str(k)
|
||||||
|
low = key.lower()
|
||||||
|
if low in _FORBIDDEN_EXTRA_KEYS or any(
|
||||||
|
s in low
|
||||||
|
for s in ("token", "secret", "password", "cookie", "auth", "dsn", "keychain")
|
||||||
|
):
|
||||||
|
out[key] = REDACTED
|
||||||
|
continue
|
||||||
|
out[key] = redact_value(v)
|
||||||
|
return out
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [redact_value(v) for v in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
scrubbed = _redact_text(value)
|
||||||
|
scrubbed = _redact_prefixes(scrubbed)
|
||||||
|
scrubbed = _PATH_RE.sub(REDACTED_PATH, scrubbed)
|
||||||
|
return scrubbed
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
return REDACTED
|
||||||
|
|
||||||
|
|
||||||
|
def hash_session_id(session_id: Any) -> str:
|
||||||
|
"""Short, stable, non-reversible fingerprint of a session id."""
|
||||||
|
digest = hashlib.sha256(str(session_id).encode("utf-8", "replace")).hexdigest()
|
||||||
|
return digest[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def build_tags(**kwargs: Any) -> dict[str, str]:
|
||||||
|
"""Return a scrubbed, allowlisted tag dict.
|
||||||
|
|
||||||
|
``session_id`` is accepted but only ever surfaced as ``session_id_hash``.
|
||||||
|
``worktree_path`` collapses to ``worktree_category``. Any non-allowlisted
|
||||||
|
key, or a value that still contains redacted material after scrubbing, is
|
||||||
|
dropped.
|
||||||
|
"""
|
||||||
|
raw: dict[str, Any] = dict(kwargs)
|
||||||
|
|
||||||
|
# Hash the session id — never emit it raw.
|
||||||
|
session_id = raw.pop("session_id", None)
|
||||||
|
if session_id and "session_id_hash" not in raw:
|
||||||
|
raw["session_id_hash"] = hash_session_id(session_id)
|
||||||
|
|
||||||
|
# A full worktree path collapses to a category tag.
|
||||||
|
wt = raw.pop("worktree_path", None)
|
||||||
|
if wt and "worktree_category" not in raw:
|
||||||
|
raw["worktree_category"] = sanitize_path(wt)
|
||||||
|
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for key, val in raw.items():
|
||||||
|
if key not in ALLOWED_TAG_KEYS:
|
||||||
|
continue
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
scrubbed = redact_value(val)
|
||||||
|
text = str(scrubbed)
|
||||||
|
if not text or REDACTED in text or REDACTED_PATH in text:
|
||||||
|
continue
|
||||||
|
if len(text) > 200:
|
||||||
|
text = text[:200] + "…"
|
||||||
|
out[key] = text
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scrub_event(event: Any, hint: Any = None) -> dict[str, Any] | None:
|
||||||
|
"""Sentry ``before_send`` / ``before_send_log`` hook.
|
||||||
|
|
||||||
|
Recursively redacts the outgoing event. On *any* failure it returns ``None``
|
||||||
|
so the event is dropped rather than sent unscrubbed (fail closed for
|
||||||
|
redaction).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
return None
|
||||||
|
scrubbed = redact_value(event)
|
||||||
|
# Drop server_name if it leaked a hostname/path; PID is kept via tags.
|
||||||
|
scrubbed.pop("server_name", None)
|
||||||
|
return scrubbed
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event builders (pure, independently testable) ───────────────────────────
|
||||||
|
def build_blocker_event(
|
||||||
|
blocker_type: str,
|
||||||
|
*,
|
||||||
|
message: str | None = None,
|
||||||
|
next_action: str | None = None,
|
||||||
|
level: str = "warning",
|
||||||
|
tags: dict[str, Any] | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a redacted, structured Sentry event for a workflow blocker.
|
||||||
|
|
||||||
|
``next_action`` maps the issue's "canonical next action when available"
|
||||||
|
requirement (acceptance criterion 7).
|
||||||
|
"""
|
||||||
|
merged_tags = dict(tags or {})
|
||||||
|
merged_tags.setdefault("blocker_type", blocker_type)
|
||||||
|
safe_tags = build_tags(**merged_tags)
|
||||||
|
|
||||||
|
safe_extra = redact_value(dict(extra or {}))
|
||||||
|
if next_action:
|
||||||
|
# A short canonical next action is allowed (it is not a full prompt).
|
||||||
|
safe_extra["canonical_next_action"] = redact_value(str(next_action)[:500])
|
||||||
|
|
||||||
|
event: dict[str, Any] = {
|
||||||
|
"message": redact_value(message or blocker_type),
|
||||||
|
"level": level if level in ("debug", "info", "warning", "error", "fatal") else "warning",
|
||||||
|
"logger": "gitea-mcp.workflow",
|
||||||
|
"tags": safe_tags,
|
||||||
|
"extra": safe_extra,
|
||||||
|
"fingerprint": ["workflow-blocker", blocker_type],
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def build_checkin_payload(
|
||||||
|
monitor: str,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
check_in_id: str | None = None,
|
||||||
|
duration: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a Sentry cron check-in payload for one of :data:`MONITOR_SLUGS`.
|
||||||
|
|
||||||
|
``monitor`` may be a registry key (e.g. ``"stale_lease_scan"``) or an
|
||||||
|
explicit slug. Raises ``ValueError`` on an unknown status so callers cannot
|
||||||
|
silently send a malformed check-in.
|
||||||
|
"""
|
||||||
|
if status not in _CHECKIN_STATUSES:
|
||||||
|
raise ValueError(
|
||||||
|
f"invalid check-in status {status!r}; expected one of {sorted(_CHECKIN_STATUSES)}"
|
||||||
|
)
|
||||||
|
slug = MONITOR_SLUGS.get(monitor, monitor)
|
||||||
|
payload: dict[str, Any] = {"monitor_slug": slug, "status": status}
|
||||||
|
if check_in_id:
|
||||||
|
payload["check_in_id"] = str(check_in_id)
|
||||||
|
if duration is not None:
|
||||||
|
try:
|
||||||
|
payload["duration"] = float(duration)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
# ── Runtime init + capture (fail open) ──────────────────────────────────────
|
||||||
|
_STATE: dict[str, Any] = {"initialized": False, "config": None}
|
||||||
|
|
||||||
|
|
||||||
|
def is_initialized() -> bool:
|
||||||
|
return bool(_STATE.get("initialized"))
|
||||||
|
|
||||||
|
|
||||||
|
def active_config() -> SentryConfig | None:
|
||||||
|
return _STATE.get("config")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_for_tests() -> None:
|
||||||
|
"""Clear module init state. Test-only helper (never called in production)."""
|
||||||
|
_STATE["initialized"] = False
|
||||||
|
_STATE["config"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_sentry(config: SentryConfig | None = None) -> dict[str, Any]:
|
||||||
|
"""Initialise the Sentry SDK if (and only if) enabled + DSN + SDK present.
|
||||||
|
|
||||||
|
Idempotent and never raises. Returns an operator-safe status dict (no DSN
|
||||||
|
value). Behaviour is unchanged when the feature is off.
|
||||||
|
"""
|
||||||
|
cfg = config or load_config()
|
||||||
|
status: dict[str, Any] = {"initialized": False, **cfg.safe_summary()}
|
||||||
|
try:
|
||||||
|
if not cfg.active:
|
||||||
|
status["reason"] = "disabled (MCP_SENTRY_ENABLED false or SENTRY_DSN empty)"
|
||||||
|
_STATE["config"] = cfg
|
||||||
|
return status
|
||||||
|
if not sdk_available():
|
||||||
|
status["reason"] = "sentry_sdk not installed"
|
||||||
|
_STATE["config"] = cfg
|
||||||
|
return status
|
||||||
|
|
||||||
|
init_kwargs: dict[str, Any] = {
|
||||||
|
"dsn": cfg.dsn,
|
||||||
|
"environment": cfg.environment,
|
||||||
|
"release": cfg.release,
|
||||||
|
"traces_sample_rate": cfg.traces_sample_rate,
|
||||||
|
"before_send": scrub_event,
|
||||||
|
"send_default_pii": False,
|
||||||
|
}
|
||||||
|
if cfg.enable_logs:
|
||||||
|
# sentry-sdk 2.x captures Python logs as structured logs when the
|
||||||
|
# experimental logs feature is enabled; scrub those too.
|
||||||
|
init_kwargs["_experiments"] = {
|
||||||
|
"enable_logs": True,
|
||||||
|
"before_send_log": scrub_event,
|
||||||
|
}
|
||||||
|
sentry_sdk.init(**init_kwargs) # type: ignore[union-attr]
|
||||||
|
_STATE["initialized"] = True
|
||||||
|
_STATE["config"] = cfg
|
||||||
|
status["initialized"] = True
|
||||||
|
status["reason"] = "sentry initialised"
|
||||||
|
except Exception as exc: # fail open: observability must not block startup
|
||||||
|
status["reason"] = f"init failed (ignored): {type(exc).__name__}"
|
||||||
|
_STATE["initialized"] = False
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def _set_scope_tags(scope: Any, tags: dict[str, str]) -> None:
|
||||||
|
for key, val in tags.items():
|
||||||
|
try:
|
||||||
|
scope.set_tag(key, val)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def capture_workflow_blocker(
|
||||||
|
blocker_type: str,
|
||||||
|
*,
|
||||||
|
message: str | None = None,
|
||||||
|
next_action: str | None = None,
|
||||||
|
level: str = "warning",
|
||||||
|
tags: dict[str, Any] | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Capture a fail-closed workflow blocker as a structured Sentry event.
|
||||||
|
|
||||||
|
Always returns the redacted event dict (so callers/tests can inspect it),
|
||||||
|
and sends it to Sentry only when initialised. Fail open.
|
||||||
|
"""
|
||||||
|
event = build_blocker_event(
|
||||||
|
blocker_type,
|
||||||
|
message=message,
|
||||||
|
next_action=next_action,
|
||||||
|
level=level,
|
||||||
|
tags=tags,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if is_initialized() and sdk_available():
|
||||||
|
sentry_sdk.capture_event(event) # type: ignore[union-attr]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def capture_exception(
|
||||||
|
exc: BaseException,
|
||||||
|
*,
|
||||||
|
tags: dict[str, Any] | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Capture a runtime exception with scrubbed tags. Fail open.
|
||||||
|
|
||||||
|
Returns True only when the event was handed to an initialised SDK.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not (is_initialized() and sdk_available()):
|
||||||
|
return False
|
||||||
|
safe_tags = build_tags(**(tags or {}))
|
||||||
|
safe_extra = redact_value(dict(extra or {}))
|
||||||
|
with sentry_sdk.push_scope() as scope: # type: ignore[union-attr]
|
||||||
|
_set_scope_tags(scope, safe_tags)
|
||||||
|
for key, val in safe_extra.items():
|
||||||
|
try:
|
||||||
|
scope.set_extra(key, val)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sentry_sdk.capture_exception(exc) # type: ignore[union-attr]
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_checkin(
|
||||||
|
monitor: str,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
check_in_id: str | None = None,
|
||||||
|
duration: float | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Send a Sentry cron check-in for a watchdog job. Fail open.
|
||||||
|
|
||||||
|
Returns the payload (for inspection/tests), or ``None`` if the status was
|
||||||
|
invalid. Only transmits when initialised.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = build_checkin_payload(
|
||||||
|
monitor, status, check_in_id=check_in_id, duration=duration
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if is_initialized() and sdk_available() and hasattr(sentry_sdk, "capture_checkin"):
|
||||||
|
sentry_sdk.capture_checkin(**payload) # type: ignore[union-attr]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
"""Session-immutable MCP mutation context (#714).
|
||||||
|
|
||||||
|
Pins profile, remote, host, repository, identity, and role for the life of an
|
||||||
|
MCP process (or until an explicit ``gitea_activate_profile`` re-bind).
|
||||||
|
|
||||||
|
Capability resolution and mutation gates must evaluate only the active
|
||||||
|
profile for the requested remote. Silent cross-host / cross-profile
|
||||||
|
substitution is forbidden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SessionContext:
|
||||||
|
"""One atomic, immutable process-session binding."""
|
||||||
|
|
||||||
|
profile_name: str | None
|
||||||
|
remote: str | None
|
||||||
|
host: str | None
|
||||||
|
identity: str | None
|
||||||
|
repository: str | None
|
||||||
|
org: str | None
|
||||||
|
role_kind: str | None
|
||||||
|
expected_username: str | None
|
||||||
|
source: str
|
||||||
|
pid: int
|
||||||
|
canonical_repository_root: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"profile_name": self.profile_name,
|
||||||
|
"remote": self.remote,
|
||||||
|
"host": self.host,
|
||||||
|
"identity": self.identity,
|
||||||
|
"repository": self.repository,
|
||||||
|
"org": self.org,
|
||||||
|
"role_kind": self.role_kind,
|
||||||
|
"expected_username": self.expected_username,
|
||||||
|
"source": self.source,
|
||||||
|
"pid": self.pid,
|
||||||
|
"canonical_repository_root": self.canonical_repository_root,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Process-local only — never a shared file (same rationale as mutation authority).
|
||||||
|
# The frozen value prevents partial mutation, while the lock makes first-bind and
|
||||||
|
# sanctioned rebind atomic across concurrent MCP calls.
|
||||||
|
_SESSION_CONTEXT: _SessionContext | None = None
|
||||||
|
_SESSION_CONTEXT_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_session_context_for_testing() -> None:
|
||||||
|
"""Reset at a pytest test boundary; unavailable to production callers.
|
||||||
|
|
||||||
|
Production sessions transition only through process startup/PID change or
|
||||||
|
the explicit profile-activation rebind. Keeping this helper private and
|
||||||
|
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
|
||||||
|
bypass.
|
||||||
|
"""
|
||||||
|
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||||
|
raise RuntimeError("session context reset is restricted to pytest boundaries")
|
||||||
|
global _SESSION_CONTEXT
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
_SESSION_CONTEXT = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_context() -> dict[str, Any] | None:
|
||||||
|
"""Return a detached snapshot of the bound context, or None if unbound."""
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
if _SESSION_CONTEXT is None:
|
||||||
|
return None
|
||||||
|
return _SESSION_CONTEXT.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def profile_host(profile: dict | None) -> str | None:
|
||||||
|
"""Hostname from profile base_url, lowercased, or None."""
|
||||||
|
if not profile:
|
||||||
|
return None
|
||||||
|
base = (profile.get("base_url") or "").strip()
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(base)
|
||||||
|
host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||||
|
return host or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def remote_host(remote: str | None, remotes: dict | None) -> str | None:
|
||||||
|
"""Hostname for a known remote key."""
|
||||||
|
if not remote or not remotes:
|
||||||
|
return None
|
||||||
|
entry = remotes.get(remote) or {}
|
||||||
|
return (entry.get("host") or "").strip().lower() or None
|
||||||
|
|
||||||
|
|
||||||
|
def profile_matches_remote(
|
||||||
|
profile: dict | None,
|
||||||
|
remote: str | None,
|
||||||
|
remotes: dict | None,
|
||||||
|
*,
|
||||||
|
contexts: dict | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when *profile* is bound to the same host/context as *remote*."""
|
||||||
|
if not profile or not remote:
|
||||||
|
return False
|
||||||
|
r_host = remote_host(remote, remotes)
|
||||||
|
p_host = profile_host(profile)
|
||||||
|
if r_host and p_host:
|
||||||
|
return r_host == p_host
|
||||||
|
# Fall back to context name heuristics when base_url missing.
|
||||||
|
ctx = (profile.get("context") or "").strip().lower()
|
||||||
|
if not ctx or not contexts:
|
||||||
|
return False
|
||||||
|
ctx_data = contexts.get(ctx) or {}
|
||||||
|
gitea = ctx_data.get("gitea") or {}
|
||||||
|
base = (gitea.get("base_url") or "").strip()
|
||||||
|
if not base or not r_host:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(base)
|
||||||
|
c_host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return bool(c_host) and c_host == r_host
|
||||||
|
|
||||||
|
|
||||||
|
def bind_session_context(
|
||||||
|
*,
|
||||||
|
profile_name: str,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None,
|
||||||
|
identity: str | None,
|
||||||
|
repository: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
expected_username: str | None = None,
|
||||||
|
source: str = "bind",
|
||||||
|
canonical_repository_root: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Atomically bind/re-bind context (the explicit activation path)."""
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
return _bind_session_context_unlocked(
|
||||||
|
profile_name=profile_name,
|
||||||
|
remote=remote,
|
||||||
|
host=host,
|
||||||
|
identity=identity,
|
||||||
|
repository=repository,
|
||||||
|
org=org,
|
||||||
|
role_kind=role_kind,
|
||||||
|
expected_username=expected_username,
|
||||||
|
source=source,
|
||||||
|
canonical_repository_root=canonical_repository_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_session_context_unlocked(
|
||||||
|
*,
|
||||||
|
profile_name: str,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None,
|
||||||
|
identity: str | None,
|
||||||
|
repository: str | None,
|
||||||
|
org: str | None,
|
||||||
|
role_kind: str | None,
|
||||||
|
expected_username: str | None,
|
||||||
|
source: str,
|
||||||
|
canonical_repository_root: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Store a complete immutable context while the caller holds the lock."""
|
||||||
|
global _SESSION_CONTEXT
|
||||||
|
_SESSION_CONTEXT = _SessionContext(
|
||||||
|
profile_name=(profile_name or "").strip() or None,
|
||||||
|
remote=(remote or "").strip() or None,
|
||||||
|
host=(host or "").strip().lower() or None,
|
||||||
|
identity=(identity or "").strip() or None,
|
||||||
|
repository=(repository or "").strip() or None,
|
||||||
|
org=(org or "").strip() or None,
|
||||||
|
role_kind=(role_kind or "").strip() or None,
|
||||||
|
expected_username=(expected_username or "").strip() or None,
|
||||||
|
source=source,
|
||||||
|
pid=os.getpid(),
|
||||||
|
canonical_repository_root=(canonical_repository_root or "").strip() or None,
|
||||||
|
)
|
||||||
|
return _SESSION_CONTEXT.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_session_context_if_unbound(
|
||||||
|
*,
|
||||||
|
profile_name: str,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None,
|
||||||
|
identity: str | None,
|
||||||
|
repository: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
expected_username: str | None = None,
|
||||||
|
source: str = "seed",
|
||||||
|
canonical_repository_root: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Atomically bind only when this process has no current context.
|
||||||
|
|
||||||
|
A changed environment or an interleaved call is not a session boundary and
|
||||||
|
therefore cannot replace an established binding. A newly started/forked
|
||||||
|
process is recognized by PID; explicit ``gitea_activate_profile`` uses
|
||||||
|
:func:`bind_session_context` as its sanctioned logical-session transition.
|
||||||
|
"""
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
|
||||||
|
return _bind_session_context_unlocked(
|
||||||
|
profile_name=profile_name,
|
||||||
|
remote=remote,
|
||||||
|
host=host,
|
||||||
|
identity=identity,
|
||||||
|
repository=repository,
|
||||||
|
org=org,
|
||||||
|
role_kind=role_kind,
|
||||||
|
expected_username=expected_username,
|
||||||
|
source=source,
|
||||||
|
canonical_repository_root=canonical_repository_root,
|
||||||
|
)
|
||||||
|
return _SESSION_CONTEXT.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def assess_session_context(
|
||||||
|
*,
|
||||||
|
profile_name: str | None,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None = None,
|
||||||
|
identity: str | None = None,
|
||||||
|
repository: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
expected_username: str | None = None,
|
||||||
|
canonical_repository_root: str | None = None,
|
||||||
|
require_bound: bool = False,
|
||||||
|
require_complete: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compare live values against the bound session context.
|
||||||
|
|
||||||
|
Returns ``proven`` / ``block`` / ``reasons``. When unbound and
|
||||||
|
``require_bound`` is false, does not block (caller may seed). When
|
||||||
|
unbound and ``require_bound`` is true, fails closed. ``require_complete``
|
||||||
|
additionally fails closed when the binding carries no verified
|
||||||
|
repository/organization identity, so a mutation can never run against an
|
||||||
|
unknown repository (#714).
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
bound = _SESSION_CONTEXT
|
||||||
|
ctx = bound.as_dict() if bound is not None else None
|
||||||
|
if ctx is None or ctx.get("pid") != os.getpid():
|
||||||
|
if require_bound:
|
||||||
|
reasons.append(
|
||||||
|
"session mutation context is unbound; call gitea_whoami or "
|
||||||
|
"gitea_activate_profile before mutating (fail closed)"
|
||||||
|
)
|
||||||
|
return _assessment(False, reasons, ctx)
|
||||||
|
return _assessment(True, reasons, ctx)
|
||||||
|
|
||||||
|
if require_complete and not (ctx.get("repository") and ctx.get("org")):
|
||||||
|
reasons.append(
|
||||||
|
"session repository/organization identity is unverified "
|
||||||
|
f"(repository={ctx.get('repository')!r}, org={ctx.get('org')!r}); "
|
||||||
|
"a mutation cannot proceed without a verified workspace "
|
||||||
|
"repository (fail closed)"
|
||||||
|
)
|
||||||
|
return _assessment(False, reasons, ctx)
|
||||||
|
|
||||||
|
live_profile = (profile_name or "").strip() or None
|
||||||
|
live_remote = (remote or "").strip() or None
|
||||||
|
live_host = (host or "").strip().lower() or None
|
||||||
|
live_identity = (identity or "").strip() or None
|
||||||
|
live_repo = (repository or "").strip() or None
|
||||||
|
live_org = (org or "").strip() or None
|
||||||
|
live_canonical = (canonical_repository_root or "").strip() or None
|
||||||
|
|
||||||
|
if ctx.get("profile_name") and live_profile and live_profile != ctx.get("profile_name"):
|
||||||
|
reasons.append(
|
||||||
|
f"profile drift: live '{live_profile}' != bound "
|
||||||
|
f"'{ctx.get('profile_name')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("remote") and live_remote and live_remote != ctx.get("remote"):
|
||||||
|
reasons.append(
|
||||||
|
f"remote drift: live '{live_remote}' != bound "
|
||||||
|
f"'{ctx.get('remote')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("host") and live_host and live_host != ctx.get("host"):
|
||||||
|
reasons.append(
|
||||||
|
f"host drift: live '{live_host}' != bound "
|
||||||
|
f"'{ctx.get('host')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
ctx.get("identity")
|
||||||
|
and live_identity
|
||||||
|
and live_identity != ctx.get("identity")
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
f"identity drift: live '{live_identity}' != bound "
|
||||||
|
f"'{ctx.get('identity')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("repository") and live_repo and live_repo != ctx.get("repository"):
|
||||||
|
reasons.append(
|
||||||
|
f"repository drift: live '{live_repo}' != bound "
|
||||||
|
f"'{ctx.get('repository')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("org") and live_org and live_org != ctx.get("org"):
|
||||||
|
reasons.append(
|
||||||
|
f"org drift: live '{live_org}' != bound "
|
||||||
|
f"'{ctx.get('org')}' (fail closed)"
|
||||||
|
)
|
||||||
|
bound_canonical = ctx.get("canonical_repository_root")
|
||||||
|
if bound_canonical and live_canonical and live_canonical != bound_canonical:
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root drift: live '{live_canonical}' != bound "
|
||||||
|
f"'{bound_canonical}' (forged or conflicting cross-repository "
|
||||||
|
"binding, fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = expected_username or ctx.get("expected_username")
|
||||||
|
if expected and live_identity and live_identity != expected:
|
||||||
|
reasons.append(
|
||||||
|
f"identity mismatch: authenticated '{live_identity}' != "
|
||||||
|
f"profile expected '{expected}' (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _assessment(not reasons, reasons, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_identity_match(
|
||||||
|
*,
|
||||||
|
authenticated: str | None,
|
||||||
|
expected_username: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail closed when profile declares a username that does not match live auth."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
auth = (authenticated or "").strip() or None
|
||||||
|
expected = (expected_username or "").strip() or None
|
||||||
|
if expected and auth and auth != expected:
|
||||||
|
reasons.append(
|
||||||
|
f"identity mismatch: authenticated '{auth}' != "
|
||||||
|
f"profile expected '{expected}' (fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": not reasons,
|
||||||
|
"block": bool(reasons),
|
||||||
|
"reasons": reasons,
|
||||||
|
"authenticated": auth,
|
||||||
|
"expected_username": expected,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_SLUG_RE = re.compile(r"^\s*(?P<org>[^/\s]+)\s*/\s*(?P<repo>[^/\s]+?)(?:\.git)?\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_repository_slug(slug: str | None) -> tuple[str, str] | None:
|
||||||
|
"""Split a canonical ``owner/repository`` slug, or None when unparseable."""
|
||||||
|
match = _REPO_SLUG_RE.match(slug or "")
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group("org"), match.group("repo")
|
||||||
|
|
||||||
|
|
||||||
|
def format_repository_slug(org: str | None, repo: str | None) -> str | None:
|
||||||
|
"""``owner/repository`` from parts, or None when either side is missing."""
|
||||||
|
left = (org or "").strip()
|
||||||
|
right = (repo or "").strip()
|
||||||
|
if not left or not right:
|
||||||
|
return None
|
||||||
|
return f"{left}/{right}"
|
||||||
|
|
||||||
|
|
||||||
|
def declared_allowed_repositories(
|
||||||
|
profile: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
strict: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Canonical ``owner/repository`` authorization boundary declared by *profile*.
|
||||||
|
|
||||||
|
This list is an authorization boundary, never the session binding itself:
|
||||||
|
the verified workspace selects exactly one entry (see
|
||||||
|
:func:`assess_repository_scope`).
|
||||||
|
|
||||||
|
When *strict* is true (mutation path), missing profile, non-list values, or
|
||||||
|
any malformed entry raise ``ValueError`` instead of silently collapsing to
|
||||||
|
an empty scope (which would fail open). Read-only diagnostics may use
|
||||||
|
strict=False.
|
||||||
|
"""
|
||||||
|
if not profile:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(
|
||||||
|
"profile unresolved; cannot declare allowed_repositories "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
raw = profile.get("allowed_repositories")
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(raw, (list, tuple)):
|
||||||
|
if strict:
|
||||||
|
raise ValueError(
|
||||||
|
"allowed_repositories must be a list of owner/repository slugs "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
slugs: list[str] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
for entry in raw:
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
errors.append(f"non-string allowed_repositories entry {entry!r}")
|
||||||
|
continue
|
||||||
|
parsed = parse_repository_slug(entry)
|
||||||
|
if not parsed:
|
||||||
|
errors.append(
|
||||||
|
f"malformed allowed_repositories entry {entry!r} "
|
||||||
|
"(expected owner/repository)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
slugs.append(f"{parsed[0]}/{parsed[1]}")
|
||||||
|
if strict and errors:
|
||||||
|
raise ValueError("; ".join(errors) + " (fail closed)")
|
||||||
|
return slugs
|
||||||
|
|
||||||
|
|
||||||
|
def assess_repository_scope(
|
||||||
|
*,
|
||||||
|
workspace_slug: str | None,
|
||||||
|
allowed: list[str] | None,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
require_scope: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Authorize the verified workspace repository against the profile allowlist.
|
||||||
|
|
||||||
|
The workspace-derived slug is the only candidate: a profile that authorizes
|
||||||
|
several repositories still binds to the single verified one, and never to
|
||||||
|
the list as a whole.
|
||||||
|
|
||||||
|
*require_scope* (mutation path): missing/empty allowlist fails closed. The
|
||||||
|
workspace remote cannot self-authorize without a configured boundary.
|
||||||
|
"""
|
||||||
|
scope = list(allowed or [])
|
||||||
|
reasons: list[str] = []
|
||||||
|
name = profile_name or "(active profile)"
|
||||||
|
if not scope:
|
||||||
|
if require_scope:
|
||||||
|
reasons.append(
|
||||||
|
f"mutation denied: profile '{name}' has no non-empty "
|
||||||
|
"allowed_repositories authorization boundary (fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
"scope_enforced": True,
|
||||||
|
"workspace_slug": workspace_slug,
|
||||||
|
"allowed_repositories": [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"scope_enforced": False,
|
||||||
|
"workspace_slug": workspace_slug,
|
||||||
|
"allowed_repositories": [],
|
||||||
|
}
|
||||||
|
if not workspace_slug:
|
||||||
|
reasons.append(
|
||||||
|
f"no verified workspace repository could be established for profile "
|
||||||
|
f"'{name}'; repository scope cannot be authorized (fail closed)"
|
||||||
|
)
|
||||||
|
elif workspace_slug.lower() not in {entry.lower() for entry in scope}:
|
||||||
|
reasons.append(
|
||||||
|
f"repository scope denial: workspace repository '{workspace_slug}' "
|
||||||
|
f"is not authorized by profile '{name}' allowed_repositories "
|
||||||
|
f"{sorted(scope)} (fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": not reasons,
|
||||||
|
"block": bool(reasons),
|
||||||
|
"reasons": reasons,
|
||||||
|
"scope_enforced": True,
|
||||||
|
"workspace_slug": workspace_slug,
|
||||||
|
"allowed_repositories": sorted(scope),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_repository_override(
|
||||||
|
*,
|
||||||
|
requested_org: str | None,
|
||||||
|
requested_repo: str | None,
|
||||||
|
bound_org: str | None,
|
||||||
|
bound_repo: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Caller-supplied org/repo must agree with the immutable binding.
|
||||||
|
|
||||||
|
A mutation request is never allowed to establish, complete, or replace the
|
||||||
|
binding — it may only be checked against it.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
req_org = (requested_org or "").strip() or None
|
||||||
|
req_repo = (requested_repo or "").strip() or None
|
||||||
|
if req_repo and bound_repo and req_repo.lower() != bound_repo.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"repository override denial: request targets '{req_repo}' but the "
|
||||||
|
f"session is bound to '{bound_repo}' (fail closed)"
|
||||||
|
)
|
||||||
|
if req_org and bound_org and req_org.lower() != bound_org.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"organization override denial: request targets '{req_org}' but the "
|
||||||
|
f"session is bound to '{bound_org}' (fail closed)"
|
||||||
|
)
|
||||||
|
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||||
|
|
||||||
|
|
||||||
|
def filter_profiles_for_remote(
|
||||||
|
config: dict | None,
|
||||||
|
remote: str | None,
|
||||||
|
remotes: dict | None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Profile names whose host/context matches *remote* (enabled only)."""
|
||||||
|
if not config or not remote:
|
||||||
|
return []
|
||||||
|
profiles = config.get("profiles") or {}
|
||||||
|
contexts = config.get("contexts") or {}
|
||||||
|
names: list[str] = []
|
||||||
|
for name, data in profiles.items():
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
continue
|
||||||
|
if not data.get("enabled", True):
|
||||||
|
continue
|
||||||
|
if profile_matches_remote(data, remote, remotes, contexts=contexts):
|
||||||
|
names.append(name)
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_allowed_for_remote(
|
||||||
|
profile: dict | None,
|
||||||
|
remote: str | None,
|
||||||
|
remotes: dict | None,
|
||||||
|
*,
|
||||||
|
contexts: dict | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether the active profile may serve *remote*."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not profile:
|
||||||
|
reasons.append("active profile unresolved (fail closed)")
|
||||||
|
return {"proven": False, "block": True, "reasons": reasons}
|
||||||
|
if not remote:
|
||||||
|
return {"proven": True, "block": False, "reasons": reasons}
|
||||||
|
# Legacy env-only profiles have no configured base URL or v2 context. Their
|
||||||
|
# first call may establish the process remote/host pin; after that,
|
||||||
|
# assess_session_context rejects any drift. Configured v2 profiles still
|
||||||
|
# require positive host/context alignment here.
|
||||||
|
if not profile_host(profile) and not (profile.get("context") or "").strip():
|
||||||
|
return {"proven": True, "block": False, "reasons": reasons}
|
||||||
|
if not profile_matches_remote(profile, remote, remotes, contexts=contexts):
|
||||||
|
p_name = profile.get("profile_name") or profile.get("name") or "(unknown)"
|
||||||
|
p_host = profile_host(profile) or "(none)"
|
||||||
|
r_host = remote_host(remote, remotes) or "(none)"
|
||||||
|
reasons.append(
|
||||||
|
f"cross-host profile denial: profile '{p_name}' (host '{p_host}') "
|
||||||
|
f"cannot serve remote '{remote}' (host '{r_host}') (fail closed)"
|
||||||
|
)
|
||||||
|
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_context_audit_fields(
|
||||||
|
ctx: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fields to include in pre-mutation audit records."""
|
||||||
|
data = ctx if ctx is not None else get_session_context()
|
||||||
|
if not data:
|
||||||
|
return {
|
||||||
|
"session_context_bound": False,
|
||||||
|
"session_profile": None,
|
||||||
|
"session_remote": None,
|
||||||
|
"session_host": None,
|
||||||
|
"session_identity": None,
|
||||||
|
"session_repository": None,
|
||||||
|
"session_org": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"session_context_bound": True,
|
||||||
|
"session_profile": data.get("profile_name"),
|
||||||
|
"session_remote": data.get("remote"),
|
||||||
|
"session_host": data.get("host"),
|
||||||
|
"session_identity": data.get("identity"),
|
||||||
|
"session_repository": data.get("repository"),
|
||||||
|
"session_org": data.get("org"),
|
||||||
|
"session_role_kind": data.get("role_kind"),
|
||||||
|
"session_context_source": data.get("source"),
|
||||||
|
"session_canonical_repository_root": data.get("canonical_repository_root"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assessment(
|
||||||
|
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"proven": proven,
|
||||||
|
"block": not proven,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"bound_context": dict(ctx) if ctx else None,
|
||||||
|
"audit": mutation_context_audit_fields(ctx),
|
||||||
|
}
|
||||||
@@ -32,6 +32,15 @@ workflow file.
|
|||||||
mutation.
|
mutation.
|
||||||
- A nearby capability does not count.
|
- A nearby capability does not count.
|
||||||
- Do not self-review or self-merge.
|
- Do not self-review or self-merge.
|
||||||
|
- **Never push a stable branch directly.** Worker sessions (author/reviewer/merger)
|
||||||
|
must never run `git push <remote> master` — or `main`/`dev`/other stable refs,
|
||||||
|
including refspecs (`HEAD:master`), `--force`, `--delete`, or `--dry-run` no-op
|
||||||
|
probes — and must never commit on the root/control checkout outside an issue
|
||||||
|
feature branch. Stable-branch updates land ONLY through sanctioned Gitea merge
|
||||||
|
tooling (`gitea_merge_pr`) or an explicitly authorized reconciler path. A
|
||||||
|
detected attempt marks the session workflow-contaminated (#671) and fails
|
||||||
|
closed on review/merge/close/completion until a reconciler audits and clears
|
||||||
|
it. `git fetch` / `git pull --ff-only` and feature-branch pushes stay allowed.
|
||||||
- Do not mix modes in one run.
|
- Do not mix modes in one run.
|
||||||
- **BLOCKED + DIAGNOSE default rule (required):** If any required workflow step, skill, tool, capability, preflight, instruction, profile, worktree binding, or terminal/MCP operation cannot be performed or loaded (including the canonical ones listed in this skill and its loaded workflow), immediately enter `BLOCKED + DIAGNOSE`. Stop before any git or Gitea mutation. Diagnose using the standard template in [`templates/blocked-diagnose-report.md`](templates/blocked-diagnose-report.md). Attempt *only* safe non-mutating recovery. Report using the template. Do not continue, use fallbacks, or treat the missing requirement as harmless.
|
- **BLOCKED + DIAGNOSE default rule (required):** If any required workflow step, skill, tool, capability, preflight, instruction, profile, worktree binding, or terminal/MCP operation cannot be performed or loaded (including the canonical ones listed in this skill and its loaded workflow), immediately enter `BLOCKED + DIAGNOSE`. Stop before any git or Gitea mutation. Diagnose using the standard template in [`templates/blocked-diagnose-report.md`](templates/blocked-diagnose-report.md). Attempt *only* safe non-mutating recovery. Report using the template. Do not continue, use fallbacks, or treat the missing requirement as harmless.
|
||||||
- If the required workflow cannot be loaded, stop and produce a recovery handoff
|
- If the required workflow cannot be loaded, stop and produce a recovery handoff
|
||||||
@@ -48,6 +57,11 @@ workflow file.
|
|||||||
- wrong profile or role for the requested operation
|
- wrong profile or role for the requested operation
|
||||||
- dirty or misbound worktree (root checkout or non-branches/ path)
|
- dirty or misbound worktree (root checkout or non-branches/ path)
|
||||||
- root checkout mutation risk
|
- root checkout mutation risk
|
||||||
|
- stable-branch push contamination (#671): a direct `git push <remote> master`
|
||||||
|
(or `main`/`dev`) equivalent, or a root/control-checkout commit not on an issue
|
||||||
|
feature branch, marks the session workflow-contaminated; review/merge/close/
|
||||||
|
completion mutations then fail closed until a reconciler audits and clears it
|
||||||
|
(`gitea_audit_stable_branch_contamination action=clear`, reconciler-only)
|
||||||
- mutation guard failure (e.g. branches-only guard)
|
- mutation guard failure (e.g. branches-only guard)
|
||||||
- missing required MCP tool/schema or operation
|
- missing required MCP tool/schema or operation
|
||||||
- stale or inconsistent runtime state (e.g. lease vs actual, dirty state disagreement)
|
- stale or inconsistent runtime state (e.g. lease vs actual, dirty state disagreement)
|
||||||
@@ -118,6 +132,42 @@ The main project checkout is a stable control checkout on `master`, `main`, or
|
|||||||
If `cwd` is not inside `branches/`, stop before any file edit, test write,
|
If `cwd` is not inside `branches/`, stop before any file edit, test write,
|
||||||
commit, merge, rebase, or cleanup. The main checkout is orchestration-only.
|
commit, merge, rebase, or cleanup. The main checkout is orchestration-only.
|
||||||
|
|
||||||
|
## Stable Branch Push Protection (#671)
|
||||||
|
|
||||||
|
Worker sessions must **never** publish a stable branch directly. This is the
|
||||||
|
prevention hardening for the #670 incident (a bare direct-to-master commit
|
||||||
|
`2fa97c26` and a PR #654 merger `git push prgs master` attempt).
|
||||||
|
|
||||||
|
**Forbidden for author/reviewer/merger sessions:**
|
||||||
|
|
||||||
|
- `git push <remote> master` (and `main`, `dev`, `develop`, `development`),
|
||||||
|
including refspecs (`HEAD:master`, `+refs/heads/x:refs/heads/master`),
|
||||||
|
`--force`, `--delete` / `:master`, and `--dry-run`/`-n` no-op probes (a
|
||||||
|
dry-run still proves intent and contaminates the session).
|
||||||
|
- Local commits on the root/control checkout that are not carried by an issue
|
||||||
|
feature branch under `branches/`.
|
||||||
|
|
||||||
|
**Allowed (never blocked):**
|
||||||
|
|
||||||
|
- `git fetch` and `git pull --ff-only` of master into the control checkout when
|
||||||
|
authorized for sync.
|
||||||
|
- Feature-branch pushes to non-stable refs (`git push <remote> fix/issue-N-...`).
|
||||||
|
- Sanctioned merges via `gitea_merge_pr` / the Gitea API merge endpoint — the
|
||||||
|
**only** way stable branches advance.
|
||||||
|
|
||||||
|
**What happens on a detected attempt:** the session is marked
|
||||||
|
workflow-contaminated (durable `stable_branch_contamination` marker, redacted
|
||||||
|
command summary + session id + remote + ref). While contaminated, all
|
||||||
|
review / merge / close / issue-completion mutations fail closed. `comment_issue`
|
||||||
|
and `lock_issue` remain allowed so the contaminated worker can post the durable
|
||||||
|
audit comment and hand off. Contamination **cannot be self-cleared** — only a
|
||||||
|
reconciler audit (`gitea_audit_stable_branch_contamination action=clear`) may
|
||||||
|
clear it.
|
||||||
|
|
||||||
|
Tooling: call `gitea_record_stable_branch_push_attempt` to classify/record a
|
||||||
|
proposed push before running it; `gitea_audit_stable_branch_contamination` to
|
||||||
|
inspect or (reconciler-only) clear the marker.
|
||||||
|
|
||||||
## Shell Spawn Hard-Stop Rule
|
## Shell Spawn Hard-Stop Rule
|
||||||
|
|
||||||
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
|
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
|
||||||
|
|||||||
@@ -33,22 +33,34 @@ Steps:
|
|||||||
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
|
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
|
||||||
2. Verify authenticated identity + active profile.
|
2. Verify authenticated identity + active profile.
|
||||||
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
|
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
|
||||||
4. Capability evidence (#179): cite the exact gitea_resolve_task_capability
|
4. **PR sync assess (required):** call `gitea_assess_pr_sync_status` with
|
||||||
|
explicit `remote`/`org`/`repo`. Route on `recommended_next_action`:
|
||||||
|
- `merge_now` → continue merger path (do **not** update the branch)
|
||||||
|
- `update_branch_by_merge` → STOP; hand off to **author** for
|
||||||
|
`gitea_update_pr_branch_by_merge` (pins expected PR head + base head)
|
||||||
|
- `author_conflict_remediation` → STOP; author worktree conflict fix
|
||||||
|
- `fresh_review_required` → STOP; independent re-review at current head
|
||||||
|
- `blocked` → STOP and diagnose
|
||||||
|
Never merge on a former-head approval after update/remediation.
|
||||||
|
5. Capability evidence (#179): cite the exact gitea_resolve_task_capability
|
||||||
output (or runtime context) proving merge_pr is allowed — a bare
|
output (or runtime context) proving merge_pr is allowed — a bare
|
||||||
"capability checks passed" claim is downgraded.
|
"capability checks passed" claim is downgraded.
|
||||||
5. Final live-state recheck (#179), immediately before the merge mutation —
|
6. Final live-state recheck (#179), immediately before the merge mutation —
|
||||||
re-read the live PR and prove:
|
re-read the live PR and prove:
|
||||||
- PR still open
|
- PR still open
|
||||||
- live head SHA still equals the pinned/reviewed head SHA
|
- live head SHA still equals the pinned/reviewed head SHA
|
||||||
- base branch unchanged
|
- base branch unchanged
|
||||||
- no undismissed REQUEST_CHANGES / blocking review state remains
|
- no undismissed REQUEST_CHANGES / blocking review state remains
|
||||||
If any recheck fails → STOP, re-pin, re-validate.
|
If any recheck fails → STOP, re-pin, re-validate.
|
||||||
6. If any gate fails → STOP and report.
|
7. If any gate fails → STOP and report.
|
||||||
7. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
8. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
||||||
pinning the reviewed head SHA (expected_head_sha) and, where supported,
|
pinning the reviewed head SHA (expected_head_sha) and, where supported,
|
||||||
the changed-file set.
|
the changed-file set.
|
||||||
8. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
|
9. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
|
||||||
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
|
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
|
||||||
|
10. **Sequential queue:** after merge, refresh live `master`, run post-merge
|
||||||
|
cleanup handoff, then reassess the **next** PR (do not batch-update all
|
||||||
|
open PRs).
|
||||||
|
|
||||||
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
|
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
|
||||||
- Record merge mutations separately from cleanup mutations in the controller handoff.
|
- Record merge mutations separately from cleanup mutations in the controller handoff.
|
||||||
|
|||||||
@@ -344,6 +344,41 @@ If edits are needed, make the smallest correction necessary and report the corre
|
|||||||
|
|
||||||
Do not silently change requested meaning.
|
Do not silently change requested meaning.
|
||||||
|
|
||||||
|
## 14a. Enforced content preflight (#582)
|
||||||
|
|
||||||
|
`gitea_create_issue` runs a **content preflight gate** before duplicate
|
||||||
|
search or creation. It fails closed (returns `BLOCKED + DIAGNOSE`, no issue
|
||||||
|
created) when the title/body is a *vague reference* to out-of-band draft
|
||||||
|
content the session cannot prove it holds, and no durable source pointer is
|
||||||
|
present.
|
||||||
|
|
||||||
|
An LLM must never fabricate issue content from stale chat memory. If asked to
|
||||||
|
create "the drafted issue" / "the prepared issue" / "the issue we discussed"
|
||||||
|
/ "the previous draft" without the full content in the current context or a
|
||||||
|
durable source pointer, stop and return `BLOCKED + DIAGNOSE` naming the exact
|
||||||
|
vague/missing fields.
|
||||||
|
|
||||||
|
A **durable source pointer** is one of: an existing issue/PR reference
|
||||||
|
(`#582`), a comment id (`comment 8155`), a checked-in file path
|
||||||
|
(`task_capability_map.py`), a URL, or a scratchpad path. When a pointer is
|
||||||
|
present, retrieve the real content from it before creating.
|
||||||
|
|
||||||
|
The gate does **not** require a non-empty body; explicit title-only creation
|
||||||
|
stays allowed. It only blocks placeholder references. An operator may bypass
|
||||||
|
it with `allow_incomplete_content=True` (report the override).
|
||||||
|
|
||||||
|
**Invalid prompts (must block):**
|
||||||
|
|
||||||
|
* "Create the drafted issue."
|
||||||
|
* "File the prepared issue we discussed."
|
||||||
|
* "Open the previous draft."
|
||||||
|
|
||||||
|
**Valid prompts (proceed):**
|
||||||
|
|
||||||
|
* Full title + body + acceptance criteria supplied inline.
|
||||||
|
* "Create the issue drafted in `#582`." (durable pointer → fetch and use it)
|
||||||
|
* "Create the issue from `scratchpad/issue-draft.md`." (durable file pointer)
|
||||||
|
|
||||||
## 15. Labels, assignees, and metadata
|
## 15. Labels, assignees, and metadata
|
||||||
|
|
||||||
Apply labels, assignees, milestones, or project fields only if:
|
Apply labels, assignees, milestones, or project fields only if:
|
||||||
@@ -415,6 +450,35 @@ If any gate fails, do not create the issue.
|
|||||||
|
|
||||||
Produce a recovery handoff or duplicate report.
|
Produce a recovery handoff or duplicate report.
|
||||||
|
|
||||||
|
### 18a. Sanctioned first-mutation path (#749)
|
||||||
|
|
||||||
|
`gitea_create_issue` is a **pure remote mutation** (no local tree write). The
|
||||||
|
issue-first gate forbids creating `branches/issue-<N>-*` before the issue
|
||||||
|
number exists. Therefore the **only sanctioned first mutation** is:
|
||||||
|
|
||||||
|
1. Read-only identity + capability + duplicate search from the control checkout.
|
||||||
|
2. Ensure the **canonical control checkout** is:
|
||||||
|
* the configured repository root for the requested remote/org/repo;
|
||||||
|
* on an accepted base branch (`master` / `main` / `dev`);
|
||||||
|
* base-equivalent to live master;
|
||||||
|
* clean (no tracked local edits);
|
||||||
|
* in runtime/master parity.
|
||||||
|
3. Resolve exact task `create_issue`, then call `gitea_create_issue` **from that
|
||||||
|
clean control checkout** (no `worktree_path` required for this step alone).
|
||||||
|
4. After the issue number exists: create a **registered** worktree under
|
||||||
|
`branches/issue-<N>-*`, claim/lock, and perform every subsequent author
|
||||||
|
mutation from that worktree only.
|
||||||
|
|
||||||
|
**Forbidden improvisations (fail closed):**
|
||||||
|
|
||||||
|
* `mkdir` dummy directories under `branches/` (#713)
|
||||||
|
* borrowing an unrelated pre-existing worktree
|
||||||
|
* creating a pre-issue worktree in violation of issue-first
|
||||||
|
* running create_issue from a dirty, drifted, detached, or non-canonical root
|
||||||
|
|
||||||
|
Post-creation mutations (`lock_issue`, commit, push, `create_pr`, etc.) **never**
|
||||||
|
receive this bootstrap exemption.
|
||||||
|
|
||||||
## 19. Issue commenting gate
|
## 19. Issue commenting gate
|
||||||
|
|
||||||
Before commenting on an existing issue, verify:
|
Before commenting on an existing issue, verify:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ before any PR mutation. Final report schema:
|
|||||||
|
|
||||||
**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this.
|
**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this.
|
||||||
|
|
||||||
|
**Native MCP failure is a hard stop (#695):** If the native MCP namespace dies (EOF, capability disconnect, session death), STOP. Do **not** import `gitea_mcp_server` from a standalone process, do **not** run offline helpers (`offline_mcp_helper.py`, `offline_mcp_runner.py`, `run_quarantine.py`), and do **not** set direct-import / keychain-bypass / raw-token environment variables. Reconnect the official MCP daemon only. Contaminated approvals must be controller-quarantined; they never authorize merge.
|
||||||
|
|
||||||
**Default task prompt:**
|
**Default task prompt:**
|
||||||
|
|
||||||
> Review the next eligible open PR in this project. Merge it only if every
|
> Review the next eligible open PR in this project. Merge it only if every
|
||||||
@@ -826,6 +828,75 @@ The final report must identify:
|
|||||||
* whether same-PR merge continuation was allowed
|
* whether same-PR merge continuation was allowed
|
||||||
* whether the run stopped as required
|
* whether the run stopped as required
|
||||||
|
|
||||||
|
## 26A-1. Stale durable review-decision lock cleanup (#594)
|
||||||
|
|
||||||
|
After #559, the review-decision lock is durable under
|
||||||
|
`~/.cache/gitea-tools/session-state/` (for example
|
||||||
|
`review_decision_lock-prgs-reviewer.json`). That durability can outlive the work
|
||||||
|
it protects: a terminal `approve` / `request_changes` on a PR that is already
|
||||||
|
**merged or closed** still hard-stops later unrelated reviews under #332.
|
||||||
|
|
||||||
|
**Do not** delete session-state files by hand. Use the canonical MCP path.
|
||||||
|
|
||||||
|
### When cleanup is allowed
|
||||||
|
|
||||||
|
Use `gitea_cleanup_stale_review_decision_lock` when:
|
||||||
|
|
||||||
|
1. `gitea_mark_final_review_decision` / live review is blocked by
|
||||||
|
`terminal review mutation already consumed ... (#332)`.
|
||||||
|
2. Live Gitea state for the **last terminal mutation's PR** is **merged** or
|
||||||
|
**closed** (no same-PR merge sequence remains).
|
||||||
|
3. Active profile identity matches the durable lock (reviewer profile with
|
||||||
|
`gitea.pr.review` for `apply=true`).
|
||||||
|
4. Optional pin: pass `expected_terminal_pr` to the prior terminal PR number
|
||||||
|
(for example `586` when resuming after a merged #586 lock).
|
||||||
|
|
||||||
|
Recommended sequence:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_whoami
|
||||||
|
gitea_resolve_task_capability(task="cleanup_stale_review_decision_lock")
|
||||||
|
gitea_cleanup_stale_review_decision_lock(apply=false, remote=..., org=..., repo=...)
|
||||||
|
# inspect is_moot / cleanup_allowed / last_terminal_pr
|
||||||
|
gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=true,
|
||||||
|
expected_terminal_pr=<last_terminal_pr>,
|
||||||
|
remote=..., org=..., repo=...,
|
||||||
|
)
|
||||||
|
gitea_resolve_task_capability(task="review_pr")
|
||||||
|
gitea_load_review_workflow()
|
||||||
|
# continue formal mark + submit for the *new* PR
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful `apply=true` clears memory + durable store and returns an audit
|
||||||
|
record (and posts a durable PR comment on the terminal PR when
|
||||||
|
`post_audit_comment` is true and comment permission allows).
|
||||||
|
|
||||||
|
Same-profile auto-expire: after `gitea_merge_pr` succeeds for the PR that this
|
||||||
|
profile just approved, the decision lock for that profile is cleared
|
||||||
|
automatically. Cross-profile locks (for example reviewer approve, merger merge)
|
||||||
|
still require `gitea_cleanup_stale_review_decision_lock`.
|
||||||
|
|
||||||
|
### When cleanup is forbidden
|
||||||
|
|
||||||
|
Refuse / fail-closed — keep #332 hard-stop — when:
|
||||||
|
|
||||||
|
* the last terminal PR is still **open** (active review or merge sequence may
|
||||||
|
still apply)
|
||||||
|
* live PR state cannot be fetched (ambiguous)
|
||||||
|
* no lock or no terminal live mutation exists
|
||||||
|
* profile identity does not match the lock
|
||||||
|
* apply requested without authenticated identity or `gitea.pr.review`
|
||||||
|
* `expected_terminal_pr` does not match the last terminal PR
|
||||||
|
|
||||||
|
Do **not** use cleanup to:
|
||||||
|
|
||||||
|
* bypass #332 on an open PR
|
||||||
|
* replace `gitea_authorize_review_correction` for a mistaken review on a still
|
||||||
|
active PR (#211)
|
||||||
|
* auto-approve or auto-merge any PR
|
||||||
|
* justify `rm` of session-state files
|
||||||
|
|
||||||
## 26B. Per-PR reviewer lease (#407)
|
## 26B. Per-PR reviewer lease (#407)
|
||||||
|
|
||||||
Parallel reviewer sessions are allowed only when each session holds a distinct,
|
Parallel reviewer sessions are allowed only when each session holds a distinct,
|
||||||
@@ -879,9 +950,53 @@ Final reports must state:
|
|||||||
* final live head SHA before merge
|
* final live head SHA before merge
|
||||||
* whether any push occurred during validation
|
* whether any push occurred during validation
|
||||||
|
|
||||||
|
## 26D. PR synchronization and conflict-remediation lifecycle
|
||||||
|
|
||||||
|
**Do not treat “approved” as the final readiness state.** For every approved
|
||||||
|
open PR, call `gitea_assess_pr_sync_status` (native MCP only) and route by
|
||||||
|
`recommended_next_action`:
|
||||||
|
|
||||||
|
| Action | Meaning | Next role / tools |
|
||||||
|
|--------|---------|-------------------|
|
||||||
|
| `merge_now` | Valid approval at exact current head; conflict-free; update not required; checks ok | Merger: sanctioned merge workflow only — **do not** update the branch |
|
||||||
|
| `update_branch_by_merge` | Behind live base; protection requires current base; Gitea can merge base without conflicts | **Author only:** `gitea_update_pr_branch_by_merge` with pinned `expected_pr_head_sha` + `expected_base_head_sha` |
|
||||||
|
| `author_conflict_remediation` | Conflicts / not auto-updatable | **Author only:** existing `branches/` worktree, issue lock, merge master, resolve, test, push; never force-push/rebase |
|
||||||
|
| `fresh_review_required` | Head changed after approval, or update/remediation produced a new head | Independent reviewer at the **new exact head**; old approvals/leases/verdicts are void |
|
||||||
|
| `blocked` | Other gate (checks, incomplete facts, closed PR, …) | Diagnose; do not merge or update |
|
||||||
|
|
||||||
|
### Hard rules
|
||||||
|
|
||||||
|
* Pin both expected PR head and expected base head for every update. Either
|
||||||
|
race → fail closed with no partial mutation.
|
||||||
|
* Never rebase or force-push author branches.
|
||||||
|
* Never update an author branch from a reviewer or merger profile.
|
||||||
|
* Never preserve approval, reviewer lease, merger lease, or prepared verdict
|
||||||
|
across a head change.
|
||||||
|
* Process PRs **sequentially**: synchronize/remediate one → review new head →
|
||||||
|
merge → refresh live `master` → reassess the next PR. Do not update every
|
||||||
|
open PR at once (each merge restales the rest).
|
||||||
|
* Conflict remediation only in the existing dedicated issue/PR worktree under
|
||||||
|
`branches/`. Do not delete that worktree until the updated PR is merged and
|
||||||
|
cleanup eligibility is proven.
|
||||||
|
* Post-merge: canonical cleanup handoff to reconciler (section 28).
|
||||||
|
|
||||||
|
### After `gitea_update_pr_branch_by_merge` succeeds
|
||||||
|
|
||||||
|
1. Treat former-head approval as invalidated.
|
||||||
|
2. Release/supersede obsolete reviewer and merger leases.
|
||||||
|
3. Route `recommended_next_action=fresh_review_required` at the new head.
|
||||||
|
4. Independent re-review, then merger lease/adopt + merge at the new head only.
|
||||||
|
|
||||||
|
All Gitea reads/mutations use native MCP. Never substitute direct API, curl,
|
||||||
|
tea/gh, Web UI mutation by an LLM, database changes, raw Git push, or token
|
||||||
|
access.
|
||||||
|
|
||||||
## 27. Merge rules
|
## 27. Merge rules
|
||||||
|
|
||||||
Before merge, rerun fresh live checks:
|
Before merge, call `gitea_assess_pr_sync_status` when the PR is approved/open
|
||||||
|
and may be behind base. Only proceed with merge when
|
||||||
|
`recommended_next_action` is `merge_now` and approval remains at the current
|
||||||
|
head. Then rerun fresh live checks:
|
||||||
|
|
||||||
* whoami
|
* whoami
|
||||||
* active profile/runtime
|
* active profile/runtime
|
||||||
@@ -1043,6 +1158,8 @@ Blocked handoffs must say to rerun the full workflow after the blocker clears.
|
|||||||
|
|
||||||
If the blocker is a terminal review mutation already consumed in the current run, the handoff must say that the next run must start from the beginning and must not reuse stale ready/approved state.
|
If the blocker is a terminal review mutation already consumed in the current run, the handoff must say that the next run must start from the beginning and must not reuse stale ready/approved state.
|
||||||
|
|
||||||
|
If the referenced terminal PR is already **merged or closed** on live Gitea, the durable #332 decision lock is **moot**. Do **not** delete session-state files by hand. Use `gitea_cleanup_stale_review_decision_lock` (assess with `apply=false`, then `apply=true` with `expected_terminal_pr` set) from the reviewer profile after identity/profile checks (#594). Cleanup is **forbidden** while that PR is still open.
|
||||||
|
|
||||||
## 30. Final report must be precise
|
## 30. Final report must be precise
|
||||||
|
|
||||||
Include:
|
Include:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ before any issue implementation mutation. Final report schema:
|
|||||||
|
|
||||||
**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this.
|
**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this.
|
||||||
|
|
||||||
|
**Native MCP failure is a hard stop (#695):** Do not import MCP server internals, run offline mutation helpers, or set credential-bypass env vars after a native transport failure. Reconnect the official daemon only.
|
||||||
|
|
||||||
**Default task prompt:**
|
**Default task prompt:**
|
||||||
|
|
||||||
> Find the next eligible issue in this project, work on it only if all gates
|
> Find the next eligible issue in this project, work on it only if all gates
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"""Fail-closed guard against direct pushes to stable branches (#671).
|
||||||
|
|
||||||
|
MCP workflow sessions must never publish to a stable branch (``master``,
|
||||||
|
``main``, ``dev``, ...) directly. Stable-branch updates land only through
|
||||||
|
sanctioned Gitea merge tooling or an explicitly authorized reconciler path.
|
||||||
|
|
||||||
|
Incident origin (#670): a bare direct-to-master commit ``2fa97c26`` landed on
|
||||||
|
``prgs/master`` without PR/review provenance, and a PR #654 merger run
|
||||||
|
attempted ``git push prgs master`` (audited as a no-op, but the *intent* is the
|
||||||
|
hazard). Partial protections already existed (``author_proofs.PROTECTED_BRANCHES``,
|
||||||
|
``root_checkout_guard``, branch-push proofs) but did not detect shell push
|
||||||
|
equivalents, mark the session contaminated after such an attempt, or fail
|
||||||
|
closed on subsequent review/merge/close/completion mutations.
|
||||||
|
|
||||||
|
This module is the detection + contamination-policy core. Like
|
||||||
|
``author_proofs`` and ``root_checkout_guard`` it is **pure**: callers gather
|
||||||
|
the raw facts (the proposed command line, git state, the durable contamination
|
||||||
|
marker) and pass them in, so the same logic serves prompts, MCP gates, and
|
||||||
|
tests. Nothing here performs git or network calls or reads durable state; the
|
||||||
|
server wires these helpers to ``mcp_session_state`` and ``verify_preflight_purity``.
|
||||||
|
|
||||||
|
Design rules honoured (from the #671 acceptance criteria):
|
||||||
|
|
||||||
|
* Detect ``git push <remote> master`` shell equivalents, including refspecs
|
||||||
|
(``HEAD:master``, ``+refs/heads/x:refs/heads/master``), ``--force``,
|
||||||
|
``--dry-run``/no-op intent, and delete refspecs (``:master``).
|
||||||
|
* Never false-block a feature-branch push (``git push prgs fix/issue-671-...``).
|
||||||
|
* Never treat ``git fetch`` / ``git pull --ff-only`` as a push.
|
||||||
|
* Never treat sanctioned ``gitea_merge_pr`` / Gitea API merge as a push.
|
||||||
|
* Fail closed on ambiguous targets that *may* resolve to a stable branch, but
|
||||||
|
surface ambiguity as its own signal rather than silently blocking feature work.
|
||||||
|
* Contamination must not be clearable by the same worker session — only a
|
||||||
|
reconciler (audit) role may clear or bypass the gate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from author_proofs import PROTECTED_BRANCHES
|
||||||
|
|
||||||
|
# Single source of truth for the stable branch set: the same protected set the
|
||||||
|
# author branch-identity proofs already enforce (#177), so the two guards can
|
||||||
|
# never drift apart.
|
||||||
|
STABLE_BRANCHES = PROTECTED_BRANCHES
|
||||||
|
|
||||||
|
# Mutations that must fail closed once the session is contaminated by a direct
|
||||||
|
# stable-branch push attempt (#671 AC4: review, merge, close, completion).
|
||||||
|
# ``comment_issue`` / ``lock_issue`` / ``create_issue`` are deliberately NOT
|
||||||
|
# gated so a contaminated worker can still post the durable audit comment and
|
||||||
|
# hand off. Only a reconciler (audit) role bypasses this set.
|
||||||
|
CONTAMINATION_GATED_TASKS = frozenset({
|
||||||
|
"create_pr",
|
||||||
|
"commit_files",
|
||||||
|
"gitea_commit_files",
|
||||||
|
"close_pr",
|
||||||
|
"close_issue",
|
||||||
|
"review_pr",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"submit_pr_review",
|
||||||
|
"merge_pr",
|
||||||
|
"delete_branch",
|
||||||
|
"complete_issue",
|
||||||
|
})
|
||||||
|
|
||||||
|
CONTAMINATION_KIND = "stable_branch_push"
|
||||||
|
|
||||||
|
REMEDIATION = (
|
||||||
|
"Direct stable-branch publication is forbidden for worker sessions. Stop, "
|
||||||
|
"leave the stable branch untouched, and route the change through sanctioned "
|
||||||
|
"Gitea merge tooling (gitea_merge_pr) or an explicitly authorized reconciler "
|
||||||
|
"audit. This session is workflow-contaminated until a reconciler audits it."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── command tokenising ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Split a compound command line into individual simple commands on shell
|
||||||
|
# separators so ``a && git push prgs master`` is analysed segment by segment.
|
||||||
|
_SEGMENT_SPLIT_RE = re.compile(r"(?:\|\||&&|\||;|\n)")
|
||||||
|
|
||||||
|
_GIT_PUSH_RE = re.compile(r"\bgit\b[\w\s\-]*?\bpush\b", re.IGNORECASE)
|
||||||
|
_GIT_FETCH_RE = re.compile(r"\bgit\b[\w\s\-]*?\b(?:fetch|pull)\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Flags that take a value argument in ``git push`` (so the following token is
|
||||||
|
# consumed as the flag's value, not a refspec).
|
||||||
|
_VALUE_FLAGS = frozenset({
|
||||||
|
"--repo",
|
||||||
|
"-o",
|
||||||
|
"--push-option",
|
||||||
|
"--receive-pack",
|
||||||
|
"--exec",
|
||||||
|
})
|
||||||
|
|
||||||
|
_FORCE_FLAGS = frozenset({"-f", "--force"})
|
||||||
|
_DRY_RUN_FLAGS = frozenset({"-n", "--dry-run"})
|
||||||
|
_DELETE_FLAGS = frozenset({"-d", "--delete"})
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: str | None) -> str:
|
||||||
|
return (value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_ref_prefix(ref: str) -> str:
|
||||||
|
ref = ref.strip()
|
||||||
|
for prefix in ("refs/heads/", "heads/"):
|
||||||
|
if ref.startswith(prefix):
|
||||||
|
return ref[len(prefix):]
|
||||||
|
return ref
|
||||||
|
|
||||||
|
|
||||||
|
def is_stable_ref(ref: str | None) -> bool:
|
||||||
|
"""True when ``ref`` names a configured stable branch."""
|
||||||
|
name = _strip_ref_prefix(_clean(ref))
|
||||||
|
return bool(name) and name in STABLE_BRANCHES
|
||||||
|
|
||||||
|
|
||||||
|
# ── redaction ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_URL_USERINFO_RE = re.compile(r"(https?://)[^/\s:@]+(?::[^/\s@]+)?@", re.IGNORECASE)
|
||||||
|
_SECRET_ASSIGN_RE = re.compile(
|
||||||
|
r"\b((?:GITEA_)?(?:TOKEN|PASSWORD|PASS|PAT|SECRET|API_KEY|AUTH))\s*=\s*\S+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_BEARER_RE = re.compile(r"\b(Bearer|token)\s+[A-Za-z0-9._\-]{8,}", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_command(command: str | None) -> str:
|
||||||
|
"""Strip credentials/URLs from a command line before logging it (#671).
|
||||||
|
|
||||||
|
Redacts URL userinfo (``https://user:tok@host`` → ``https://***@host``),
|
||||||
|
``TOKEN=...`` style assignments, and bearer/token headers. Leaves the
|
||||||
|
structural parts (``git push <remote> master``) intact for audit value.
|
||||||
|
"""
|
||||||
|
text = _clean(command)
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = _URL_USERINFO_RE.sub(r"\1***@", text)
|
||||||
|
text = _SECRET_ASSIGN_RE.sub(lambda m: f"{m.group(1)}=***", text)
|
||||||
|
text = _BEARER_RE.sub(lambda m: f"{m.group(1)} ***", text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ── push classification ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _analyse_push_segment(segment: str) -> dict[str, Any]:
|
||||||
|
"""Classify one ``git push ...`` command segment."""
|
||||||
|
tokens = segment.split()
|
||||||
|
# Drop everything up to and including the ``push`` verb.
|
||||||
|
try:
|
||||||
|
push_idx = next(
|
||||||
|
i for i, tok in enumerate(tokens)
|
||||||
|
if tok.lower() == "push"
|
||||||
|
)
|
||||||
|
except StopIteration:
|
||||||
|
push_idx = -1
|
||||||
|
args = tokens[push_idx + 1:] if push_idx >= 0 else []
|
||||||
|
|
||||||
|
is_force = False
|
||||||
|
is_dry_run = False
|
||||||
|
is_delete = False
|
||||||
|
positionals: list[str] = []
|
||||||
|
|
||||||
|
skip_next = False
|
||||||
|
for tok in args:
|
||||||
|
if skip_next:
|
||||||
|
skip_next = False
|
||||||
|
continue
|
||||||
|
if tok in _FORCE_FLAGS or tok.startswith("--force-with-lease") or tok.startswith("--force-if-includes"):
|
||||||
|
is_force = True
|
||||||
|
continue
|
||||||
|
if tok in _DRY_RUN_FLAGS:
|
||||||
|
is_dry_run = True
|
||||||
|
continue
|
||||||
|
if tok in _DELETE_FLAGS:
|
||||||
|
is_delete = True
|
||||||
|
continue
|
||||||
|
if tok in _VALUE_FLAGS:
|
||||||
|
skip_next = True
|
||||||
|
continue
|
||||||
|
if tok.startswith("--") and "=" in tok:
|
||||||
|
# e.g. --repo=... : self-contained, not a refspec.
|
||||||
|
continue
|
||||||
|
if tok.startswith("-"):
|
||||||
|
# Unknown/combined short flag; ignore for ref detection.
|
||||||
|
continue
|
||||||
|
positionals.append(tok)
|
||||||
|
|
||||||
|
# First positional after push is the remote (when any positional exists);
|
||||||
|
# the rest are refspecs / branch names.
|
||||||
|
remote = positionals[0] if positionals else None
|
||||||
|
refspecs = positionals[1:]
|
||||||
|
|
||||||
|
stable_refs: list[str] = []
|
||||||
|
force_to_stable = False
|
||||||
|
delete_stable = False
|
||||||
|
for spec in refspecs:
|
||||||
|
force_spec = spec.startswith("+")
|
||||||
|
body = spec[1:] if force_spec else spec
|
||||||
|
if ":" in body:
|
||||||
|
src, _, dst = body.partition(":")
|
||||||
|
dest = dst
|
||||||
|
if src == "" and dst:
|
||||||
|
# ``:master`` — delete refspec.
|
||||||
|
is_delete = True
|
||||||
|
else:
|
||||||
|
dest = body
|
||||||
|
if is_stable_ref(dest):
|
||||||
|
stable_refs.append(_strip_ref_prefix(dest))
|
||||||
|
if force_spec or is_force:
|
||||||
|
force_to_stable = True
|
||||||
|
if is_delete:
|
||||||
|
delete_stable = True
|
||||||
|
|
||||||
|
targets_stable = bool(stable_refs)
|
||||||
|
# Ambiguous: ``git push <remote>`` (or bare ``git push``) with no refspec —
|
||||||
|
# resolves to the current branch's upstream, which we cannot see from the
|
||||||
|
# command alone. Flag it, but do not assert stable (would false-block
|
||||||
|
# feature-branch pushes).
|
||||||
|
ambiguous = not refspecs
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_git_push": True,
|
||||||
|
"remote": remote,
|
||||||
|
"refspecs": refspecs,
|
||||||
|
"targets_stable": targets_stable,
|
||||||
|
"stable_refs": stable_refs,
|
||||||
|
"is_force": is_force or force_to_stable,
|
||||||
|
"is_dry_run": is_dry_run,
|
||||||
|
"is_delete": is_delete or delete_stable,
|
||||||
|
"ambiguous_target": ambiguous,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_push_command(command: str | None) -> dict[str, Any]:
|
||||||
|
"""Classify a shell command line for direct stable-branch push intent (#671).
|
||||||
|
|
||||||
|
Returns a dict describing whether the command is a ``git push`` targeting a
|
||||||
|
stable branch. Fetch/pull are never pushes. A sanctioned Gitea merge (which
|
||||||
|
is not a ``git push`` at all) classifies as non-push. Dry-run and no-op
|
||||||
|
pushes still count as *intent* and are reported as contamination
|
||||||
|
(``proves_intent``) so a ``--dry-run`` cannot be used to probe the gate.
|
||||||
|
"""
|
||||||
|
text = _clean(command)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"command": text,
|
||||||
|
"redacted_command": redact_command(text),
|
||||||
|
"is_git_push": False,
|
||||||
|
"is_fetch_or_pull": False,
|
||||||
|
"targets_stable": False,
|
||||||
|
"stable_refs": [],
|
||||||
|
"is_force": False,
|
||||||
|
"is_dry_run": False,
|
||||||
|
"is_delete": False,
|
||||||
|
"ambiguous_target": False,
|
||||||
|
"contamination": False,
|
||||||
|
"proves_intent": False,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
if not text:
|
||||||
|
return result
|
||||||
|
|
||||||
|
stable_refs: list[str] = []
|
||||||
|
saw_push = False
|
||||||
|
for segment in _SEGMENT_SPLIT_RE.split(text):
|
||||||
|
seg = segment.strip()
|
||||||
|
if not seg:
|
||||||
|
continue
|
||||||
|
if _GIT_FETCH_RE.search(seg) and not _GIT_PUSH_RE.search(seg):
|
||||||
|
result["is_fetch_or_pull"] = True
|
||||||
|
continue
|
||||||
|
if not _GIT_PUSH_RE.search(seg):
|
||||||
|
continue
|
||||||
|
saw_push = True
|
||||||
|
analysis = _analyse_push_segment(seg)
|
||||||
|
result["is_git_push"] = True
|
||||||
|
result["is_force"] = result["is_force"] or analysis["is_force"]
|
||||||
|
result["is_dry_run"] = result["is_dry_run"] or analysis["is_dry_run"]
|
||||||
|
result["is_delete"] = result["is_delete"] or analysis["is_delete"]
|
||||||
|
result["ambiguous_target"] = result["ambiguous_target"] or analysis["ambiguous_target"]
|
||||||
|
stable_refs.extend(analysis["stable_refs"])
|
||||||
|
|
||||||
|
result["stable_refs"] = sorted(set(stable_refs))
|
||||||
|
result["targets_stable"] = bool(stable_refs)
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
if result["targets_stable"]:
|
||||||
|
refs = ", ".join(result["stable_refs"])
|
||||||
|
verb = "delete" if result["is_delete"] else ("force-push" if result["is_force"] else "push")
|
||||||
|
qualifier = " (dry-run/no-op still proves intent)" if result["is_dry_run"] else ""
|
||||||
|
reasons.append(
|
||||||
|
f"direct stable-branch {verb} detected targeting: {refs}{qualifier}; "
|
||||||
|
"worker sessions must never publish stable branches directly"
|
||||||
|
)
|
||||||
|
result["contamination"] = True
|
||||||
|
result["proves_intent"] = True
|
||||||
|
elif saw_push and result["ambiguous_target"]:
|
||||||
|
# Bare ``git push`` with no refspec: ambiguous. Report, but do not
|
||||||
|
# contaminate on the command alone — the root-checkout / branch-state
|
||||||
|
# detector resolves whether the current branch is stable.
|
||||||
|
reasons.append(
|
||||||
|
"ambiguous 'git push' with no refspec: resolve the current branch "
|
||||||
|
"before pushing; if it is a stable branch this is forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
result["reasons"] = reasons
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def detect_stable_push(commands: str | Iterable[str] | None) -> dict[str, Any]:
|
||||||
|
"""Classify one command or an iterable of commands; contamination if any hit."""
|
||||||
|
if commands is None:
|
||||||
|
return classify_push_command(None)
|
||||||
|
if isinstance(commands, str):
|
||||||
|
return classify_push_command(commands)
|
||||||
|
worst: dict[str, Any] | None = None
|
||||||
|
for cmd in commands:
|
||||||
|
res = classify_push_command(cmd)
|
||||||
|
if res["contamination"]:
|
||||||
|
return res
|
||||||
|
if worst is None or (res["is_git_push"] and not worst["is_git_push"]):
|
||||||
|
worst = res
|
||||||
|
return worst or classify_push_command(None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── root/control checkout local-commit detection ──────────────────────────────
|
||||||
|
|
||||||
|
def assess_root_checkout_local_commit(
|
||||||
|
*,
|
||||||
|
current_branch: str | None,
|
||||||
|
head_sha: str | None,
|
||||||
|
remote_master_sha: str | None,
|
||||||
|
is_under_branches: bool,
|
||||||
|
ahead_count: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Detect a local commit on the root/control checkout not on a feature branch.
|
||||||
|
|
||||||
|
#671 AC2. An isolated ``branches/...`` worktree is exempt (that is where
|
||||||
|
feature work belongs). On the control checkout, a commit is illegitimate
|
||||||
|
when the checkout sits on a stable branch (or detached) and its HEAD has
|
||||||
|
advanced past the tracking ``prgs/master`` — i.e. a commit was made that is
|
||||||
|
not carried by an issue feature branch/PR.
|
||||||
|
|
||||||
|
Positive evidence only: missing state reports ``unknown`` rather than
|
||||||
|
asserting contamination, but an unreadable *branch* on the control checkout
|
||||||
|
(detached HEAD) with an advanced HEAD is still flagged.
|
||||||
|
"""
|
||||||
|
branch = _clean(current_branch)
|
||||||
|
head = _clean(head_sha).lower()
|
||||||
|
master = _clean(remote_master_sha).lower()
|
||||||
|
|
||||||
|
if is_under_branches:
|
||||||
|
return {
|
||||||
|
"contamination": False,
|
||||||
|
"unknown": False,
|
||||||
|
"reasons": [],
|
||||||
|
"detail": "isolated branches/ worktree — feature commits are expected here",
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
unknown = False
|
||||||
|
|
||||||
|
on_stable = (not branch) or (branch in STABLE_BRANCHES)
|
||||||
|
if not on_stable:
|
||||||
|
# Control checkout is on some non-stable branch: out of scope for this
|
||||||
|
# detector (root_checkout_guard #475 handles that contamination class).
|
||||||
|
return {
|
||||||
|
"contamination": False,
|
||||||
|
"unknown": False,
|
||||||
|
"reasons": [],
|
||||||
|
"detail": f"control checkout on non-stable branch '{branch}' — not this detector's class",
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced = False
|
||||||
|
if ahead_count is not None and ahead_count > 0:
|
||||||
|
advanced = True
|
||||||
|
if head and master and head != master:
|
||||||
|
advanced = True
|
||||||
|
if (not head or not master) and ahead_count is None:
|
||||||
|
unknown = True
|
||||||
|
|
||||||
|
if advanced:
|
||||||
|
where = f"branch '{branch}'" if branch else "detached HEAD"
|
||||||
|
reasons.append(
|
||||||
|
f"control checkout ({where}) has local commits not on an issue "
|
||||||
|
"feature branch (HEAD advanced past prgs/master); worker sessions "
|
||||||
|
"must commit only on issue branches under branches/"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"contamination": bool(reasons),
|
||||||
|
"unknown": unknown and not reasons,
|
||||||
|
"reasons": reasons,
|
||||||
|
"current_branch": branch or None,
|
||||||
|
"head_sha": head or None,
|
||||||
|
"remote_master_sha": master or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── contamination record + gate ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_contamination_record(
|
||||||
|
*,
|
||||||
|
reason_class: str,
|
||||||
|
command_redacted: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
remote: str | None = None,
|
||||||
|
ref: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
detail: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the durable contamination marker payload (redacted, audit-safe).
|
||||||
|
|
||||||
|
``reason_class`` is one of ``stable_branch_push`` / ``root_checkout_commit``.
|
||||||
|
The command is stored already-redacted; callers pass the raw line through
|
||||||
|
:func:`redact_command` (or this builder redacts a raw ``command`` for them).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"kind": CONTAMINATION_KIND,
|
||||||
|
"reason_class": _clean(reason_class) or "stable_branch_push",
|
||||||
|
"command_summary": redact_command(command_redacted),
|
||||||
|
"session_id": _clean(session_id) or None,
|
||||||
|
"remote": _clean(remote) or None,
|
||||||
|
"ref": _clean(ref) or None,
|
||||||
|
"role": _clean(role) or None,
|
||||||
|
"detail": _clean(detail) or None,
|
||||||
|
"cleared_by_reconciler": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_contamination_gate(
|
||||||
|
marker: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
task: str | None,
|
||||||
|
actual_role: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail closed on gated mutations while a contamination marker is live (#671 AC4).
|
||||||
|
|
||||||
|
* No marker → allowed.
|
||||||
|
* Reconciler (audit) role → allowed (the sanctioned path to inspect/clear).
|
||||||
|
* Marker present + ``task`` in :data:`CONTAMINATION_GATED_TASKS` → blocked.
|
||||||
|
* Marker present + non-gated task (e.g. ``comment_issue``) → allowed, so the
|
||||||
|
worker can still post the durable audit/handoff comment.
|
||||||
|
"""
|
||||||
|
if not marker or marker.get("cleared_by_reconciler"):
|
||||||
|
return {"block": False, "reasons": [], "task": task}
|
||||||
|
|
||||||
|
role = _clean(actual_role).lower()
|
||||||
|
if role == "reconciler":
|
||||||
|
return {
|
||||||
|
"block": False,
|
||||||
|
"reasons": [],
|
||||||
|
"task": task,
|
||||||
|
"detail": "reconciler audit path is exempt from the contamination gate",
|
||||||
|
}
|
||||||
|
|
||||||
|
task_name = _clean(task)
|
||||||
|
if task_name and task_name in CONTAMINATION_GATED_TASKS:
|
||||||
|
summary = marker.get("command_summary") or marker.get("detail") or "(no summary)"
|
||||||
|
reason_class = marker.get("reason_class") or "stable_branch_push"
|
||||||
|
return {
|
||||||
|
"block": True,
|
||||||
|
"reasons": [
|
||||||
|
f"session is workflow-contaminated ({reason_class}): {summary}. "
|
||||||
|
f"'{task_name}' is blocked until a reconciler audits and clears "
|
||||||
|
"the contamination. " + REMEDIATION
|
||||||
|
],
|
||||||
|
"task": task_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"block": False, "reasons": [], "task": task_name or None}
|
||||||
|
|
||||||
|
|
||||||
|
def format_contamination_gate_error(gate: dict[str, Any]) -> str:
|
||||||
|
"""Single RuntimeError message for MCP mutation gates."""
|
||||||
|
reasons = "; ".join(gate.get("reasons") or ["session workflow-contaminated"])
|
||||||
|
return f"Stable-branch contamination gate (#671): {reasons}"
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
"""Stable-control vs dev/test runtime classification and mutation gates (#615).
|
||||||
|
|
||||||
|
``docs/architecture/mcp-stable-control-runtime-policy-adr.md`` states the policy:
|
||||||
|
real Gitea mutations may only be performed by the **stable control runtime**,
|
||||||
|
while MCP server development happens in isolated ``branches/`` worktrees and
|
||||||
|
optional dev/test runtimes. The ADR alone is not enforcement — a daemon
|
||||||
|
relaunched from a feature worktree still holds production credentials and will
|
||||||
|
happily mutate production issues.
|
||||||
|
|
||||||
|
This module supplies the runtime half of that policy:
|
||||||
|
|
||||||
|
* :func:`classify_runtime_mode` decides whether the running process is a
|
||||||
|
``stable-control``, ``dev-test``, or ``unknown`` runtime.
|
||||||
|
* :func:`build_runtime_report` collects the reporting fields the ADR requires
|
||||||
|
(mode, SHA, branch, checkout path, process root, workspace, binding, dirty
|
||||||
|
files, alignment, and whether real mutations are allowed).
|
||||||
|
* :func:`assess_runtime_mutation_gate` turns that report into a fail-closed
|
||||||
|
mutation gate.
|
||||||
|
* The post-transport-flap helpers keep namespace re-proving **per namespace**,
|
||||||
|
so proving the author namespace never implies the reviewer, merger, or
|
||||||
|
reconciler namespace is callable.
|
||||||
|
|
||||||
|
Every assessment is pure: callers inject the observed facts, so the logic is
|
||||||
|
unit-testable without a git checkout or a live daemon. Only the thin
|
||||||
|
:func:`observe_runtime` reader touches the filesystem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Operator declaration of the runtime this process is. An explicit, valid
|
||||||
|
# declaration wins over inference — an operator running a packaged release
|
||||||
|
# layout may have no git checkout to infer from.
|
||||||
|
ENV_RUNTIME_MODE = "GITEA_MCP_RUNTIME_MODE"
|
||||||
|
# Escape hatch mirroring the #420 parity gate: disables enforcement only.
|
||||||
|
ENV_DISABLE = "GITEA_MCP_DISABLE_RUNTIME_MODE_GATE"
|
||||||
|
|
||||||
|
RUNTIME_MODE_STABLE = "stable-control"
|
||||||
|
RUNTIME_MODE_DEV_TEST = "dev-test"
|
||||||
|
RUNTIME_MODE_UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
VALID_RUNTIME_MODES = frozenset(
|
||||||
|
{RUNTIME_MODE_STABLE, RUNTIME_MODE_DEV_TEST, RUNTIME_MODE_UNKNOWN}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Branches a stable control checkout is allowed to sit on. Anything else is a
|
||||||
|
# development checkout by definition (the global worktree rule keeps the
|
||||||
|
# control checkout on a stable branch).
|
||||||
|
STABLE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||||
|
|
||||||
|
# Path segment that marks an isolated development worktree.
|
||||||
|
DEV_WORKTREE_SEGMENT = "branches"
|
||||||
|
|
||||||
|
BLOCKER_DEV_TEST_PRODUCTION = "dev_test_runtime_targets_production"
|
||||||
|
BLOCKER_UNKNOWN_RUNTIME = "unknown_runtime_mode"
|
||||||
|
BLOCKER_DIRTY_STABLE_RUNTIME = "dirty_stable_runtime_checkout"
|
||||||
|
BLOCKER_DEV_WORKTREE_LAUNCH = "runtime_launched_from_dev_worktree"
|
||||||
|
BLOCKER_UNSAFE_ALIGNMENT = "unsafe_process_root_workspace_alignment"
|
||||||
|
BLOCKER_NAMESPACE_NOT_REPROVEN = "namespace_not_reproven_after_flap"
|
||||||
|
|
||||||
|
# Namespaces that must each be re-proven independently after a transport flap.
|
||||||
|
WORKFLOW_NAMESPACES = ("author", "reviewer", "merger", "reconciler")
|
||||||
|
|
||||||
|
|
||||||
|
def gate_disabled() -> bool:
|
||||||
|
"""Whether the runtime-mode gate is disabled by env escape hatch."""
|
||||||
|
return bool((os.environ.get(ENV_DISABLE) or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def declared_runtime_mode() -> str | None:
|
||||||
|
"""Return the operator-declared runtime mode, if a valid one is set.
|
||||||
|
|
||||||
|
An unset or unrecognised value returns ``None`` so classification falls
|
||||||
|
back to inference rather than trusting a typo.
|
||||||
|
"""
|
||||||
|
value = (os.environ.get(ENV_RUNTIME_MODE) or "").strip().lower()
|
||||||
|
if value in VALID_RUNTIME_MODES:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _path_segments(path: str) -> list[str]:
|
||||||
|
return [seg for seg in os.path.normpath(path).split(os.sep) if seg]
|
||||||
|
|
||||||
|
|
||||||
|
def launched_from_dev_worktree(process_root: str | None) -> bool:
|
||||||
|
"""Whether *process_root* sits inside a ``branches/`` development worktree."""
|
||||||
|
if not process_root:
|
||||||
|
return False
|
||||||
|
return DEV_WORKTREE_SEGMENT in _path_segments(process_root)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_runtime_mode(
|
||||||
|
*,
|
||||||
|
process_root: str | None,
|
||||||
|
checkout_branch: str | None,
|
||||||
|
is_git_checkout: bool = True,
|
||||||
|
declared_mode: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Classify the runtime this process is serving from.
|
||||||
|
|
||||||
|
``declared_mode`` (normally :func:`declared_runtime_mode`) is authoritative
|
||||||
|
when supplied and valid. Otherwise the mode is inferred:
|
||||||
|
|
||||||
|
* no resolvable root, or a root that is not a git checkout → ``unknown``
|
||||||
|
(a packaged deployment must declare its mode explicitly);
|
||||||
|
* a root inside a ``branches/`` worktree → ``dev-test``;
|
||||||
|
* an unreadable branch → ``unknown``;
|
||||||
|
* a stable branch (``master``/``main``/``dev``) → ``stable-control``;
|
||||||
|
* any other branch → ``dev-test``.
|
||||||
|
|
||||||
|
Returns the mode plus the discriminating facts and human-readable reasons.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
dev_worktree = launched_from_dev_worktree(process_root)
|
||||||
|
|
||||||
|
if declared_mode in VALID_RUNTIME_MODES:
|
||||||
|
reasons.append(
|
||||||
|
f"runtime mode declared by operator via {ENV_RUNTIME_MODE}="
|
||||||
|
f"{declared_mode}"
|
||||||
|
)
|
||||||
|
return _mode_result(declared_mode, dev_worktree, True, reasons)
|
||||||
|
|
||||||
|
if not process_root:
|
||||||
|
reasons.append(
|
||||||
|
"runtime process root could not be resolved; runtime mode is "
|
||||||
|
"indeterminate"
|
||||||
|
)
|
||||||
|
return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)
|
||||||
|
|
||||||
|
if not is_git_checkout:
|
||||||
|
reasons.append(
|
||||||
|
f"runtime process root '{process_root}' is not a git checkout and "
|
||||||
|
f"no {ENV_RUNTIME_MODE} declaration was supplied"
|
||||||
|
)
|
||||||
|
return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)
|
||||||
|
|
||||||
|
if dev_worktree:
|
||||||
|
reasons.append(
|
||||||
|
f"runtime was launched from development worktree '{process_root}' "
|
||||||
|
f"(inside '{DEV_WORKTREE_SEGMENT}/')"
|
||||||
|
)
|
||||||
|
return _mode_result(RUNTIME_MODE_DEV_TEST, True, False, reasons)
|
||||||
|
|
||||||
|
branch = (checkout_branch or "").strip()
|
||||||
|
if not branch:
|
||||||
|
reasons.append(
|
||||||
|
f"runtime checkout branch at '{process_root}' could not be read "
|
||||||
|
f"(detached HEAD or unreadable); runtime mode is indeterminate"
|
||||||
|
)
|
||||||
|
return _mode_result(RUNTIME_MODE_UNKNOWN, False, False, reasons)
|
||||||
|
|
||||||
|
if branch in STABLE_BRANCHES:
|
||||||
|
reasons.append(
|
||||||
|
f"runtime checkout '{process_root}' is on stable branch '{branch}'"
|
||||||
|
)
|
||||||
|
return _mode_result(RUNTIME_MODE_STABLE, False, False, reasons)
|
||||||
|
|
||||||
|
reasons.append(
|
||||||
|
f"runtime checkout '{process_root}' is on development branch "
|
||||||
|
f"'{branch}', not a stable branch "
|
||||||
|
f"({', '.join(sorted(STABLE_BRANCHES))})"
|
||||||
|
)
|
||||||
|
return _mode_result(RUNTIME_MODE_DEV_TEST, False, False, reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def _mode_result(mode, dev_worktree, declared, reasons) -> dict:
|
||||||
|
return {
|
||||||
|
"runtime_mode": mode,
|
||||||
|
"dev_worktree_launched": bool(dev_worktree),
|
||||||
|
"declared": bool(declared),
|
||||||
|
"reasons": list(reasons),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_report(
|
||||||
|
*,
|
||||||
|
process_root: str | None,
|
||||||
|
checkout_branch: str | None,
|
||||||
|
runtime_head: str | None,
|
||||||
|
active_task_workspace: str | None = None,
|
||||||
|
canonical_repository_root: str | None = None,
|
||||||
|
repository_slug: str | None = None,
|
||||||
|
profile: str | None = None,
|
||||||
|
authenticated_identity: str | None = None,
|
||||||
|
dirty_files: list[str] | tuple[str, ...] | None = None,
|
||||||
|
workspace_roots_aligned: bool | None = None,
|
||||||
|
is_git_checkout: bool = True,
|
||||||
|
declared_mode: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Build the ADR-required runtime report (#615 acceptance criterion 6).
|
||||||
|
|
||||||
|
``real_mutations_allowed`` is the summary bit: it is true only when the
|
||||||
|
corresponding mutation gate finds nothing to block on for a production
|
||||||
|
target.
|
||||||
|
"""
|
||||||
|
classification = classify_runtime_mode(
|
||||||
|
process_root=process_root,
|
||||||
|
checkout_branch=checkout_branch,
|
||||||
|
is_git_checkout=is_git_checkout,
|
||||||
|
declared_mode=declared_mode,
|
||||||
|
)
|
||||||
|
report = {
|
||||||
|
"runtime_mode": classification["runtime_mode"],
|
||||||
|
"runtime_mode_declared": classification["declared"],
|
||||||
|
"runtime_mode_reasons": classification["reasons"],
|
||||||
|
"dev_worktree_launched": classification["dev_worktree_launched"],
|
||||||
|
"runtime_git_sha": runtime_head,
|
||||||
|
"runtime_branch": checkout_branch,
|
||||||
|
"runtime_checkout_path": process_root,
|
||||||
|
"mcp_process_root": process_root,
|
||||||
|
"active_task_workspace": active_task_workspace,
|
||||||
|
"canonical_repository_root": canonical_repository_root,
|
||||||
|
"repository_slug": repository_slug,
|
||||||
|
"profile": profile,
|
||||||
|
"authenticated_identity": authenticated_identity,
|
||||||
|
"dirty_files": sorted(dirty_files or []),
|
||||||
|
"workspace_roots_aligned": workspace_roots_aligned,
|
||||||
|
"gate_enforced": not gate_disabled(),
|
||||||
|
}
|
||||||
|
gate = assess_runtime_mutation_gate(report)
|
||||||
|
report["real_mutations_allowed"] = not gate["block"]
|
||||||
|
report["mutation_block_reasons"] = gate["reasons"]
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def assess_runtime_mutation_gate(
|
||||||
|
report: dict,
|
||||||
|
*,
|
||||||
|
target_is_production: bool = True,
|
||||||
|
namespace: str | None = None,
|
||||||
|
namespace_reproof: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Fail-closed mutation gate for the runtime a mutation would execute in.
|
||||||
|
|
||||||
|
Blocks when (acceptance criterion 7):
|
||||||
|
|
||||||
|
* the runtime is ``dev-test`` and the mutation targets the production
|
||||||
|
repository;
|
||||||
|
* the runtime mode is ``unknown``;
|
||||||
|
* the stable runtime checkout is dirty;
|
||||||
|
* the runtime was launched from a development worktree;
|
||||||
|
* process-root / workspace alignment is unsafe;
|
||||||
|
* (criterion 8) the namespace has not been re-proven since a transport flap.
|
||||||
|
|
||||||
|
The disabled escape hatch never blocks; the caller decides read-vs-mutate
|
||||||
|
before calling.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
blockers: list[str] = []
|
||||||
|
|
||||||
|
if gate_disabled():
|
||||||
|
return _gate_result(False, blockers, reasons, disabled=True)
|
||||||
|
|
||||||
|
mode = (report or {}).get("runtime_mode")
|
||||||
|
|
||||||
|
if mode == RUNTIME_MODE_UNKNOWN:
|
||||||
|
blockers.append(BLOCKER_UNKNOWN_RUNTIME)
|
||||||
|
reasons.append(
|
||||||
|
"runtime mode is 'unknown'; a runtime that cannot prove it is the "
|
||||||
|
f"stable control runtime must not mutate production (declare "
|
||||||
|
f"{ENV_RUNTIME_MODE} or run from a stable checkout)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == RUNTIME_MODE_DEV_TEST and target_is_production:
|
||||||
|
blockers.append(BLOCKER_DEV_TEST_PRODUCTION)
|
||||||
|
reasons.append(
|
||||||
|
"runtime mode is 'dev-test' and the mutation targets the "
|
||||||
|
"production repository; dev/test runtimes must not mutate real "
|
||||||
|
"issues or PRs (ADR: stable control runtime vs dev runtime)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if report.get("dev_worktree_launched") and target_is_production:
|
||||||
|
blockers.append(BLOCKER_DEV_WORKTREE_LAUNCH)
|
||||||
|
reasons.append(
|
||||||
|
f"runtime was launched from a '{DEV_WORKTREE_SEGMENT}/' development "
|
||||||
|
f"worktree ('{report.get('mcp_process_root')}'); production "
|
||||||
|
f"mutations require the promoted stable control runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
dirty = list(report.get("dirty_files") or [])
|
||||||
|
if mode == RUNTIME_MODE_STABLE and dirty:
|
||||||
|
blockers.append(BLOCKER_DIRTY_STABLE_RUNTIME)
|
||||||
|
reasons.append(
|
||||||
|
"stable control runtime checkout is dirty "
|
||||||
|
f"({len(dirty)} file(s): {', '.join(dirty[:5])}"
|
||||||
|
f"{'...' if len(dirty) > 5 else ''}); the control plane must run "
|
||||||
|
"promoted, unmodified code"
|
||||||
|
)
|
||||||
|
|
||||||
|
if report.get("workspace_roots_aligned") is False:
|
||||||
|
blockers.append(BLOCKER_UNSAFE_ALIGNMENT)
|
||||||
|
reasons.append(
|
||||||
|
"process-root / active-workspace alignment is unsafe; the runtime "
|
||||||
|
"and the task workspace disagree about which checkout is being "
|
||||||
|
"mutated"
|
||||||
|
)
|
||||||
|
|
||||||
|
if namespace:
|
||||||
|
reproof = assess_namespace_reproof(namespace_reproof, namespace)
|
||||||
|
if reproof["reproof_required"] and not reproof["proven"]:
|
||||||
|
blockers.append(BLOCKER_NAMESPACE_NOT_REPROVEN)
|
||||||
|
reasons.extend(reproof["reasons"])
|
||||||
|
|
||||||
|
return _gate_result(bool(blockers), blockers, reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def _gate_result(block, blockers, reasons, *, disabled=False) -> dict:
|
||||||
|
return {
|
||||||
|
"block": bool(block),
|
||||||
|
"blocker_kinds": list(blockers),
|
||||||
|
"blocker_kind": blockers[0] if blockers else None,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"gate_disabled": bool(disabled),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_block_reasons(
|
||||||
|
report: dict,
|
||||||
|
*,
|
||||||
|
target_is_production: bool = True,
|
||||||
|
namespace: str | None = None,
|
||||||
|
namespace_reproof: dict | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Block reasons for a mutation gate (empty when the mutation may proceed)."""
|
||||||
|
gate = assess_runtime_mutation_gate(
|
||||||
|
report,
|
||||||
|
target_is_production=target_is_production,
|
||||||
|
namespace=namespace,
|
||||||
|
namespace_reproof=namespace_reproof,
|
||||||
|
)
|
||||||
|
return gate["reasons"]
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_report_payload(report: dict, gate: dict | None = None) -> dict:
|
||||||
|
"""Structured recovery payload for permission-block responses."""
|
||||||
|
gate = gate or assess_runtime_mutation_gate(report)
|
||||||
|
return {
|
||||||
|
"kind": "runtime_mode_block",
|
||||||
|
"runtime_mode": report.get("runtime_mode"),
|
||||||
|
"runtime_git_sha": report.get("runtime_git_sha"),
|
||||||
|
"runtime_branch": report.get("runtime_branch"),
|
||||||
|
"runtime_checkout_path": report.get("runtime_checkout_path"),
|
||||||
|
"blocker_kind": gate.get("blocker_kind"),
|
||||||
|
"blocker_kinds": list(gate.get("blocker_kinds") or []),
|
||||||
|
"reasons": list(gate.get("reasons") or []),
|
||||||
|
"recovery": [
|
||||||
|
"Real workflow mutations run only on the promoted stable control "
|
||||||
|
"runtime (see docs/architecture/"
|
||||||
|
"mcp-stable-control-runtime-policy-adr.md).",
|
||||||
|
"Operator action: promote the intended revision into the stable "
|
||||||
|
"runtime and reload it — see "
|
||||||
|
"docs/stable-runtime-promotion-runbook.md.",
|
||||||
|
"Normal author/reviewer/merger/reconciler sessions must not kill, "
|
||||||
|
"restart, or relaunch the MCP server themselves.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_runtime_mode(report: dict) -> str:
|
||||||
|
"""One-line human summary for logs / runtime context."""
|
||||||
|
mode = report.get("runtime_mode") or RUNTIME_MODE_UNKNOWN
|
||||||
|
sha = report.get("runtime_git_sha")
|
||||||
|
branch = report.get("runtime_branch") or "unknown-branch"
|
||||||
|
short = sha[:12] if sha else "unknown-sha"
|
||||||
|
suffix = (
|
||||||
|
"" if report.get("real_mutations_allowed", True) else " (mutations blocked)"
|
||||||
|
)
|
||||||
|
return f"{mode} at {short} on {branch}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post-transport-flap namespace re-proving (#615 acceptance criterion 8)
|
||||||
|
#
|
||||||
|
# A transport flap (#584) drops every gitea-* namespace at once. Proving the
|
||||||
|
# author namespace afterwards says nothing about the reviewer, merger, or
|
||||||
|
# reconciler namespace, so proof is tracked per namespace and a flap
|
||||||
|
# invalidates all of them.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
REQUIRED_NAMESPACE_PROOF_STEPS = (
|
||||||
|
"whoami",
|
||||||
|
"runtime_context",
|
||||||
|
"capability_resolved",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def new_reproof_state() -> dict:
|
||||||
|
"""Return an empty post-flap re-proving state."""
|
||||||
|
return {"flap_at": None, "namespaces": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def record_transport_flap(state: dict | None, *, at: str) -> dict:
|
||||||
|
"""Record a transport flap: every namespace must be re-proven after *at*.
|
||||||
|
|
||||||
|
Existing per-namespace proofs are kept for audit but no longer satisfy the
|
||||||
|
gate, because they were recorded before the flap.
|
||||||
|
"""
|
||||||
|
result = dict(state or new_reproof_state())
|
||||||
|
result["flap_at"] = at
|
||||||
|
result["namespaces"] = dict(result.get("namespaces") or {})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def record_namespace_proof(
|
||||||
|
state: dict | None,
|
||||||
|
namespace: str,
|
||||||
|
*,
|
||||||
|
at: str,
|
||||||
|
whoami: bool = False,
|
||||||
|
runtime_context: bool = False,
|
||||||
|
capability_resolved: bool = False,
|
||||||
|
stale_runtime_reported: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Record proof steps completed for exactly one namespace.
|
||||||
|
|
||||||
|
A namespace whose proof reported a reconnect/restart/stale-runtime gate is
|
||||||
|
never counted as proven, regardless of which steps ran.
|
||||||
|
"""
|
||||||
|
result = dict(state or new_reproof_state())
|
||||||
|
namespaces = dict(result.get("namespaces") or {})
|
||||||
|
namespaces[(namespace or "").strip()] = {
|
||||||
|
"at": at,
|
||||||
|
"whoami": bool(whoami),
|
||||||
|
"runtime_context": bool(runtime_context),
|
||||||
|
"capability_resolved": bool(capability_resolved),
|
||||||
|
"stale_runtime_reported": bool(stale_runtime_reported),
|
||||||
|
}
|
||||||
|
result["namespaces"] = namespaces
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_namespace_reproof(state: dict | None, namespace: str) -> dict:
|
||||||
|
"""Whether *namespace* is re-proven after the most recent transport flap.
|
||||||
|
|
||||||
|
``reproof_required`` is false when no flap has been recorded — this gate
|
||||||
|
only speaks to post-flap proof and never invents a requirement.
|
||||||
|
"""
|
||||||
|
ns = (namespace or "").strip()
|
||||||
|
store = state or {}
|
||||||
|
flap_at = store.get("flap_at")
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if not flap_at:
|
||||||
|
return {
|
||||||
|
"namespace": ns,
|
||||||
|
"reproof_required": False,
|
||||||
|
"proven": True,
|
||||||
|
"flap_at": None,
|
||||||
|
"proof_at": None,
|
||||||
|
"missing_steps": [],
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = (store.get("namespaces") or {}).get(ns)
|
||||||
|
if not entry:
|
||||||
|
reasons.append(
|
||||||
|
f"MCP namespace '{ns}' has not been re-proven since the transport "
|
||||||
|
f"flap at {flap_at}; run whoami, runtime context, and capability "
|
||||||
|
f"resolve for '{ns}' itself (proof of another namespace does not "
|
||||||
|
f"transfer)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"namespace": ns,
|
||||||
|
"reproof_required": True,
|
||||||
|
"proven": False,
|
||||||
|
"flap_at": flap_at,
|
||||||
|
"proof_at": None,
|
||||||
|
"missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
proof_at = entry.get("at")
|
||||||
|
if proof_at is not None and str(proof_at) < str(flap_at):
|
||||||
|
reasons.append(
|
||||||
|
f"MCP namespace '{ns}' proof at {proof_at} predates the transport "
|
||||||
|
f"flap at {flap_at}; re-prove the namespace before mutating"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"namespace": ns,
|
||||||
|
"reproof_required": True,
|
||||||
|
"proven": False,
|
||||||
|
"flap_at": flap_at,
|
||||||
|
"proof_at": proof_at,
|
||||||
|
"missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = [step for step in REQUIRED_NAMESPACE_PROOF_STEPS if not entry.get(step)]
|
||||||
|
if missing:
|
||||||
|
reasons.append(
|
||||||
|
f"MCP namespace '{ns}' post-flap proof is incomplete; missing: "
|
||||||
|
f"{', '.join(missing)}"
|
||||||
|
)
|
||||||
|
if entry.get("stale_runtime_reported"):
|
||||||
|
missing = missing or ["stale_runtime_clear"]
|
||||||
|
reasons.append(
|
||||||
|
f"MCP namespace '{ns}' reported a reconnect/restart/stale-runtime "
|
||||||
|
f"gate during re-proving; mutation stays blocked until the "
|
||||||
|
f"namespace reconnects cleanly"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"namespace": ns,
|
||||||
|
"reproof_required": True,
|
||||||
|
"proven": not missing,
|
||||||
|
"flap_at": flap_at,
|
||||||
|
"proof_at": proof_at,
|
||||||
|
"missing_steps": list(missing),
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def unproven_namespaces(
|
||||||
|
state: dict | None, namespaces=WORKFLOW_NAMESPACES
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the namespaces still requiring post-flap re-proving."""
|
||||||
|
out = []
|
||||||
|
for ns in namespaces:
|
||||||
|
assessment = assess_namespace_reproof(state, ns)
|
||||||
|
if assessment["reproof_required"] and not assessment["proven"]:
|
||||||
|
out.append(ns)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Promotion records (#615 acceptance criterion 4 / 10)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PROMOTION_REQUIRED_FIELDS = (
|
||||||
|
"previous_runtime_sha",
|
||||||
|
"promoted_runtime_sha",
|
||||||
|
"source_branch",
|
||||||
|
"source_pr",
|
||||||
|
"restart_method",
|
||||||
|
"health_check_proof",
|
||||||
|
"identity_proof",
|
||||||
|
"profile_proof",
|
||||||
|
"workspace_proof",
|
||||||
|
"mutation_capability_proof",
|
||||||
|
"rollback_instructions",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_promotion_record(record: dict | None) -> dict:
|
||||||
|
"""Validate an operator promotion record against the ADR checklist.
|
||||||
|
|
||||||
|
A promotion that does not record both the previous and the promoted SHA is
|
||||||
|
not a promotion — it is an undocumented restart.
|
||||||
|
"""
|
||||||
|
data = record or {}
|
||||||
|
missing = [
|
||||||
|
field
|
||||||
|
for field in PROMOTION_REQUIRED_FIELDS
|
||||||
|
if not str(data.get(field) or "").strip()
|
||||||
|
]
|
||||||
|
reasons = []
|
||||||
|
if missing:
|
||||||
|
reasons.append(
|
||||||
|
"promotion record is incomplete; missing: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
previous = str(data.get("previous_runtime_sha") or "").strip()
|
||||||
|
promoted = str(data.get("promoted_runtime_sha") or "").strip()
|
||||||
|
if previous and promoted and previous == promoted:
|
||||||
|
reasons.append(
|
||||||
|
"promotion record lists the same previous and promoted SHA "
|
||||||
|
f"({previous[:12]}); nothing was promoted"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"valid": not reasons,
|
||||||
|
"missing_fields": missing,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filesystem observation (the only impure helper)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _git_capture(root: str, *args: str) -> str | None:
|
||||||
|
if not root:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", root, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if res.returncode != 0:
|
||||||
|
return None
|
||||||
|
return (res.stdout or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def observe_dirty_files(process_root: str | None) -> list[str]:
|
||||||
|
"""Read the live dirty-file list at *process_root*.
|
||||||
|
|
||||||
|
Split out from :func:`observe_runtime` because dirtiness is the one runtime
|
||||||
|
fact that legitimately changes during a process lifetime. The mutation gate
|
||||||
|
must re-read it per call rather than trust a startup snapshot, or a checkout
|
||||||
|
that goes dirty after the snapshot is never blocked again (#615).
|
||||||
|
"""
|
||||||
|
if not process_root:
|
||||||
|
return []
|
||||||
|
porcelain = _git_capture(process_root, "status", "--porcelain") or ""
|
||||||
|
return [line[3:].strip() for line in porcelain.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def observe_runtime(process_root: str | None) -> dict:
|
||||||
|
"""Read the runtime facts classification needs from *process_root*.
|
||||||
|
|
||||||
|
Returns ``checkout_branch``, ``runtime_head``, ``is_git_checkout``, and
|
||||||
|
``dirty_files``. Every read failure degrades to ``None``/empty rather than
|
||||||
|
raising, so a runtime that cannot be inspected classifies as ``unknown``
|
||||||
|
instead of crashing the caller.
|
||||||
|
"""
|
||||||
|
empty = {
|
||||||
|
"checkout_branch": None,
|
||||||
|
"runtime_head": None,
|
||||||
|
"is_git_checkout": False,
|
||||||
|
"dirty_files": [],
|
||||||
|
}
|
||||||
|
if not process_root:
|
||||||
|
return empty
|
||||||
|
if not _git_capture(process_root, "rev-parse", "--show-toplevel"):
|
||||||
|
return empty
|
||||||
|
branch = _git_capture(process_root, "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
if branch == "HEAD": # detached HEAD has no branch name
|
||||||
|
branch = None
|
||||||
|
dirty = observe_dirty_files(process_root)
|
||||||
|
return {
|
||||||
|
"checkout_branch": branch,
|
||||||
|
"runtime_head": _git_capture(process_root, "rev-parse", "HEAD"),
|
||||||
|
"is_git_checkout": True,
|
||||||
|
"dirty_files": dirty,
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Sanctioned recovery for stale env-bound task workspace bindings (#702).
|
||||||
|
|
||||||
|
When the MCP daemon dies from an unhandled exception it never runs teardown,
|
||||||
|
and the auto-reconnected daemon inherits ``GITEA_ACTIVE_WORKTREE`` from its
|
||||||
|
parent environment. That inherited value can permanently bind the fresh
|
||||||
|
session to a prior task's worktree (the PR #701 / ``review-pr-654`` incident).
|
||||||
|
|
||||||
|
This module classifies the active env binding against live session evidence
|
||||||
|
and produces a fail-closed recovery plan. Only two situations permit the
|
||||||
|
daemon to clear the binding on its own:
|
||||||
|
|
||||||
|
- the bound path no longer exists on disk (``provably_stale_missing_path``)
|
||||||
|
- the binding was inherited at daemon boot and a *sanctioned* in-session
|
||||||
|
reviewer/merger lease later bound a different worktree
|
||||||
|
(``superseded_by_session_lease``)
|
||||||
|
|
||||||
|
Everything else is surfaced (``unverified_inherited``) and left for the
|
||||||
|
managed reconnect path — never cleared silently, never rebound to a guessed
|
||||||
|
worktree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||||
|
|
||||||
|
CLASSIFICATION_UNBOUND = "unbound"
|
||||||
|
CLASSIFICATION_CORROBORATED = "corroborated"
|
||||||
|
CLASSIFICATION_UNVERIFIED_INHERITED = "unverified_inherited"
|
||||||
|
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH = "provably_stale_missing_path"
|
||||||
|
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE = "superseded_by_session_lease"
|
||||||
|
|
||||||
|
CLASSIFICATIONS = frozenset({
|
||||||
|
CLASSIFICATION_UNBOUND,
|
||||||
|
CLASSIFICATION_CORROBORATED,
|
||||||
|
CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||||
|
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||||
|
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Roles whose task workspace is owned by a lease lifecycle, so an inherited
|
||||||
|
# generic binding without a corroborating lease is suspect.
|
||||||
|
LEASE_BOUND_ROLES = frozenset({"reviewer", "merger"})
|
||||||
|
|
||||||
|
RECOVERY_ACTION_NONE = "none"
|
||||||
|
RECOVERY_ACTION_CLEAR_ENV = "clear_env"
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(path: str | None) -> str:
|
||||||
|
text = (path or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return os.path.realpath(os.path.abspath(text))
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_boot_bindings(
|
||||||
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Capture worktree env bindings present when the daemon booted.
|
||||||
|
|
||||||
|
A value present in this snapshot was inherited from the parent
|
||||||
|
environment, not established by any sanctioned tool in this daemon's
|
||||||
|
lifetime.
|
||||||
|
"""
|
||||||
|
env_map = env if env is not None else os.environ
|
||||||
|
return {
|
||||||
|
"active_worktree": (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_active_worktree_binding(
|
||||||
|
*,
|
||||||
|
active_value: str | None,
|
||||||
|
role_env_value: str | None = None,
|
||||||
|
session_lease_worktree: str | None = None,
|
||||||
|
boot_inherited: bool,
|
||||||
|
path_exists: bool | None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify the GITEA_ACTIVE_WORKTREE binding against live evidence.
|
||||||
|
|
||||||
|
Fail closed: unknown evidence never yields a clear-eligible class.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
active = _norm(active_value)
|
||||||
|
if not active:
|
||||||
|
return {
|
||||||
|
"classification": CLASSIFICATION_UNBOUND,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"active_worktree": None,
|
||||||
|
"reasons": ["no GITEA_ACTIVE_WORKTREE binding present"],
|
||||||
|
}
|
||||||
|
|
||||||
|
role = (role_kind or "").strip().lower()
|
||||||
|
role_env = _norm(role_env_value)
|
||||||
|
lease_wt = _norm(session_lease_worktree)
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"active_worktree": active,
|
||||||
|
"boot_inherited": bool(boot_inherited),
|
||||||
|
"role_kind": role or None,
|
||||||
|
"session_lease_worktree": lease_wt or None,
|
||||||
|
"role_env_worktree": role_env or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if path_exists is False:
|
||||||
|
reasons.append(
|
||||||
|
f"bound worktree '{active}' no longer exists on disk; the binding "
|
||||||
|
"cannot name a valid task workspace"
|
||||||
|
)
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||||
|
"clear_eligible": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
if lease_wt and lease_wt == active:
|
||||||
|
reasons.append("binding matches the sanctioned in-session lease worktree")
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_CORROBORATED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
if lease_wt and lease_wt != active and boot_inherited:
|
||||||
|
reasons.append(
|
||||||
|
"boot-inherited binding disagrees with the sanctioned in-session "
|
||||||
|
f"lease worktree '{lease_wt}'; the lease is live authority"
|
||||||
|
)
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||||
|
"clear_eligible": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
if lease_wt and lease_wt != active and not boot_inherited:
|
||||||
|
# Set after boot by tooling; disagreement is surfaced, never auto-cleared.
|
||||||
|
reasons.append(
|
||||||
|
"post-boot binding disagrees with the in-session lease worktree; "
|
||||||
|
"surfacing only (fail closed, no automatic clear)"
|
||||||
|
)
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
if role_env and role_env == active:
|
||||||
|
reasons.append("binding matches the role-specific worktree env binding")
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_CORROBORATED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
if boot_inherited and role in LEASE_BOUND_ROLES and not lease_wt:
|
||||||
|
reasons.append(
|
||||||
|
"binding was inherited at daemon boot, no sanctioned in-session "
|
||||||
|
f"lease corroborates it, and the {role} role binds workspaces "
|
||||||
|
"through the lease lifecycle; treat as unverified until a fresh "
|
||||||
|
"lease or managed reconnect re-establishes the workspace"
|
||||||
|
)
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
reasons.append("no contradicting evidence; binding treated as corroborated")
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_CORROBORATED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def plan_recovery(classification_result: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Turn a classification into an explicit, fail-closed recovery plan."""
|
||||||
|
classification = classification_result.get("classification")
|
||||||
|
clear_eligible = bool(classification_result.get("clear_eligible"))
|
||||||
|
reasons = list(classification_result.get("reasons") or [])
|
||||||
|
|
||||||
|
if classification not in CLASSIFICATIONS:
|
||||||
|
return {
|
||||||
|
"action": RECOVERY_ACTION_NONE,
|
||||||
|
"clear_allowed": False,
|
||||||
|
"classification": classification,
|
||||||
|
"reasons": reasons + ["unknown classification (fail closed)"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if clear_eligible and classification in {
|
||||||
|
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||||
|
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||||
|
}:
|
||||||
|
return {
|
||||||
|
"action": RECOVERY_ACTION_CLEAR_ENV,
|
||||||
|
"clear_allowed": True,
|
||||||
|
"classification": classification,
|
||||||
|
"active_worktree": classification_result.get("active_worktree"),
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
exact_next_action = None
|
||||||
|
if classification == CLASSIFICATION_UNVERIFIED_INHERITED:
|
||||||
|
exact_next_action = (
|
||||||
|
"managed recovery required: reconnect the MCP namespace through "
|
||||||
|
"the managed client configuration (or start a fresh session) so "
|
||||||
|
"the daemon boots without the inherited GITEA_ACTIVE_WORKTREE, or "
|
||||||
|
"corroborate the binding by acquiring a lease for that worktree; "
|
||||||
|
"do not clear or rewrite the environment manually"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"action": RECOVERY_ACTION_NONE,
|
||||||
|
"clear_allowed": False,
|
||||||
|
"classification": classification,
|
||||||
|
"active_worktree": classification_result.get("active_worktree"),
|
||||||
|
"reasons": reasons,
|
||||||
|
**({"exact_next_action": exact_next_action} if exact_next_action else {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_recovery(
|
||||||
|
plan: dict[str, Any],
|
||||||
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Apply a clear-eligible plan by removing the stale env binding.
|
||||||
|
|
||||||
|
This is the sanctioned in-daemon mechanism (#702 AC2); callers must
|
||||||
|
persist the returned record for audit. Refuses anything but an explicit
|
||||||
|
``clear_env`` plan.
|
||||||
|
"""
|
||||||
|
env_map = env if env is not None else os.environ
|
||||||
|
if not plan.get("clear_allowed") or plan.get("action") != RECOVERY_ACTION_CLEAR_ENV:
|
||||||
|
return {
|
||||||
|
"performed": False,
|
||||||
|
"action": RECOVERY_ACTION_NONE,
|
||||||
|
"reasons": ["recovery plan does not authorize clearing (fail closed)"],
|
||||||
|
}
|
||||||
|
cleared_value = (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None
|
||||||
|
env_map.pop(ACTIVE_WORKTREE_ENV, None)
|
||||||
|
return {
|
||||||
|
"performed": True,
|
||||||
|
"action": RECOVERY_ACTION_CLEAR_ENV,
|
||||||
|
"cleared_env": ACTIVE_WORKTREE_ENV,
|
||||||
|
"cleared_value": cleared_value,
|
||||||
|
"classification": plan.get("classification"),
|
||||||
|
"reasons": list(plan.get("reasons") or []),
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+338
-2
@@ -60,22 +60,121 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.close",
|
"permission": "gitea.pr.close",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# Non-closing PR metadata edits (title/body/base). Closing uses close_pr.
|
||||||
|
"edit_pr": {
|
||||||
|
"permission": "gitea.pr.create",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_edit_pr": {
|
||||||
|
"permission": "gitea.pr.create",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
"address_pr_change_requests": {
|
"address_pr_change_requests": {
|
||||||
"permission": "gitea.branch.push",
|
"permission": "gitea.branch.push",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# PR synchronization lifecycle: assess is read-only (any role with gitea.read);
|
||||||
|
# update-by-merge is author-only and mutates the PR head via Gitea API.
|
||||||
|
"assess_pr_sync_status": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_assess_pr_sync_status": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"update_pr_branch_by_merge": {
|
||||||
|
"permission": "gitea.branch.push",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_update_pr_branch_by_merge": {
|
||||||
|
"permission": "gitea.branch.push",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
"review_pr": {
|
"review_pr": {
|
||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
"submit_pr_review": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
"merge_pr": {
|
"merge_pr": {
|
||||||
"permission": "gitea.pr.merge",
|
"permission": "gitea.pr.merge",
|
||||||
"role": "merger",
|
"role": "merger",
|
||||||
},
|
},
|
||||||
"adopt_merger_pr_lease": {
|
"acquire_reviewer_pr_lease": {
|
||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
"gitea_acquire_reviewer_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
# #695 AC8: controller quarantine of contaminated formal reviews.
|
||||||
|
# Apply path posts an append-only forensic audit comment (pr.comment).
|
||||||
|
"quarantine_contaminated_review": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_quarantine_contaminated_review": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"adopt_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"gitea_adopt_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"acquire_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"gitea_acquire_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
# #742: owner-session terminal release/abandon of a merger-held lease when
|
||||||
|
# the merge does not occur. Apply path posts an append-only terminal lease
|
||||||
|
# marker (gitea.pr.comment); merger-only, never a reviewer path.
|
||||||
|
"release_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"gitea_release_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
||||||
|
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
||||||
|
"cleanup_obsolete_reviewer_comment_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"gitea_cleanup_obsolete_reviewer_comment_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
# #745: post-merge moot reviewer-lease cleanup is reconciler-owned. The
|
||||||
|
# apply path posts an append-only terminal `phase: released` lease marker
|
||||||
|
# (gitea.pr.comment), so holding the comment permission alone must not
|
||||||
|
# authorize it — author, reviewer and merger fail closed on the role gate
|
||||||
|
# even though their profiles carry gitea.pr.comment. The read-only
|
||||||
|
# `apply=false` assessment deliberately stays reachable under gitea.read
|
||||||
|
# inside the tool (the same convention as cleanup_stale_review_decision_lock
|
||||||
|
# below), so any namespace can diagnose a stuck lease; only apply requires
|
||||||
|
# this task plus the reconciler role.
|
||||||
|
"cleanup_post_merge_moot_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_cleanup_post_merge_moot_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
"blind_pr_queue_review": {
|
"blind_pr_queue_review": {
|
||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
@@ -96,9 +195,69 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.approve",
|
"permission": "gitea.pr.approve",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
# #594: clear durable #332 decision lock only when last terminal PR is
|
||||||
|
# already merged/closed (moot). Apply path requires reviewer review
|
||||||
|
# permission; assessment itself uses gitea.read inside the tool.
|
||||||
|
"cleanup_stale_review_decision_lock": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"gitea_cleanup_stale_review_decision_lock": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
# #693: read-only classification of durable decision locks (incl. open PRs).
|
||||||
|
"diagnose_review_decision_lock": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"gitea_diagnose_review_decision_lock": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"authorize_review_correction": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"gitea_authorize_review_correction": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
# #709: truthful absence-of-proof recovery (server-side auth + record + consume).
|
||||||
|
# Dedicated mutation capability — gitea.read is insufficient (review 434 F1).
|
||||||
|
"issue_irrecoverable_provenance_authorization": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_issue_irrecoverable_provenance_authorization": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"record_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_record_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"consume_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.pr.merge",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"gitea_consume_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.pr.merge",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
# #729: delete_branch is reconciler-owned. gitea.branch.delete is granted
|
||||||
|
# only to the reconciler profile, so the resolver must classify this task as
|
||||||
|
# reconciler (previously "author", which no delete-capable profile held).
|
||||||
|
# Raw gitea_delete_branch still redirects reconciler to the guarded
|
||||||
|
# gitea_cleanup_merged_pr_branch path (#514/#687); author/reviewer/merger
|
||||||
|
# stay denied by both the permission gate and this role gate.
|
||||||
"delete_branch": {
|
"delete_branch": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "author",
|
"role": "reconciler",
|
||||||
},
|
},
|
||||||
"cleanup_merged_pr_branch": {
|
"cleanup_merged_pr_branch": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
@@ -128,6 +287,103 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.create",
|
"permission": "gitea.pr.create",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# #600: controller-owned allocator — any authenticated profile may call;
|
||||||
|
# routing enforces role match to selected work. Uses control-plane DB (#613).
|
||||||
|
"allocate_next_work": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_allocate_next_work": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
|
||||||
|
# #601 first-class lease lifecycle — inspect/list need read; mutations gate on
|
||||||
|
# ownership in the control-plane DB (not a separate Gitea write permission).
|
||||||
|
"list_workflow_leases": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_list_workflow_leases": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"inspect_workflow_lease": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_inspect_workflow_lease": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"adopt_workflow_lease": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_adopt_workflow_lease": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"release_workflow_lease": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_release_workflow_lease": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"expire_workflow_leases": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_expire_workflow_leases": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"abandon_workflow_lease": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_abandon_workflow_lease": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"reclaim_expired_workflow_lease": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_reclaim_expired_workflow_lease": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
# #612 incident bridge — reconcile uses create_issue for apply;
|
||||||
|
# dry-run needs read only. Tools gate apply paths themselves.
|
||||||
|
"observability_reconcile_incident": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_observability_reconcile_incident": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"observability_list_projects": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_observability_list_projects": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"observability_link_issue": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_observability_link_issue": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
"reconcile_landed_pr": {
|
"reconcile_landed_pr": {
|
||||||
"permission": "gitea.read",
|
"permission": "gitea.read",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -150,6 +406,18 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.issue.close",
|
"permission": "gitea.issue.close",
|
||||||
"role": "reconciler",
|
"role": "reconciler",
|
||||||
},
|
},
|
||||||
|
"reconcile_close_superseded_pr": {
|
||||||
|
"permission": "gitea.pr.close",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"reconcile_close_satisfied_issue": {
|
||||||
|
"permission": "gitea.issue.close",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"reconcile_create_followup_issue": {
|
||||||
|
"permission": "gitea.issue.create",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
"post_heartbeat": {
|
"post_heartbeat": {
|
||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -164,6 +432,74 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# A reviewer lease is the first mutation in the canonical ``review_pr``
|
||||||
|
# workflow, so the already-resolved review capability is valid for that one
|
||||||
|
# narrower transition. Keep this directed and explicit: lease acquisition
|
||||||
|
# does not authorize a review verdict, and reviewer proof never authorizes a
|
||||||
|
# merger lease (#763).
|
||||||
|
_PREFLIGHT_TASK_TRANSITIONS = frozenset({
|
||||||
|
("review_pr", "acquire_reviewer_pr_lease"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_preflight_task(task: str | None) -> str:
|
||||||
|
"""Normalize only declared ``gitea_`` aliases for preflight comparison."""
|
||||||
|
value = (task or "").strip()
|
||||||
|
if value.startswith("gitea_") and value[6:] in TASK_CAPABILITY_MAP:
|
||||||
|
return value[6:]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_task_matches(
|
||||||
|
resolved_task: str | None,
|
||||||
|
mutation_task: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether capability proof authorizes this mutation transition."""
|
||||||
|
resolved = _canonical_preflight_task(resolved_task)
|
||||||
|
mutation = _canonical_preflight_task(mutation_task)
|
||||||
|
if not resolved or not mutation:
|
||||||
|
return False
|
||||||
|
return resolved == mutation or (resolved, mutation) in _PREFLIGHT_TASK_TRANSITIONS
|
||||||
|
|
||||||
|
|
||||||
|
# Tasks for which permission alone is insufficient: the active/configured
|
||||||
|
# profile's declared role must also match the task role. This is the complete
|
||||||
|
# resolver set from master at the #723 reconstruction point, shared with
|
||||||
|
# runtime reporting so those two authorities cannot drift again.
|
||||||
|
ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"acquire_reviewer_pr_lease",
|
||||||
|
"gitea_acquire_reviewer_pr_lease",
|
||||||
|
"review_pr",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"blind_pr_queue_review",
|
||||||
|
"pr_queue_cleanup",
|
||||||
|
"pr-queue-cleanup",
|
||||||
|
"merge_pr",
|
||||||
|
"acquire_merger_pr_lease",
|
||||||
|
"gitea_acquire_merger_pr_lease",
|
||||||
|
"adopt_merger_pr_lease",
|
||||||
|
"gitea_adopt_merger_pr_lease",
|
||||||
|
"release_merger_pr_lease",
|
||||||
|
"gitea_release_merger_pr_lease",
|
||||||
|
"create_branch",
|
||||||
|
"push_branch",
|
||||||
|
"create_pr",
|
||||||
|
"commit_files",
|
||||||
|
"gitea_commit_files",
|
||||||
|
"address_pr_change_requests",
|
||||||
|
"update_pr_branch_by_merge",
|
||||||
|
"gitea_update_pr_branch_by_merge",
|
||||||
|
"delete_branch",
|
||||||
|
"cleanup_merged_pr_branch",
|
||||||
|
"reconciliation_cleanup",
|
||||||
|
"work_issue",
|
||||||
|
"work-issue",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Issue-mutating MCP tools and their resolver task keys.
|
# Issue-mutating MCP tools and their resolver task keys.
|
||||||
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
||||||
"gitea_create_issue": "create_issue",
|
"gitea_create_issue": "create_issue",
|
||||||
|
|||||||
+185
-25
@@ -1,20 +1,45 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Live health-check script to verify Gitea MCP namespace connections.
|
"""Offline-only MCP namespace spawn probe (NOT IDE-namespace proof).
|
||||||
|
|
||||||
Spawns the MCP server processes as defined in the IDE's global config,
|
Spawns a *separate* MCP server process from config via subprocess.Popen,
|
||||||
performs the JSON-RPC handshake, and queries the tools list to verify
|
performs the JSON-RPC handshake, verifies the required tool is registered,
|
||||||
that the connection is fully operational and doesn't return EOF.
|
and invokes that tool. Results are classified with
|
||||||
|
``probe_source=offline_spawn``.
|
||||||
|
|
||||||
|
This path is useful for offline launch/registration debugging. It does
|
||||||
|
**not** prove the IDE-managed MCP client namespace is healthy (#543). For
|
||||||
|
workflow gates, pass live IDE call evidence with
|
||||||
|
``probe_source=client_namespace`` to ``gitea_assess_mcp_namespace_health``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def run_connection_test(name, config):
|
from mcp_namespace_health import REQUIRED_NAMESPACE_TOOLS, classify_namespace_probe
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_line(proc):
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
stderr_content = proc.stderr.read()
|
||||||
|
return None, stderr_content
|
||||||
|
return json.loads(line), None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_message(proc, payload):
|
||||||
|
proc.stdin.write(json.dumps(payload) + "\n")
|
||||||
|
proc.stdin.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def run_connection_test(name, config, *, required_tool=None, config_path=None):
|
||||||
print(f"Testing MCP connection for '{name}'...")
|
print(f"Testing MCP connection for '{name}'...")
|
||||||
command = config.get("command")
|
command = config.get("command")
|
||||||
args = config.get("args", [])
|
args = config.get("args", [])
|
||||||
env = config.get("env", {})
|
env = config.get("env", {})
|
||||||
|
tool_name = required_tool or REQUIRED_NAMESPACE_TOOLS.get(name) or "gitea_whoami"
|
||||||
|
|
||||||
# Merge current environment
|
# Merge current environment
|
||||||
run_env = os.environ.copy()
|
run_env = os.environ.copy()
|
||||||
@@ -31,8 +56,17 @@ def run_connection_test(name, config):
|
|||||||
bufsize=1,
|
bufsize=1,
|
||||||
env=run_env
|
env=run_env
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print(f" [FAIL] Failed to spawn process: {e}")
|
print(f" [FAIL] Failed to spawn process: {exc}")
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
probe_result={"success": False, "error": str(exc)},
|
||||||
|
process={"profile": env.get("GITEA_MCP_PROFILE"), "env": env},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Send initialize request
|
# Send initialize request
|
||||||
@@ -48,26 +82,36 @@ def run_connection_test(name, config):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc.stdin.write(json.dumps(init_req) + "\n")
|
_write_message(proc, init_req)
|
||||||
proc.stdin.flush()
|
|
||||||
|
|
||||||
# Read response
|
# Read response
|
||||||
line = proc.stdout.readline()
|
res, stderr_content = _read_json_line(proc)
|
||||||
if not line:
|
if res is None:
|
||||||
stderr_content = proc.stderr.read()
|
|
||||||
print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}")
|
print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}")
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
probe_result={"success": False, "error": stderr_content or "EOF"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print(f" [OK] Received initialize response: {line.strip()[:150]}...")
|
print(f" [OK] Received initialize response: {str(res)[:150]}...")
|
||||||
|
|
||||||
# Send initialized notification
|
# Send initialized notification
|
||||||
init_notif = {
|
init_notif = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"method": "notifications/initialized"
|
"method": "notifications/initialized"
|
||||||
}
|
}
|
||||||
proc.stdin.write(json.dumps(init_notif) + "\n")
|
_write_message(proc, init_notif)
|
||||||
proc.stdin.flush()
|
|
||||||
|
|
||||||
# Send tools/list request
|
# Send tools/list request
|
||||||
list_req = {
|
list_req = {
|
||||||
@@ -76,16 +120,27 @@ def run_connection_test(name, config):
|
|||||||
"params": {},
|
"params": {},
|
||||||
"id": 2
|
"id": 2
|
||||||
}
|
}
|
||||||
proc.stdin.write(json.dumps(list_req) + "\n")
|
_write_message(proc, list_req)
|
||||||
proc.stdin.flush()
|
|
||||||
|
|
||||||
line = proc.stdout.readline()
|
res, stderr_content = _read_json_line(proc)
|
||||||
if not line:
|
if res is None:
|
||||||
print(" [FAIL] Received EOF on tools/list request.")
|
print(" [FAIL] Received EOF on tools/list request.")
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
probe_result={"success": False, "error": stderr_content or "EOF"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
res = json.loads(line)
|
|
||||||
if "error" in res:
|
if "error" in res:
|
||||||
print(f" [FAIL] Server returned error: {res['error']}")
|
print(f" [FAIL] Server returned error: {res['error']}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
@@ -94,16 +149,115 @@ def run_connection_test(name, config):
|
|||||||
tools = res.get("result", {}).get("tools", [])
|
tools = res.get("result", {}).get("tools", [])
|
||||||
tool_names = [t.get("name") for t in tools]
|
tool_names = [t.get("name") for t in tools]
|
||||||
print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...")
|
print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...")
|
||||||
|
if tool_name not in tool_names:
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": False, "error": "required tool missing"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [FAIL] Required tool '{tool_name}' is not registered.")
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
call_req = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {"name": tool_name, "arguments": {}},
|
||||||
|
"id": 3,
|
||||||
|
}
|
||||||
|
_write_message(proc, call_req)
|
||||||
|
|
||||||
|
call_res, stderr_content = _read_json_line(proc)
|
||||||
|
if call_res is None:
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": False, "error": stderr_content or "EOF"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [FAIL] Received EOF on {tool_name} invocation.")
|
||||||
|
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
if "error" in call_res:
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": False, "error": call_res["error"]},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [FAIL] {tool_name} invocation returned error: {call_res['error']}")
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": True, "result": call_res.get("result")},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [OK] Successfully invoked required tool '{tool_name}'.")
|
||||||
|
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
|
||||||
|
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print(f" [FAIL] Error during handshake: {e}")
|
print(f" [FAIL] Error during handshake: {exc}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
config_path = "/Users/jasonwalker/.gemini/config/mcp_config.json"
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
default=os.environ.get(
|
||||||
|
"MCP_CONFIG_PATH",
|
||||||
|
os.path.expanduser("~/.gemini/config/mcp_config.json"),
|
||||||
|
),
|
||||||
|
help="Path to MCP config JSON.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--namespace",
|
||||||
|
action="append",
|
||||||
|
dest="namespaces",
|
||||||
|
help="Namespace to test. May be repeated.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config_path = args.config
|
||||||
try:
|
try:
|
||||||
with open(config_path) as f:
|
with open(config_path) as f:
|
||||||
mcp_config = json.load(f)
|
mcp_config = json.load(f)
|
||||||
@@ -112,10 +266,16 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
servers = mcp_config.get("mcpServers", {})
|
servers = mcp_config.get("mcpServers", {})
|
||||||
|
namespaces = args.namespaces or list(REQUIRED_NAMESPACE_TOOLS)
|
||||||
failed = False
|
failed = False
|
||||||
for name in ["gitea-author", "gitea-reviewer"]:
|
for name in namespaces:
|
||||||
if name in servers:
|
if name in servers:
|
||||||
if not run_connection_test(name, servers[name]):
|
if not run_connection_test(
|
||||||
|
name,
|
||||||
|
servers[name],
|
||||||
|
required_tool=REQUIRED_NAMESPACE_TOOLS.get(name),
|
||||||
|
config_path=config_path,
|
||||||
|
):
|
||||||
failed = True
|
failed = True
|
||||||
else:
|
else:
|
||||||
print(f"Server '{name}' not found in mcp_config.json")
|
print(f"Server '{name}' not found in mcp_config.json")
|
||||||
|
|||||||
+128
-19
@@ -1,4 +1,5 @@
|
|||||||
"""Shared pytest fixtures for the Gitea-Tools test suite."""
|
"""Shared pytest fixtures for the Gitea-Tools test suite."""
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -16,22 +17,104 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
the next test. It must never replace verify_mutation_authority with a
|
the next test. It must never replace verify_mutation_authority with a
|
||||||
no-op: individual tests that need a specific authority state set it up
|
no-op: individual tests that need a specific authority state set it up
|
||||||
explicitly.
|
explicitly.
|
||||||
|
|
||||||
|
Session-state isolation (#559 / #590 / #594): many tests use
|
||||||
|
``patch.dict(os.environ, ..., clear=True)``, which drops
|
||||||
|
``GITEA_MCP_SESSION_STATE_DIR``. Relying only on the env var therefore
|
||||||
|
re-exposes the operator host cache
|
||||||
|
(``~/.cache/gitea-tools/session-state``) and its review-mutation ledger.
|
||||||
|
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||||
|
so durable load/save never touches host state even after env clears.
|
||||||
"""
|
"""
|
||||||
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
|
import session_context_binding as session_ctx
|
||||||
|
|
||||||
|
# Each pytest item is an independent logical MCP session. Reset both before
|
||||||
|
# and after the item; the post-yield reset is in a finally block so an
|
||||||
|
# assertion, exception, or unittest teardown failure cannot pollute the
|
||||||
|
# next item. Production code has no automatic per-call reset path.
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
|
||||||
|
for env_key in [
|
||||||
|
"GITEA_SESSION_PROFILE_LOCK",
|
||||||
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
|
"GITEA_AUTHOR_WORKTREE",
|
||||||
|
"GITEA_REVIEWER_WORKTREE",
|
||||||
|
"GITEA_MERGER_WORKTREE",
|
||||||
|
"GITEA_RECONCILER_WORKTREE",
|
||||||
|
]:
|
||||||
|
monkeypatch.delenv(env_key, raising=False)
|
||||||
|
|
||||||
# Isolate durable session-state files so tests never share host cache (#559).
|
# Isolate durable session-state files so tests never share host cache (#559).
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
_state_tmp = tempfile.TemporaryDirectory(prefix="gitea-session-state-")
|
_state_tmp = tempfile.TemporaryDirectory(prefix="gitea-session-state-")
|
||||||
monkeypatch.setenv("GITEA_MCP_SESSION_STATE_DIR", _state_tmp.name)
|
state_dir = _state_tmp.name
|
||||||
|
monkeypatch.setenv("GITEA_MCP_SESSION_STATE_DIR", state_dir)
|
||||||
|
try:
|
||||||
|
import mcp_session_state
|
||||||
|
except Exception:
|
||||||
|
mcp_session_state = None
|
||||||
|
if mcp_session_state is not None:
|
||||||
|
# Survive patch.dict(..., clear=True) that drops the env var (#590).
|
||||||
|
# Still honor an explicit GITEA_MCP_SESSION_STATE_DIR when tests set
|
||||||
|
# their own temp dir (see tests/test_mcp_session_state.py).
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_session_state, "DEFAULT_STATE_DIR", state_dir, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _isolated_default_state_dir(
|
||||||
|
_fallback: str = state_dir,
|
||||||
|
_env_key: str = mcp_session_state.STATE_DIR_ENV,
|
||||||
|
) -> str:
|
||||||
|
# #695 AC2: when production native transport has pinned a session
|
||||||
|
# state root, that pin is authoritative even under test isolation
|
||||||
|
# (PR #701 redirected-state regression).
|
||||||
|
try:
|
||||||
|
import mcp_daemon_guard
|
||||||
|
|
||||||
|
pinned = mcp_daemon_guard.pinned_session_state_dir()
|
||||||
|
if pinned:
|
||||||
|
return pinned
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raw = (os.environ.get(_env_key) or "").strip()
|
||||||
|
return raw or _fallback
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_session_state,
|
||||||
|
"default_state_dir",
|
||||||
|
_isolated_default_state_dir,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
import mcp_server
|
import mcp_server
|
||||||
except Exception:
|
except Exception:
|
||||||
_state_tmp.cleanup()
|
try:
|
||||||
yield
|
yield
|
||||||
|
finally:
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
_state_tmp.cleanup()
|
||||||
return
|
return
|
||||||
|
import gitea_config
|
||||||
|
|
||||||
|
monkeypatch.setattr(gitea_config, "_active_profile_override", None)
|
||||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||||
|
# #714: clear both module namespaces. mcp_server.py execs gitea_mcp_server.py
|
||||||
|
# into its own globals, so `import gitea_mcp_server` is a separate module
|
||||||
|
# object with its own identity caches; leaving it dirty leaks login across
|
||||||
|
# tests that import the implementation module directly.
|
||||||
|
try:
|
||||||
|
import gitea_mcp_server as _gitea_impl
|
||||||
|
|
||||||
|
monkeypatch.setattr(_gitea_impl, "_IDENTITY_CACHE", {})
|
||||||
|
if hasattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE"):
|
||||||
|
monkeypatch.setattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE", {})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if hasattr(mcp_server, "_ACTOR_IDENTITY_CACHE"):
|
||||||
|
monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {})
|
||||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||||
|
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
|
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
|
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
|
||||||
@@ -40,6 +123,14 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
monkeypatch.setattr(mcp_server, "_preflight_capability_baseline_porcelain", None)
|
monkeypatch.setattr(mcp_server, "_preflight_capability_baseline_porcelain", None)
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_resolved_task", None)
|
monkeypatch.setattr(mcp_server, "_preflight_resolved_task", None)
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_reviewer_violation_files", [])
|
monkeypatch.setattr(mcp_server, "_preflight_reviewer_violation_files", [])
|
||||||
|
# Unit tests must not depend on live host MCP process health. Tests that
|
||||||
|
# use patch.dict(..., clear=True) drop PYTEST_CURRENT_TEST, which would
|
||||||
|
# otherwise re-enable the stale-runtime probe against operator daemons.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_server,
|
||||||
|
"_check_mcp_runtimes_diagnostics",
|
||||||
|
lambda *args, **kwargs: [],
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
import review_workflow_load
|
import review_workflow_load
|
||||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||||
@@ -50,19 +141,37 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
capability_stop_terminal.clear()
|
capability_stop_terminal.clear()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
import capability_stop_terminal
|
||||||
|
capability_stop_terminal.clear()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import review_workflow_load
|
||||||
|
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
mcp_server._REVIEW_DECISION_LOCK = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
_state_tmp.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
# #714: deterministic workspace remotes only (no host git dependency).
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _deterministic_workspace_remotes():
|
||||||
|
try:
|
||||||
|
from mutation_profile_fixture import install_deterministic_remote_urls
|
||||||
|
install_deterministic_remote_urls()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield
|
yield
|
||||||
try:
|
|
||||||
import capability_stop_terminal
|
|
||||||
capability_stop_terminal.clear()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
import review_workflow_load
|
|
||||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
mcp_server._REVIEW_DECISION_LOCK = None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_state_tmp.cleanup()
|
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
"""Centralized config-backed mutation fixture for #714.
|
||||||
|
|
||||||
|
Mutation tests must opt into this helper explicitly. It never grants
|
||||||
|
mutation authority via environment variables alone.
|
||||||
|
|
||||||
|
Design rules:
|
||||||
|
- temporary v2 configuration with non-empty ``allowed_repositories``
|
||||||
|
- profile-scoped operations (not every mutation op for every test)
|
||||||
|
- deterministic workspace remotes (no host Git dependency)
|
||||||
|
- one canonical repository per profile by default
|
||||||
|
- isolated state + cleanup in ``finally``
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Iterable, Sequence
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
PRGS_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
PRGS_URL = f"https://gitea.prgs.cc/{PRGS_SLUG}.git"
|
||||||
|
MDCPS_SLUG = "913443/eAgenda"
|
||||||
|
MDCPS_URL = f"https://gitea.dadeschools.net/{MDCPS_SLUG}.git"
|
||||||
|
EXAMPLE_SLUG = "Example-Org/Example-Repo"
|
||||||
|
EXAMPLE_URL = f"https://gitea.example.com/{EXAMPLE_SLUG}.git"
|
||||||
|
TIMESHEET_SLUG = "Scaled-Tech-Consulting/Timesheet"
|
||||||
|
|
||||||
|
|
||||||
|
def _author_ops() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _author_forbidden() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.review",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewer_ops() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _merger_ops() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
context: str,
|
||||||
|
role: str,
|
||||||
|
base_url: str,
|
||||||
|
auth_env: str,
|
||||||
|
allowed_operations: Sequence[str],
|
||||||
|
forbidden_operations: Sequence[str],
|
||||||
|
allowed_repositories: Sequence[str],
|
||||||
|
username: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
body = {
|
||||||
|
"enabled": True,
|
||||||
|
"context": context,
|
||||||
|
"role": role,
|
||||||
|
"base_url": base_url,
|
||||||
|
"auth": {"type": "env", "name": auth_env},
|
||||||
|
"allowed_operations": list(allowed_operations),
|
||||||
|
"forbidden_operations": list(forbidden_operations),
|
||||||
|
"allowed_repositories": list(allowed_repositories),
|
||||||
|
"execution_profile": name,
|
||||||
|
}
|
||||||
|
if username:
|
||||||
|
body["username"] = username
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def build_dual_remote_config(
|
||||||
|
*,
|
||||||
|
allowed_operations: Iterable[str] | None = None,
|
||||||
|
allowed_repositories_prgs: Sequence[str] | None = None,
|
||||||
|
allowed_repositories_mdcps: Sequence[str] | None = None,
|
||||||
|
extra_profiles: dict | None = None,
|
||||||
|
include_example_repo: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a minimal dual-host v2 config.
|
||||||
|
|
||||||
|
Each profile authorizes only its host-canonical repository by default.
|
||||||
|
``include_example_repo`` adds Example-Org/Example-Repo when a test patches
|
||||||
|
REMOTES to that synthetic unit-test identity.
|
||||||
|
"""
|
||||||
|
ops = list(allowed_operations or _author_ops())
|
||||||
|
forb = _author_forbidden()
|
||||||
|
prgs_repos = list(allowed_repositories_prgs or [PRGS_SLUG])
|
||||||
|
mdcps_repos = list(allowed_repositories_mdcps or [MDCPS_SLUG])
|
||||||
|
if include_example_repo:
|
||||||
|
for repos in (prgs_repos, mdcps_repos):
|
||||||
|
if EXAMPLE_SLUG not in repos:
|
||||||
|
repos.append(EXAMPLE_SLUG)
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"test-author-prgs": _profile(
|
||||||
|
name="test-author-prgs",
|
||||||
|
context="prgs",
|
||||||
|
role="author",
|
||||||
|
base_url="https://gitea.prgs.cc",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=ops,
|
||||||
|
forbidden_operations=forb,
|
||||||
|
allowed_repositories=prgs_repos,
|
||||||
|
),
|
||||||
|
"test-author-dadeschools": _profile(
|
||||||
|
name="test-author-dadeschools",
|
||||||
|
context="mdcps",
|
||||||
|
role="author",
|
||||||
|
base_url="https://gitea.dadeschools.net",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=ops,
|
||||||
|
forbidden_operations=forb,
|
||||||
|
allowed_repositories=mdcps_repos,
|
||||||
|
),
|
||||||
|
"test-reviewer-prgs": _profile(
|
||||||
|
name="test-reviewer-prgs",
|
||||||
|
context="prgs",
|
||||||
|
role="reviewer",
|
||||||
|
base_url="https://gitea.prgs.cc",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=_reviewer_ops(),
|
||||||
|
forbidden_operations=[
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.issue.create",
|
||||||
|
],
|
||||||
|
allowed_repositories=prgs_repos,
|
||||||
|
),
|
||||||
|
"test-merger-prgs": _profile(
|
||||||
|
name="test-merger-prgs",
|
||||||
|
context="prgs",
|
||||||
|
role="merger",
|
||||||
|
base_url="https://gitea.prgs.cc",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=_merger_ops(),
|
||||||
|
forbidden_operations=[
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.issue.create",
|
||||||
|
],
|
||||||
|
allowed_repositories=prgs_repos,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if extra_profiles:
|
||||||
|
profiles.update(deepcopy(extra_profiles))
|
||||||
|
|
||||||
|
# Legacy aliases used by older tests — same host scope as the base profile.
|
||||||
|
for alias, base in (
|
||||||
|
("gitea-author", "test-author-prgs"),
|
||||||
|
("author-test", "test-author-dadeschools"),
|
||||||
|
("author", "test-author-dadeschools"),
|
||||||
|
("full-author", "test-author-dadeschools"),
|
||||||
|
("test-author", "test-author-dadeschools"),
|
||||||
|
("prgs-author", "test-author-prgs"),
|
||||||
|
("gitea-reviewer", "test-reviewer-prgs"),
|
||||||
|
("prgs-reviewer", "test-reviewer-prgs"),
|
||||||
|
("gitea-merger", "test-merger-prgs"),
|
||||||
|
("prgs-merger", "test-merger-prgs"),
|
||||||
|
):
|
||||||
|
if alias not in profiles and base in profiles:
|
||||||
|
clone = deepcopy(profiles[base])
|
||||||
|
clone["execution_profile"] = alias
|
||||||
|
profiles[alias] = clone
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"rules": {"allow_runtime_switching": True},
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {
|
||||||
|
"enabled": True,
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"profiles": profiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_v2_config(**kwargs):
|
||||||
|
"""Back-compat alias."""
|
||||||
|
return build_dual_remote_config(
|
||||||
|
allowed_operations=kwargs.get("allowed_operations"),
|
||||||
|
extra_profiles=kwargs.get("extra_profiles"),
|
||||||
|
include_example_repo=bool(kwargs.get("include_example_repo")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def inject_allowed_repositories(
|
||||||
|
config: dict,
|
||||||
|
repos: Sequence[str],
|
||||||
|
*,
|
||||||
|
profiles: Sequence[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Return a deep copy of *config* with allowlists set on selected profiles."""
|
||||||
|
out = deepcopy(config)
|
||||||
|
targets = set(profiles) if profiles is not None else set(out.get("profiles") or {})
|
||||||
|
for name, prof in (out.get("profiles") or {}).items():
|
||||||
|
if name in targets:
|
||||||
|
prof["allowed_repositories"] = list(repos)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_all_profiles_have_scope(
|
||||||
|
config: dict,
|
||||||
|
default_repos: Sequence[str],
|
||||||
|
) -> dict:
|
||||||
|
"""Add non-empty allowed_repositories to every profile that lacks one."""
|
||||||
|
out = deepcopy(config)
|
||||||
|
for _name, prof in (out.get("profiles") or {}).items():
|
||||||
|
raw = prof.get("allowed_repositories")
|
||||||
|
if not isinstance(raw, (list, tuple)) or not raw:
|
||||||
|
prof["allowed_repositories"] = list(default_repos)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def mutation_profile_env(
|
||||||
|
*,
|
||||||
|
profile_name: str = "test-author-dadeschools",
|
||||||
|
allowed_operations: Iterable[str] | None = None,
|
||||||
|
allowed_repositories: Sequence[str] | None = None,
|
||||||
|
workspace_urls: dict | None = None,
|
||||||
|
clear_env: bool = False,
|
||||||
|
extra_env: dict | None = None,
|
||||||
|
extra_profiles: dict | None = None,
|
||||||
|
include_example_repo: bool = False,
|
||||||
|
config: dict | None = None,
|
||||||
|
):
|
||||||
|
"""Context manager: config-backed mutation authority + deterministic remotes."""
|
||||||
|
import gitea_config
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
import session_context_binding as session_ctx
|
||||||
|
|
||||||
|
if config is None:
|
||||||
|
prgs_repos = None
|
||||||
|
mdcps_repos = None
|
||||||
|
if allowed_repositories is not None:
|
||||||
|
# Caller-specified single allowlist applied to both host profiles.
|
||||||
|
prgs_repos = list(allowed_repositories)
|
||||||
|
mdcps_repos = list(allowed_repositories)
|
||||||
|
cfg = build_dual_remote_config(
|
||||||
|
allowed_operations=allowed_operations,
|
||||||
|
allowed_repositories_prgs=prgs_repos,
|
||||||
|
allowed_repositories_mdcps=mdcps_repos,
|
||||||
|
extra_profiles=extra_profiles,
|
||||||
|
include_example_repo=include_example_repo,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cfg = deepcopy(config)
|
||||||
|
if allowed_repositories is not None:
|
||||||
|
cfg = ensure_all_profiles_have_scope(cfg, list(allowed_repositories))
|
||||||
|
|
||||||
|
urls = (
|
||||||
|
{"prgs": PRGS_URL, "dadeschools": MDCPS_URL}
|
||||||
|
if workspace_urls is None
|
||||||
|
else dict(workspace_urls)
|
||||||
|
)
|
||||||
|
|
||||||
|
tmp = tempfile.TemporaryDirectory(prefix="gitea-mut-cfg-")
|
||||||
|
try:
|
||||||
|
cfg_path = os.path.join(tmp.name, "profiles.json")
|
||||||
|
with open(cfg_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(cfg, fh)
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": cfg_path,
|
||||||
|
"GITEA_MCP_PROFILE": profile_name,
|
||||||
|
"GITEA_TOKEN_TEST": "test-token",
|
||||||
|
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||||
|
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if extra_env:
|
||||||
|
env.update(extra_env)
|
||||||
|
|
||||||
|
def _remote_url(name: str):
|
||||||
|
if name in urls:
|
||||||
|
return urls[name]
|
||||||
|
# Prefer live REMOTES (tests often patch Example-Org).
|
||||||
|
try:
|
||||||
|
entry = srv.REMOTES.get(name) or {}
|
||||||
|
host = entry.get("host")
|
||||||
|
org = entry.get("org")
|
||||||
|
repo = entry.get("repo")
|
||||||
|
if host and org and repo:
|
||||||
|
if (
|
||||||
|
name == "prgs"
|
||||||
|
and org == "Scaled-Tech-Consulting"
|
||||||
|
and repo == "Timesheet"
|
||||||
|
):
|
||||||
|
return PRGS_URL
|
||||||
|
if name == "dadeschools" and repo == "Timesheet":
|
||||||
|
return MDCPS_URL
|
||||||
|
return f"https://{host}/{org}/{repo}.git"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
try:
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"env": env,
|
||||||
|
"config_path": cfg_path,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"urls": urls,
|
||||||
|
"config": cfg,
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=clear_env):
|
||||||
|
os.environ.setdefault(
|
||||||
|
"PYTEST_CURRENT_TEST",
|
||||||
|
env.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||||
|
)
|
||||||
|
with patch.object(srv, "_local_git_remote_url", side_effect=_remote_url):
|
||||||
|
mcp_srv = None
|
||||||
|
try:
|
||||||
|
import mcp_server as mcp_srv # type: ignore
|
||||||
|
except Exception:
|
||||||
|
mcp_srv = None
|
||||||
|
if mcp_srv is not None:
|
||||||
|
with patch.object(
|
||||||
|
mcp_srv, "_local_git_remote_url", side_effect=_remote_url
|
||||||
|
):
|
||||||
|
yield payload
|
||||||
|
else:
|
||||||
|
yield payload
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
tmp.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
# Process-lifetime shared config for mass-migrating env-only mutation tests.
|
||||||
|
_SHARED_CFG_PATH = None
|
||||||
|
_SHARED_CFG_DIR = None
|
||||||
|
_SHARED_CFG_WITH_EXAMPLE_PATH = None
|
||||||
|
|
||||||
|
|
||||||
|
def shared_mutation_config_path(*, include_example_repo: bool = False) -> str:
|
||||||
|
"""Write (once) a dual-remote mutation config and return its path."""
|
||||||
|
global _SHARED_CFG_PATH, _SHARED_CFG_DIR, _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||||
|
import atexit
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
if include_example_repo:
|
||||||
|
if _SHARED_CFG_WITH_EXAMPLE_PATH and os.path.isfile(
|
||||||
|
_SHARED_CFG_WITH_EXAMPLE_PATH
|
||||||
|
):
|
||||||
|
return _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||||
|
if _SHARED_CFG_DIR is None:
|
||||||
|
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||||
|
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||||
|
path = os.path.join(_SHARED_CFG_DIR, "profiles-with-example.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(build_dual_remote_config(include_example_repo=True), fh)
|
||||||
|
_SHARED_CFG_WITH_EXAMPLE_PATH = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
if _SHARED_CFG_PATH and os.path.isfile(_SHARED_CFG_PATH):
|
||||||
|
return _SHARED_CFG_PATH
|
||||||
|
if _SHARED_CFG_DIR is None:
|
||||||
|
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||||
|
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||||
|
_SHARED_CFG_PATH = os.path.join(_SHARED_CFG_DIR, "profiles.json")
|
||||||
|
with open(_SHARED_CFG_PATH, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(build_dual_remote_config(), fh)
|
||||||
|
return _SHARED_CFG_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def shared_mutation_env(
|
||||||
|
profile_name: str = "test-author-dadeschools",
|
||||||
|
*,
|
||||||
|
include_example_repo: bool = False,
|
||||||
|
**extra,
|
||||||
|
) -> dict:
|
||||||
|
"""Env dict for mutation tests: config-backed + optional extras.
|
||||||
|
|
||||||
|
Always carries ``PYTEST_CURRENT_TEST`` so ``patch.dict(..., clear=True)``
|
||||||
|
does not strip the pytest marker and trip the #695 native-transport wall.
|
||||||
|
"""
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": shared_mutation_config_path(
|
||||||
|
include_example_repo=include_example_repo
|
||||||
|
),
|
||||||
|
"GITEA_MCP_PROFILE": profile_name,
|
||||||
|
"GITEA_TOKEN_TEST": "test-token",
|
||||||
|
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||||
|
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
env.update(extra)
|
||||||
|
# Callers must not be able to drop the pytest marker by accident.
|
||||||
|
env.setdefault(
|
||||||
|
"PYTEST_CURRENT_TEST",
|
||||||
|
os.environ.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||||
|
)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def install_deterministic_remote_urls() -> None:
|
||||||
|
"""Patch server modules so workspace remotes are deterministic.
|
||||||
|
|
||||||
|
Prefer live ``REMOTES`` entries (so tests that patch Example-Org keep
|
||||||
|
workspace alignment). Map the historical prgs→Timesheet default to
|
||||||
|
Gitea-Tools so session binding matches the control repository under test.
|
||||||
|
"""
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
|
||||||
|
def _url(name: str):
|
||||||
|
try:
|
||||||
|
entry = srv.REMOTES.get(name) or {}
|
||||||
|
host = entry.get("host")
|
||||||
|
org = entry.get("org")
|
||||||
|
repo = entry.get("repo")
|
||||||
|
if host and org and repo:
|
||||||
|
if (
|
||||||
|
name == "prgs"
|
||||||
|
and org == "Scaled-Tech-Consulting"
|
||||||
|
and repo == "Timesheet"
|
||||||
|
):
|
||||||
|
return PRGS_URL
|
||||||
|
if name == "dadeschools" and repo == "Timesheet":
|
||||||
|
# Prefer mdcps eAgenda canonical for dual-config tests.
|
||||||
|
return MDCPS_URL
|
||||||
|
return f"https://{host}/{org}/{repo}.git"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if name == "prgs":
|
||||||
|
return PRGS_URL
|
||||||
|
if name == "dadeschools":
|
||||||
|
return MDCPS_URL
|
||||||
|
return None
|
||||||
|
|
||||||
|
srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||||
|
try:
|
||||||
|
import mcp_server as mcp_srv
|
||||||
|
|
||||||
|
mcp_srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
|
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -65,11 +69,9 @@ class TestPreflightWarnings(unittest.TestCase):
|
|||||||
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
|
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
|
||||||
# gitea.issue.comment (see task_capability_map), so the gate must be
|
# gitea.issue.comment (see task_capability_map), so the gate must be
|
||||||
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
|
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
|
||||||
ISSUE_WRITE_ENV = {
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
"test-author-prgs",
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
)
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestIssueLockArtifactWarning(unittest.TestCase):
|
class TestIssueLockArtifactWarning(unittest.TestCase):
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""Dependency parsing/resolution and allocator completeness tests (#758).
|
||||||
|
|
||||||
|
Covers the two defects behind #758:
|
||||||
|
|
||||||
|
* Defect 1 — candidate truncation before ranking, which let a result-size
|
||||||
|
parameter change the winner.
|
||||||
|
* Defect 2 — dependency state inferred from body substrings, which emitted
|
||||||
|
canonical ``Depends:`` blocked issues as eligible.
|
||||||
|
|
||||||
|
No production behavior is special-cased for any issue number (#758 AC14), so
|
||||||
|
these tests use synthetic issue numbers throughout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import allocator_dependencies
|
||||||
|
from allocator_service import (
|
||||||
|
OUTCOME_PREVIEW,
|
||||||
|
SELECTION_POLICY,
|
||||||
|
WorkCandidate,
|
||||||
|
allocate_next_work,
|
||||||
|
classify_skip,
|
||||||
|
sort_candidates,
|
||||||
|
)
|
||||||
|
from control_plane_db import ControlPlaneDB
|
||||||
|
|
||||||
|
# The canonical linkage line this repository writes into issue bodies.
|
||||||
|
CANONICAL_BODY = (
|
||||||
|
"## Dependencies and linkage\n\n"
|
||||||
|
"* Parent: #900 · Depends: #901, #902 · Related: #903, #904\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParseDependencyRefsTest(unittest.TestCase):
|
||||||
|
def test_parses_canonical_depends_field(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
allocator_dependencies.parse_dependency_refs(CANONICAL_BODY),
|
||||||
|
(901, 902),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stops_at_sibling_field_and_ignores_related(self) -> None:
|
||||||
|
""""Related:" refs must never be treated as dependencies."""
|
||||||
|
refs = allocator_dependencies.parse_dependency_refs(CANONICAL_BODY)
|
||||||
|
self.assertNotIn(903, refs)
|
||||||
|
self.assertNotIn(904, refs)
|
||||||
|
self.assertNotIn(900, refs) # Parent is not a dependency
|
||||||
|
|
||||||
|
def test_single_reference(self) -> None:
|
||||||
|
body = "* Parent: #10 · Depends: #11 · Related: #12"
|
||||||
|
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (11,))
|
||||||
|
|
||||||
|
def test_depends_on_spelling_and_and_separator(self) -> None:
|
||||||
|
body = "Depends on #21 and #22\n"
|
||||||
|
self.assertEqual(
|
||||||
|
allocator_dependencies.parse_dependency_refs(body), (21, 22)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_newline_terminates_declaration(self) -> None:
|
||||||
|
body = "Depends: #31, #32\nRelated: #33\n"
|
||||||
|
self.assertEqual(
|
||||||
|
allocator_dependencies.parse_dependency_refs(body), (31, 32)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_blocked_on_marker_still_recognized(self) -> None:
|
||||||
|
body = "This work is blocked on #41 until that lands.\n"
|
||||||
|
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (41,))
|
||||||
|
|
||||||
|
def test_dependencies_heading_alone_is_not_a_declaration(self) -> None:
|
||||||
|
""""Dependencies and linkage" must not parse as "Depends"."""
|
||||||
|
body = "## Dependencies and linkage\n\n* Related: #51\n"
|
||||||
|
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())
|
||||||
|
|
||||||
|
def test_deduplicates_and_preserves_order(self) -> None:
|
||||||
|
body = "Depends: #61, #62, #61\n"
|
||||||
|
self.assertEqual(
|
||||||
|
allocator_dependencies.parse_dependency_refs(body), (61, 62)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_malformed_and_empty_inputs(self) -> None:
|
||||||
|
for body in ("", None, "Depends:", "Depends: none", "Depends: TBD\n"):
|
||||||
|
self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveDependencyStateTest(unittest.TestCase):
|
||||||
|
def test_open_dependency_is_unmet(self) -> None:
|
||||||
|
result = allocator_dependencies.resolve_dependency_state(
|
||||||
|
(901, 902), lambda n: "open", subject="issue#644"
|
||||||
|
)
|
||||||
|
self.assertTrue(result["dependency_unmet"])
|
||||||
|
self.assertEqual(result["unmet"], (901, 902))
|
||||||
|
self.assertIn("#901", result["reason"])
|
||||||
|
|
||||||
|
def test_closed_dependencies_are_met(self) -> None:
|
||||||
|
result = allocator_dependencies.resolve_dependency_state(
|
||||||
|
(901, 902), lambda n: "closed"
|
||||||
|
)
|
||||||
|
self.assertFalse(result["dependency_unmet"])
|
||||||
|
self.assertEqual(result["met"], (901, 902))
|
||||||
|
self.assertIsNone(result["reason"])
|
||||||
|
|
||||||
|
def test_mixed_open_and_closed_is_unmet(self) -> None:
|
||||||
|
states = {901: "closed", 902: "open"}
|
||||||
|
result = allocator_dependencies.resolve_dependency_state(
|
||||||
|
(901, 902), states.get
|
||||||
|
)
|
||||||
|
self.assertTrue(result["dependency_unmet"])
|
||||||
|
self.assertEqual(result["unmet"], (902,))
|
||||||
|
self.assertEqual(result["met"], (901,))
|
||||||
|
|
||||||
|
def test_unavailable_evidence_fails_closed(self) -> None:
|
||||||
|
"""AC7: unknown state must block, never pass."""
|
||||||
|
result = allocator_dependencies.resolve_dependency_state(
|
||||||
|
(901,), lambda n: None
|
||||||
|
)
|
||||||
|
self.assertTrue(result["dependency_unmet"])
|
||||||
|
self.assertEqual(result["unavailable"], (901,))
|
||||||
|
self.assertIn("fail closed", result["reason"])
|
||||||
|
|
||||||
|
def test_raising_lookup_fails_closed(self) -> None:
|
||||||
|
def boom(_n: int) -> str:
|
||||||
|
raise RuntimeError("lookup exploded")
|
||||||
|
|
||||||
|
result = allocator_dependencies.resolve_dependency_state((901,), boom)
|
||||||
|
self.assertTrue(result["dependency_unmet"])
|
||||||
|
self.assertEqual(result["unavailable"], (901,))
|
||||||
|
|
||||||
|
def test_no_refs_is_eligible(self) -> None:
|
||||||
|
result = allocator_dependencies.resolve_dependency_state((), lambda n: None)
|
||||||
|
self.assertFalse(result["dependency_unmet"])
|
||||||
|
self.assertIsNone(result["reason"])
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyBlockedCandidateTest(unittest.TestCase):
|
||||||
|
"""A dependency-blocked candidate must be skipped, not selected."""
|
||||||
|
|
||||||
|
def test_classify_skip_rejects_unmet_dependency(self) -> None:
|
||||||
|
candidate = WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=644,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=20,
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason="issue#644 depends on unresolved issue(s) #633",
|
||||||
|
)
|
||||||
|
reason = classify_skip(candidate, role="author", terminal_pr=None)
|
||||||
|
self.assertIsNotNone(reason)
|
||||||
|
self.assertIn("#633", reason)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectionInvarianceTest(unittest.TestCase):
|
||||||
|
"""AC1/AC2/AC11: ranking sees everything; result bounds cannot move the winner."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ready_issue(number: int, **kw) -> WorkCandidate:
|
||||||
|
return WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=number,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=20,
|
||||||
|
title=f"issue {number}",
|
||||||
|
**kw,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _preview(self, candidates):
|
||||||
|
return allocate_next_work(
|
||||||
|
self.db,
|
||||||
|
session_id="s-758",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
candidates=candidates,
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_more_than_fifty_candidates_lowest_number_wins(self) -> None:
|
||||||
|
"""Winner is the oldest eligible issue across a >50 inventory."""
|
||||||
|
candidates = [self._ready_issue(n) for n in range(600, 700)] # 100 items
|
||||||
|
result = self._preview(candidates)
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(result["selected"]["number"], 600)
|
||||||
|
|
||||||
|
def test_selection_is_invariant_to_candidate_ordering(self) -> None:
|
||||||
|
"""Ranking must not depend on the order the inventory arrived in."""
|
||||||
|
forward = [self._ready_issue(n) for n in range(600, 700)]
|
||||||
|
reverse = list(reversed(forward))
|
||||||
|
self.assertEqual(
|
||||||
|
self._preview(forward)["selected"]["number"],
|
||||||
|
self._preview(reverse)["selected"]["number"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_truncating_inventory_changes_winner(self) -> None:
|
||||||
|
"""Regression guard: this is exactly what pre-ranking slicing did.
|
||||||
|
|
||||||
|
A 50-item slice of a 100-item inventory yields a different winner, so
|
||||||
|
any future reintroduction of pre-ranking truncation is detectable.
|
||||||
|
"""
|
||||||
|
full = [self._ready_issue(n) for n in range(600, 700)]
|
||||||
|
sliced = sorted(full, key=lambda c: -c.number)[:50]
|
||||||
|
self.assertNotEqual(
|
||||||
|
self._preview(full)["selected"]["number"],
|
||||||
|
self._preview(sliced)["selected"]["number"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_blocked_first_candidate_falls_through_to_next(self) -> None:
|
||||||
|
"""AC8: a blocked winner must not end the iteration."""
|
||||||
|
blocked = self._ready_issue(
|
||||||
|
600,
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason="issue#600 depends on unresolved issue(s) #599",
|
||||||
|
)
|
||||||
|
result = self._preview([blocked, self._ready_issue(601)])
|
||||||
|
self.assertEqual(result["selected"]["number"], 601)
|
||||||
|
skipped = {s["number"] for s in result["skipped"]}
|
||||||
|
self.assertIn(600, skipped)
|
||||||
|
|
||||||
|
def test_all_blocked_yields_no_safe_work(self) -> None:
|
||||||
|
candidates = [
|
||||||
|
self._ready_issue(
|
||||||
|
n, dependency_unmet=True, dependency_reason=f"issue#{n} blocked"
|
||||||
|
)
|
||||||
|
for n in range(600, 605)
|
||||||
|
]
|
||||||
|
result = self._preview(candidates)
|
||||||
|
self.assertIsNone(result["selected"])
|
||||||
|
self.assertEqual(len(result["skipped"]), 5)
|
||||||
|
|
||||||
|
def test_dry_run_and_apply_select_identically(self) -> None:
|
||||||
|
"""AC9: apply mode must not re-rank differently from preview."""
|
||||||
|
candidates = [self._ready_issue(n) for n in range(600, 700)]
|
||||||
|
preview = self._preview(candidates)
|
||||||
|
applied = allocate_next_work(
|
||||||
|
self.db,
|
||||||
|
session_id="s-758-apply",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
candidates=candidates,
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
preview["selected"]["number"], applied["selected"]["number"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sort_is_stable_and_documented(self) -> None:
|
||||||
|
ordered = sort_candidates(
|
||||||
|
[self._ready_issue(603), self._ready_issue(601), self._ready_issue(602)]
|
||||||
|
)
|
||||||
|
self.assertEqual([c.number for c in ordered], [601, 602, 603])
|
||||||
|
self.assertIn("never affect selection", SELECTION_POLICY)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""MCP-level allocator inventory and dependency regressions (#758).
|
||||||
|
|
||||||
|
Exercises ``_allocator_candidates_from_gitea`` and the ``gitea_allocate_next_work``
|
||||||
|
tool end to end against a faked Gitea API, proving:
|
||||||
|
|
||||||
|
* the complete open-issue inventory is ranked (no pre-ranking truncation);
|
||||||
|
* ``limit`` cannot change which candidate wins;
|
||||||
|
* canonical ``Depends:`` declarations are resolved from live issue state;
|
||||||
|
* unavailable dependency evidence fails closed;
|
||||||
|
* an incomplete listing fails closed instead of ranking a partial set.
|
||||||
|
|
||||||
|
Issue numbers here are synthetic; no production number is special-cased.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
from control_plane_db import ControlPlaneDB
|
||||||
|
|
||||||
|
FAKE_AUTH = "token REDACTED"
|
||||||
|
ORG = "Scaled-Tech-Consulting"
|
||||||
|
REPO = "Gitea-Tools"
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(number: int, *, body: str = "", labels=("status:ready",)) -> dict:
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"title": f"issue {number}",
|
||||||
|
"body": body,
|
||||||
|
"labels": [{"name": name} for name in labels],
|
||||||
|
"state": "open",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _depends_body(*refs: int) -> str:
|
||||||
|
joined = ", ".join(f"#{r}" for r in refs)
|
||||||
|
return f"## Dependencies and linkage\n\n* Parent: #999 · Depends: {joined}\n"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeGitea:
|
||||||
|
"""Minimal stand-in for the two Gitea list endpoints plus issue lookups."""
|
||||||
|
|
||||||
|
def __init__(self, issues, *, closed=(), unavailable=(), fail_issue_list=False):
|
||||||
|
self.issues = list(issues)
|
||||||
|
self.closed = set(closed)
|
||||||
|
self.unavailable = set(unavailable)
|
||||||
|
self.fail_issue_list = fail_issue_list
|
||||||
|
self.lookups: list[int] = []
|
||||||
|
|
||||||
|
def api_get_all(self, url, _auth, **_kw):
|
||||||
|
if "/pulls" in url:
|
||||||
|
return []
|
||||||
|
if self.fail_issue_list:
|
||||||
|
raise RuntimeError("issue listing failed")
|
||||||
|
return list(self.issues)
|
||||||
|
|
||||||
|
def api_request(self, _method, url, _auth, **_kw):
|
||||||
|
number = int(url.rsplit("/", 1)[-1])
|
||||||
|
self.lookups.append(number)
|
||||||
|
if number in self.unavailable:
|
||||||
|
raise RuntimeError("lookup failed")
|
||||||
|
if number in self.closed:
|
||||||
|
return {"number": number, "state": "closed"}
|
||||||
|
return {"number": number, "state": "open"}
|
||||||
|
|
||||||
|
|
||||||
|
class AllocatorInventoryTest(unittest.TestCase):
|
||||||
|
"""Direct tests of the candidate loader."""
|
||||||
|
|
||||||
|
def _load(self, fake, **kwargs):
|
||||||
|
with patch("gitea_mcp_server._resolve", return_value=("h", ORG, REPO)), patch(
|
||||||
|
"gitea_mcp_server._auth", return_value=FAKE_AUTH
|
||||||
|
), patch("gitea_mcp_server.api_get_all", side_effect=fake.api_get_all), patch(
|
||||||
|
"gitea_mcp_server.api_request", side_effect=fake.api_request
|
||||||
|
):
|
||||||
|
return srv._allocator_candidates_from_gitea(
|
||||||
|
remote="prgs", host=None, org=ORG, repo=REPO, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_inventory_above_fifty_is_ranked(self) -> None:
|
||||||
|
"""AC1: all 73 open issues become candidates, not the first 50."""
|
||||||
|
fake = _FakeGitea([_issue(n) for n in range(600, 673)])
|
||||||
|
candidates, _reasons, complete = self._load(fake)
|
||||||
|
self.assertTrue(complete)
|
||||||
|
self.assertEqual(len(candidates), 73)
|
||||||
|
self.assertEqual(min(c.number for c in candidates), 600)
|
||||||
|
self.assertEqual(max(c.number for c in candidates), 672)
|
||||||
|
|
||||||
|
def test_open_dependency_marks_candidate_unmet(self) -> None:
|
||||||
|
"""AC4/AC5/AC6: canonical Depends on an open issue blocks the candidate."""
|
||||||
|
fake = _FakeGitea(
|
||||||
|
[_issue(600, body=_depends_body(601, 602)), _issue(601), _issue(602)]
|
||||||
|
)
|
||||||
|
candidates, _reasons, _complete = self._load(fake)
|
||||||
|
blocked = next(c for c in candidates if c.number == 600)
|
||||||
|
self.assertTrue(blocked.dependency_unmet)
|
||||||
|
self.assertIn("#601", blocked.dependency_reason)
|
||||||
|
|
||||||
|
def test_closed_dependency_is_eligible(self) -> None:
|
||||||
|
"""A dependency absent from the open list is confirmed closed, not assumed."""
|
||||||
|
fake = _FakeGitea([_issue(600, body=_depends_body(500))], closed={500})
|
||||||
|
candidates, _reasons, _complete = self._load(fake)
|
||||||
|
candidate = next(c for c in candidates if c.number == 600)
|
||||||
|
self.assertFalse(candidate.dependency_unmet)
|
||||||
|
self.assertIn(500, fake.lookups) # proved live, not inferred
|
||||||
|
|
||||||
|
def test_unavailable_dependency_evidence_fails_closed(self) -> None:
|
||||||
|
"""AC7: an unreachable dependency must block, never pass."""
|
||||||
|
fake = _FakeGitea([_issue(600, body=_depends_body(500))], unavailable={500})
|
||||||
|
candidates, _reasons, _complete = self._load(fake)
|
||||||
|
candidate = next(c for c in candidates if c.number == 600)
|
||||||
|
self.assertTrue(candidate.dependency_unmet)
|
||||||
|
self.assertIn("fail closed", candidate.dependency_reason)
|
||||||
|
|
||||||
|
def test_dependency_state_lookups_are_cached(self) -> None:
|
||||||
|
"""Repeated references resolve with a single live lookup."""
|
||||||
|
fake = _FakeGitea(
|
||||||
|
[_issue(n, body=_depends_body(500)) for n in range(600, 610)],
|
||||||
|
closed={500},
|
||||||
|
)
|
||||||
|
self._load(fake)
|
||||||
|
self.assertEqual(fake.lookups.count(500), 1)
|
||||||
|
|
||||||
|
def test_open_dependency_needs_no_lookup(self) -> None:
|
||||||
|
"""The complete open listing already proves openness."""
|
||||||
|
fake = _FakeGitea([_issue(600, body=_depends_body(601)), _issue(601)])
|
||||||
|
self._load(fake)
|
||||||
|
self.assertNotIn(601, fake.lookups)
|
||||||
|
|
||||||
|
def test_failed_issue_listing_reports_incomplete(self) -> None:
|
||||||
|
"""AC3: a failed listing must not silently yield a short inventory."""
|
||||||
|
fake = _FakeGitea([], fail_issue_list=True)
|
||||||
|
_candidates, reasons, complete = self._load(fake)
|
||||||
|
self.assertFalse(complete)
|
||||||
|
self.assertTrue(any("failed to list open issues" in r for r in reasons))
|
||||||
|
|
||||||
|
|
||||||
|
class AllocateNextWorkToolTest(unittest.TestCase):
|
||||||
|
"""End-to-end tests of the gitea_allocate_next_work MCP tool."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _allocate(self, fake, **kwargs):
|
||||||
|
with patch("gitea_mcp_server._profile_operation_gate", return_value=None), patch(
|
||||||
|
"gitea_mcp_server._resolve", return_value=("h", ORG, REPO)
|
||||||
|
), patch("gitea_mcp_server._auth", return_value=FAKE_AUTH), patch(
|
||||||
|
"gitea_mcp_server.get_profile",
|
||||||
|
return_value={"profile_name": "prgs-author", "role": "author"},
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.api_get_all", side_effect=fake.api_get_all
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.api_request", side_effect=fake.api_request
|
||||||
|
), patch(
|
||||||
|
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
|
||||||
|
):
|
||||||
|
return srv.gitea_allocate_next_work(
|
||||||
|
remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_limit_does_not_change_selection(self) -> None:
|
||||||
|
"""AC2/AC11: the winner is identical at limit=1 and limit=300."""
|
||||||
|
issues = [_issue(n) for n in range(600, 673)] # 73 candidates
|
||||||
|
low = self._allocate(_FakeGitea(issues), limit=1)
|
||||||
|
high = self._allocate(_FakeGitea(issues), limit=300)
|
||||||
|
self.assertEqual(low["selected"]["number"], high["selected"]["number"])
|
||||||
|
self.assertEqual(low["selected"]["number"], 600)
|
||||||
|
self.assertEqual(low["candidate_count"], 73)
|
||||||
|
self.assertEqual(high["candidate_count"], 73)
|
||||||
|
|
||||||
|
def test_dependency_blocked_winner_falls_through(self) -> None:
|
||||||
|
"""AC8: a blocked highest-ranked issue yields the next eligible one."""
|
||||||
|
issues = [
|
||||||
|
_issue(600, body=_depends_body(601)),
|
||||||
|
_issue(601),
|
||||||
|
_issue(602),
|
||||||
|
]
|
||||||
|
result = self._allocate(_FakeGitea(issues))
|
||||||
|
# 600 is blocked by open 601; 601 itself is a valid candidate.
|
||||||
|
self.assertEqual(result["selected"]["number"], 601)
|
||||||
|
skipped = {s["number"] for s in result["skipped"]}
|
||||||
|
self.assertIn(600, skipped)
|
||||||
|
|
||||||
|
def test_limit_truncates_only_the_reported_skip_list(self) -> None:
|
||||||
|
"""A shortened report is labelled, never presented as full coverage."""
|
||||||
|
issues = [_issue(n, body=_depends_body(999)) for n in range(600, 640)]
|
||||||
|
issues.append(_issue(999)) # open dependency blocks all of the above
|
||||||
|
issues.append(_issue(700)) # the one eligible candidate
|
||||||
|
result = self._allocate(_FakeGitea(issues), limit=5)
|
||||||
|
self.assertTrue(result["skipped_report_truncated"])
|
||||||
|
self.assertEqual(len(result["skipped"]), 5)
|
||||||
|
self.assertGreater(result["skipped_total"], 5)
|
||||||
|
self.assertEqual(result["limit_applies_to"], "reported_skip_list_only")
|
||||||
|
|
||||||
|
def test_incomplete_inventory_fails_closed(self) -> None:
|
||||||
|
"""AC3: no selection is made from a partial candidate set."""
|
||||||
|
result = self._allocate(_FakeGitea([], fail_issue_list=True))
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["inventory_complete"])
|
||||||
|
self.assertIsNone(result["assignment"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("fail closed" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selection_policy_is_reported(self) -> None:
|
||||||
|
"""AC10: tie-breaking is stated in the result, not left implicit."""
|
||||||
|
result = self._allocate(_FakeGitea([_issue(600)]))
|
||||||
|
self.assertIn("selection_policy", result)
|
||||||
|
self.assertIn("number asc", result["selection_policy"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"""Tests for controller-owned allocator (#600) on control-plane DB (#613)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
from allocator_service import (
|
||||||
|
OUTCOME_ASSIGNED,
|
||||||
|
OUTCOME_BLOCKED_TERMINAL,
|
||||||
|
OUTCOME_NO_SAFE,
|
||||||
|
OUTCOME_PREVIEW,
|
||||||
|
OUTCOME_WAIT,
|
||||||
|
WorkCandidate,
|
||||||
|
allocate_next_work,
|
||||||
|
candidate_from_dict,
|
||||||
|
classify_skip,
|
||||||
|
expected_role_for_candidate,
|
||||||
|
)
|
||||||
|
from control_plane_db import ControlPlaneDB, InvalidWorkKindError
|
||||||
|
|
||||||
|
|
||||||
|
class AllocatorServiceTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||||
|
self.db = ControlPlaneDB(self.db_path)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _alloc(self, **kwargs):
|
||||||
|
defaults = dict(
|
||||||
|
db=self.db,
|
||||||
|
session_id="s-test",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
candidates=[],
|
||||||
|
apply=False,
|
||||||
|
profile_name="prgs-author",
|
||||||
|
username="jcwalker3",
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return allocate_next_work(**defaults)
|
||||||
|
|
||||||
|
def test_selects_ready_issue_for_author(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=612,
|
||||||
|
labels=("status:ready",),
|
||||||
|
title="bridge",
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason="downstream of #600",
|
||||||
|
),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=600,
|
||||||
|
labels=("status:ready", "type:feature"),
|
||||||
|
title="allocator",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=601,
|
||||||
|
labels=("status:blocked",),
|
||||||
|
title="blocked",
|
||||||
|
blocked=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands, apply=False)
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(res["selected"]["number"], 600)
|
||||||
|
self.assertEqual(res["selected"]["kind"], "issue")
|
||||||
|
# When 600 is highest priority valid work, lower-priority blocked/dep
|
||||||
|
# candidates are not visited. Prove they are skipped when they sort first.
|
||||||
|
res2 = self._alloc(
|
||||||
|
candidates=[
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=612,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=99,
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason="downstream of #600",
|
||||||
|
),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=601,
|
||||||
|
labels=("status:blocked",),
|
||||||
|
priority=98,
|
||||||
|
blocked=True,
|
||||||
|
),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=600,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
skipped_nums = {s["number"] for s in res2["skipped"]}
|
||||||
|
self.assertIn(612, skipped_nums)
|
||||||
|
self.assertIn(601, skipped_nums)
|
||||||
|
self.assertEqual(res2["selected"]["number"], 600)
|
||||||
|
self.assertIsNone(res["assignment"])
|
||||||
|
self.assertFalse(res["file_lock_only"])
|
||||||
|
self.assertFalse(res["comment_lease_only"])
|
||||||
|
|
||||||
|
def test_atomic_assign_and_lease_on_apply(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=600,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=10,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands, apply=True, session_id="s-a")
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||||
|
asn = res["assignment"]
|
||||||
|
self.assertEqual(asn["outcome"], "assigned")
|
||||||
|
self.assertIsNotNone(asn["assignment_id"])
|
||||||
|
self.assertIsNotNone(asn["lease_id"])
|
||||||
|
self.assertEqual(asn["work_number"], 600)
|
||||||
|
self.assertIn("implement", asn["allowed_actions"])
|
||||||
|
self.assertIn("merge", asn["forbidden_actions"])
|
||||||
|
proof = res["lease_proof"]
|
||||||
|
self.assertEqual(proof["source"], "control_plane_db.assign_and_lease")
|
||||||
|
|
||||||
|
def test_concurrent_allocators_no_double_assign(self) -> None:
|
||||||
|
cand = WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=100,
|
||||||
|
head_sha="a" * 40,
|
||||||
|
priority=10,
|
||||||
|
)
|
||||||
|
# Seed work item so both race the same target
|
||||||
|
self.db.upsert_work_item(
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
kind="pr",
|
||||||
|
number=100,
|
||||||
|
current_head_sha="a" * 40,
|
||||||
|
)
|
||||||
|
results = []
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def worker(sid: str):
|
||||||
|
r = allocate_next_work(
|
||||||
|
self.db,
|
||||||
|
session_id=sid,
|
||||||
|
role="reviewer",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
candidates=[cand],
|
||||||
|
apply=True,
|
||||||
|
profile_name="prgs-reviewer",
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
results.append(r)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
futs = [pool.submit(worker, f"s-{i}") for i in range(2)]
|
||||||
|
for f in as_completed(futs):
|
||||||
|
f.result()
|
||||||
|
|
||||||
|
outcomes = [r["outcome"] for r in results]
|
||||||
|
self.assertEqual(outcomes.count(OUTCOME_ASSIGNED), 1, outcomes)
|
||||||
|
self.assertEqual(outcomes.count(OUTCOME_WAIT), 1, outcomes)
|
||||||
|
|
||||||
|
def test_blocked_and_dependency_skipped(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(kind="issue", number=1, blocked=True, labels=("status:blocked",)),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=612,
|
||||||
|
labels=("status:ready",),
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason="downstream of #600",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands, apply=True, role="author")
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
|
||||||
|
self.assertIsNone(res["selected"])
|
||||||
|
reasons = " ".join(s["reason"] for s in res["skipped"])
|
||||||
|
self.assertIn("blocked", reasons.lower())
|
||||||
|
self.assertIn("600", reasons)
|
||||||
|
|
||||||
|
def test_already_leased_returns_wait(self) -> None:
|
||||||
|
self.db.upsert_session(session_id="owner", role="author")
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="owner",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
kind="issue",
|
||||||
|
number=50,
|
||||||
|
)
|
||||||
|
res = self._alloc(
|
||||||
|
session_id="other",
|
||||||
|
candidates=[
|
||||||
|
WorkCandidate(kind="issue", number=50, labels=("status:ready",))
|
||||||
|
],
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_WAIT)
|
||||||
|
self.assertEqual(res["owner_session_id"], "owner")
|
||||||
|
|
||||||
|
def test_role_ineligible_skipped(self) -> None:
|
||||||
|
# PR with current-head REQUEST_CHANGES expects author, not reviewer.
|
||||||
|
cand = WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=9,
|
||||||
|
head_sha="b" * 40,
|
||||||
|
request_changes_current_head=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(expected_role_for_candidate(cand), "author")
|
||||||
|
res = self._alloc(
|
||||||
|
role="reviewer",
|
||||||
|
profile_name="prgs-reviewer",
|
||||||
|
candidates=[cand],
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
|
||||||
|
self.assertTrue(any("expects role" in s["reason"] for s in res["skipped"]))
|
||||||
|
|
||||||
|
def test_terminal_lock_blocks_downstream_reviewer_prs(self) -> None:
|
||||||
|
self.db.set_terminal_lock(
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
terminal_pr=10,
|
||||||
|
decision="request_changes",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(kind="pr", number=11, head_sha="c" * 40, priority=5),
|
||||||
|
WorkCandidate(kind="pr", number=10, head_sha="d" * 40, priority=1),
|
||||||
|
]
|
||||||
|
res = self._alloc(
|
||||||
|
role="reviewer",
|
||||||
|
profile_name="prgs-reviewer",
|
||||||
|
candidates=cands,
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
# Terminal PR #10 is selectable; #11 skipped for terminal path.
|
||||||
|
self.assertEqual(res["selected"]["number"], 10)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
s["number"] == 11 and "terminal-review lock" in s["reason"]
|
||||||
|
for s in res["skipped"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_terminal_lock_blocks_all_when_only_downstream(self) -> None:
|
||||||
|
self.db.set_terminal_lock(
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
terminal_pr=10,
|
||||||
|
decision="request_changes",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
res = self._alloc(
|
||||||
|
role="reviewer",
|
||||||
|
profile_name="prgs-reviewer",
|
||||||
|
candidates=[
|
||||||
|
WorkCandidate(kind="pr", number=99, head_sha="e" * 40),
|
||||||
|
],
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_BLOCKED_TERMINAL)
|
||||||
|
|
||||||
|
def test_no_work_structured(self) -> None:
|
||||||
|
res = self._alloc(candidates=[], apply=True)
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
|
||||||
|
self.assertIsNone(res["selected"])
|
||||||
|
self.assertTrue(res["reasons"])
|
||||||
|
|
||||||
|
def test_rejects_incident_kind(self) -> None:
|
||||||
|
with self.assertRaises(InvalidWorkKindError):
|
||||||
|
WorkCandidate(kind="sentry_incident", number=1)
|
||||||
|
|
||||||
|
def test_612_downstream_marker_in_result(self) -> None:
|
||||||
|
res = self._alloc(
|
||||||
|
candidates=[
|
||||||
|
WorkCandidate(kind="issue", number=600, labels=("status:ready",))
|
||||||
|
],
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
self.assertIn("612", res.get("downstream_note", ""))
|
||||||
|
|
||||||
|
def test_substrate_not_file_or_comment_lease(self) -> None:
|
||||||
|
res = self._alloc(
|
||||||
|
candidates=[
|
||||||
|
WorkCandidate(kind="issue", number=1, labels=("status:ready",))
|
||||||
|
],
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["substrate"], "control_plane_db")
|
||||||
|
self.assertFalse(res["file_lock_only"])
|
||||||
|
self.assertFalse(res["comment_lease_only"])
|
||||||
|
self.assertEqual(
|
||||||
|
res["lease_proof"]["source"], "control_plane_db.assign_and_lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_candidate_from_dict(self) -> None:
|
||||||
|
c = candidate_from_dict(
|
||||||
|
{
|
||||||
|
"kind": "pr",
|
||||||
|
"number": 7,
|
||||||
|
"head_sha": "f" * 40,
|
||||||
|
"approval_on_current_head": True,
|
||||||
|
"mergeable": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(expected_role_for_candidate(c), "merger")
|
||||||
|
|
||||||
|
def test_merger_gets_clean_approval(self) -> None:
|
||||||
|
c = WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=3,
|
||||||
|
head_sha="1" * 40,
|
||||||
|
approval_on_current_head=True,
|
||||||
|
mergeable=True,
|
||||||
|
priority=100,
|
||||||
|
)
|
||||||
|
res = self._alloc(
|
||||||
|
role="merger",
|
||||||
|
profile_name="prgs-merger",
|
||||||
|
candidates=[c],
|
||||||
|
apply=True,
|
||||||
|
session_id="merger-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||||
|
self.assertEqual(res["assignment"]["work_number"], 3)
|
||||||
|
self.assertIn("merge", res["assignment"]["allowed_actions"])
|
||||||
|
|
||||||
|
def test_db_unavailable_fails_closed(self) -> None:
|
||||||
|
res = allocate_next_work(
|
||||||
|
None, # type: ignore[arg-type]
|
||||||
|
session_id="x",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
candidates=[],
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertIn("unavailable", res["reasons"][0].lower())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -79,41 +79,46 @@ class TestApiRequestFailures(unittest.TestCase):
|
|||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_timeout_converted_to_runtimeerror(self, mock_open):
|
def test_timeout_converted_to_runtimeerror(self, mock_open):
|
||||||
mock_open.side_effect = TimeoutError("timed out")
|
mock_open.side_effect = TimeoutError("timed out")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
# Fixed message only (#699) — still a RuntimeError subclass.
|
||||||
|
self.assertIsInstance(ctx.exception, RuntimeError)
|
||||||
|
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_dns_network_failure_converted(self, mock_open):
|
def test_dns_network_failure_converted(self, mock_open):
|
||||||
mock_open.side_effect = urllib.error.URLError("Name or service not known")
|
mock_open.side_effect = urllib.error.URLError("Name or service not known")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_502_upstream_message(self, mock_open):
|
def test_502_upstream_message(self, mock_open):
|
||||||
mock_open.side_effect = http_error(502, "bad gateway")
|
mock_open.side_effect = http_error(502, "bad gateway")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
msg = str(ctx.exception)
|
msg = str(ctx.exception)
|
||||||
self.assertIn("HTTP 502", msg)
|
self.assertEqual(msg, "Gitea upstream unavailable")
|
||||||
self.assertIn("upstream unavailable", msg)
|
self.assertEqual(ctx.exception.reason_code, "upstream_unavailable")
|
||||||
|
self.assertEqual(ctx.exception.http_status, 502)
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_503_upstream_message(self, mock_open):
|
def test_503_upstream_message(self, mock_open):
|
||||||
mock_open.side_effect = http_error(503, "")
|
mock_open.side_effect = http_error(503, "")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
self.assertIn("HTTP 503", str(ctx.exception))
|
self.assertEqual(str(ctx.exception), "Gitea upstream unavailable")
|
||||||
self.assertIn("upstream unavailable", str(ctx.exception))
|
self.assertEqual(ctx.exception.http_status, 503)
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_malformed_error_payload_does_not_crash(self, mock_open):
|
def test_malformed_error_payload_does_not_crash(self, mock_open):
|
||||||
# Non-JSON garbage error body must still yield a clean RuntimeError.
|
# Non-JSON garbage error body must still yield a clean typed error
|
||||||
|
# with a fixed message (no body echo — #699).
|
||||||
mock_open.side_effect = http_error(500, "<html>garbage</html>")
|
mock_open.side_effect = http_error(500, "<html>garbage</html>")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
self.assertIn("HTTP 500", str(ctx.exception))
|
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
|
||||||
|
self.assertNotIn("<html>", str(ctx.exception))
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_malformed_success_json_raises_clean_error(self, mock_open):
|
def test_malformed_success_json_raises_clean_error(self, mock_open):
|
||||||
@@ -126,16 +131,17 @@ class TestApiRequestFailures(unittest.TestCase):
|
|||||||
def test_no_secret_leak_in_error_body(self, mock_open):
|
def test_no_secret_leak_in_error_body(self, mock_open):
|
||||||
mock_open.side_effect = http_error(
|
mock_open.side_effect = http_error(
|
||||||
400, "failed: token supersecret123 rejected")
|
400, "failed: token supersecret123 rejected")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
msg = str(ctx.exception)
|
msg = str(ctx.exception)
|
||||||
self.assertNotIn("supersecret123", msg)
|
self.assertNotIn("supersecret123", msg)
|
||||||
self.assertIn(gitea_audit.REDACTED, msg)
|
# Fixed message — body never appears (stronger than redaction).
|
||||||
|
self.assertEqual(msg, "Gitea HTTP request failed")
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_auth_header_never_in_error(self, mock_open):
|
def test_auth_header_never_in_error(self, mock_open):
|
||||||
mock_open.side_effect = http_error(400, "bad request")
|
mock_open.side_effect = http_error(400, "bad request")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|||||||
+55
-21
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
|
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
|
||||||
|
|
||||||
Covers the pure audit module (redaction, event building, sink writes) and the
|
Covers the pure audit module (redaction, event building, sink writes) and the
|
||||||
@@ -161,11 +165,23 @@ class _AuditWiringBase(unittest.TestCase):
|
|||||||
self._dir.cleanup()
|
self._dir.cleanup()
|
||||||
|
|
||||||
def _env(self, **extra):
|
def _env(self, **extra):
|
||||||
env = {"GITEA_AUDIT_LOG": self.audit_path,
|
# Default: prgs-aligned author with create/close (remote=prgs tests).
|
||||||
"GITEA_PROFILE_NAME": "gitea-author",
|
# Callers override GITEA_MCP_PROFILE for merger/reviewer paths.
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
profile = extra.pop("GITEA_MCP_PROFILE", None) or extra.pop(
|
||||||
"read,merge,gitea.issue.create,gitea.issue.close")}
|
"GITEA_PROFILE_NAME", None
|
||||||
|
) or "test-author-prgs"
|
||||||
|
env = shared_mutation_env(
|
||||||
|
profile,
|
||||||
|
GITEA_AUDIT_LOG=self.audit_path,
|
||||||
|
GITEA_TOKEN_TEST="test-token",
|
||||||
|
)
|
||||||
|
# Strip legacy env-only authority knobs if callers still pass them;
|
||||||
|
# config-backed profiles own operations and repositories (#714).
|
||||||
|
extra.pop("GITEA_ALLOWED_OPERATIONS", None)
|
||||||
|
extra.pop("GITEA_FORBIDDEN_OPERATIONS", None)
|
||||||
|
extra.pop("GITEA_PROFILE_NAME", None)
|
||||||
env.update(extra)
|
env.update(extra)
|
||||||
|
env["GITEA_MCP_PROFILE"] = profile
|
||||||
return env
|
return env
|
||||||
|
|
||||||
def _records(self):
|
def _records(self):
|
||||||
@@ -196,7 +212,7 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
|||||||
rec = recs[0]
|
rec = recs[0]
|
||||||
self.assertEqual(rec["action"], "create_issue")
|
self.assertEqual(rec["action"], "create_issue")
|
||||||
self.assertEqual(rec["result"], "succeeded")
|
self.assertEqual(rec["result"], "succeeded")
|
||||||
self.assertEqual(rec["profile_name"], "gitea-author")
|
self.assertIn(rec["profile_name"], ("gitea-author", "test-author-prgs", "prgs-author"))
|
||||||
self.assertEqual(rec["authenticated_username"], "author-bot")
|
self.assertEqual(rec["authenticated_username"], "author-bot")
|
||||||
self.assertEqual(rec["issue_number"], 11)
|
self.assertEqual(rec["issue_number"], 11)
|
||||||
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
|
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
|
||||||
@@ -239,10 +255,11 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
|||||||
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
|
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
|
||||||
# No GITEA_AUDIT_LOG -> audit is a no-op: one create POST, no file.
|
# No GITEA_AUDIT_LOG -> audit is a no-op: one create POST, no file.
|
||||||
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
|
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
|
||||||
with patch.dict(os.environ, {
|
with patch.dict(
|
||||||
"GITEA_PROFILE_NAME": "gitea-author",
|
os.environ,
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
shared_mutation_env("test-author-prgs"),
|
||||||
}, clear=True):
|
clear=True,
|
||||||
|
):
|
||||||
gitea_create_issue(title="x", remote="prgs")
|
gitea_create_issue(title="x", remote="prgs")
|
||||||
issue_posts = [
|
issue_posts = [
|
||||||
c for c in mock_api.call_args_list if c.args[0] == "POST"
|
c for c in mock_api.call_args_list if c.args[0] == "POST"
|
||||||
@@ -329,17 +346,20 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_merge_success_audited(self, _auth, mock_api):
|
def test_merge_success_audited(self, _auth, mock_api):
|
||||||
# user, pr, feedback pr+reviews, merge POST, readback.
|
# user, pr, eligibility feedback pr+reviews (#695), gate-7 feedback
|
||||||
|
# pr+reviews, merge POST, readback.
|
||||||
|
approval = [{
|
||||||
|
"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||||
|
"commit_id": "abc123", "submitted_at": "2026-07-06T10:00:00Z",
|
||||||
|
"dismissed": False,
|
||||||
|
}]
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
{"login": "merger-bot"}, self._pr("author-bot"),
|
{"login": "merger-bot"}, self._pr("author-bot"),
|
||||||
self._pr("author-bot"),
|
self._pr("author-bot"), approval, # eligibility merge feedback
|
||||||
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
self._pr("author-bot"), approval, # gate 7 feedback
|
||||||
"commit_id": "abc123", "submitted_at": "2026-07-06T10:00:00Z",
|
|
||||||
"dismissed": False}],
|
|
||||||
{}, {"merged_commit_sha": "c1"},
|
{}, {"merged_commit_sha": "c1"},
|
||||||
]
|
]
|
||||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_load_review_workflow()
|
mcp_server.gitea_load_review_workflow()
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
||||||
@@ -358,8 +378,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
def test_merge_blocked_audited(self, _auth, mock_api):
|
def test_merge_blocked_audited(self, _auth, mock_api):
|
||||||
# Self-author merge is blocked; must still be recorded as blocked.
|
# Self-author merge is blocked; must still be recorded as blocked.
|
||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_load_review_workflow()
|
mcp_server.gitea_load_review_workflow()
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
||||||
@@ -381,11 +400,23 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||||
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
||||||
]
|
]
|
||||||
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
env = self._env(GITEA_MCP_PROFILE="test-reviewer-prgs")
|
||||||
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
import session_context_binding as _sc
|
||||||
|
from tests.test_mcp_server import (
|
||||||
|
_init_reviewer_session,
|
||||||
|
_install_owned_reviewer_lease,
|
||||||
|
)
|
||||||
from mcp_server import gitea_mark_final_review_decision
|
from mcp_server import gitea_mark_final_review_decision
|
||||||
|
|
||||||
|
# Rebind after profile env is applied so durable session state
|
||||||
|
# matches the active reviewer profile (#714 / #695).
|
||||||
|
_sc._reset_session_context_for_testing()
|
||||||
|
_init_reviewer_session("prgs")
|
||||||
|
lease = _install_owned_reviewer_lease(8)
|
||||||
|
lease.start()
|
||||||
|
self.addCleanup(lease.stop)
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
gitea_mark_final_review_decision(
|
gitea_mark_final_review_decision(
|
||||||
8, "approve", expected_head_sha="abc123", remote="prgs",
|
8, "approve", expected_head_sha="abc123", remote="prgs",
|
||||||
)
|
)
|
||||||
@@ -394,7 +425,10 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
body="LGTM", remote="prgs",
|
body="LGTM", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
)
|
)
|
||||||
self.assertTrue(r["performed"])
|
self.assertTrue(
|
||||||
|
r["performed"],
|
||||||
|
msg=f"submit_pr_review blocked: {r}",
|
||||||
|
)
|
||||||
recs = self._records()
|
recs = self._records()
|
||||||
self.assertEqual(len(recs), 1)
|
self.assertEqual(len(recs), 1)
|
||||||
self.assertEqual(recs[0]["action"], "submit_pr_review")
|
self.assertEqual(recs[0]["action"], "submit_pr_review")
|
||||||
|
|||||||
@@ -26,8 +26,15 @@ from review_proofs import assess_audit_reconciliation_report as proofs_assess
|
|||||||
from task_capability_map import required_permission, required_role
|
from task_capability_map import required_permission, required_role
|
||||||
|
|
||||||
DELETE_PROFILE = {
|
DELETE_PROFILE = {
|
||||||
"profile_name": "prgs-author-delete",
|
# #729: delete_branch is reconciler-owned.
|
||||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
"profile_name": "prgs-reconciler-delete",
|
||||||
|
"role": "reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"audit_label": "prgs-author-delete",
|
"audit_label": "prgs-author-delete",
|
||||||
}
|
}
|
||||||
@@ -232,7 +239,7 @@ class TestMcpGates(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||||
def test_delete_branch_blocked_in_audit_phase(self, _profile):
|
def test_delete_branch_blocked_in_audit_phase(self, _profile):
|
||||||
mcp_server.record_preflight_check("whoami")
|
mcp_server.record_preflight_check("whoami")
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
|
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
|
||||||
@@ -272,7 +279,7 @@ class TestMcpGates(unittest.TestCase):
|
|||||||
after_state="gone",
|
after_state="gone",
|
||||||
)
|
)
|
||||||
mcp_server.record_preflight_check("whoami")
|
mcp_server.record_preflight_check("whoami")
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||||
self.mock_api.return_value = {}
|
self.mock_api.return_value = {}
|
||||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
|
|||||||
@@ -79,17 +79,33 @@ class TestPreflightIntegration(unittest.TestCase):
|
|||||||
mcp_server._preflight_whoami_called = True
|
mcp_server._preflight_whoami_called = True
|
||||||
mcp_server._preflight_capability_called = True
|
mcp_server._preflight_capability_called = True
|
||||||
mcp_server._preflight_resolved_role = "author"
|
mcp_server._preflight_resolved_role = "author"
|
||||||
|
mcp_server._preflight_resolved_task = None
|
||||||
control_root = "/repo/Gitea-Tools"
|
control_root = "/repo/Gitea-Tools"
|
||||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
||||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||||
with mock.patch.dict(
|
with mock.patch(
|
||||||
"os.environ",
|
"gitea_mcp_server._session_author_lock_worktree",
|
||||||
{"GITEA_TEST_PORCELAIN": ""},
|
return_value=None,
|
||||||
clear=False,
|
|
||||||
):
|
):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with mock.patch(
|
||||||
mcp_server.verify_preflight_purity()
|
"gitea_auth.get_profile",
|
||||||
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
return_value={"profile_name": "gitea-author"},
|
||||||
|
):
|
||||||
|
with mock.patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"GITEA_TEST_PORCELAIN": ""},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
mcp_server.verify_preflight_purity()
|
||||||
|
blob = str(ctx.exception)
|
||||||
|
self.assertTrue(
|
||||||
|
"Branches-only mutation guard" in blob
|
||||||
|
or "control checkout" in blob
|
||||||
|
or "author worktree" in blob.lower()
|
||||||
|
or "#618" in blob,
|
||||||
|
msg=blob,
|
||||||
|
)
|
||||||
|
|
||||||
def test_verify_preflight_allows_branches_worktree(self):
|
def test_verify_preflight_allows_branches_worktree(self):
|
||||||
import mcp_server
|
import mcp_server
|
||||||
@@ -97,14 +113,46 @@ class TestPreflightIntegration(unittest.TestCase):
|
|||||||
mcp_server._preflight_whoami_called = True
|
mcp_server._preflight_whoami_called = True
|
||||||
mcp_server._preflight_capability_called = True
|
mcp_server._preflight_capability_called = True
|
||||||
mcp_server._preflight_resolved_role = "author"
|
mcp_server._preflight_resolved_role = "author"
|
||||||
|
mcp_server._preflight_resolved_task = None
|
||||||
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
||||||
|
healthy_ctx = {
|
||||||
|
"workspace_path": worktree,
|
||||||
|
"workspace_binding_source": "worktree_path argument",
|
||||||
|
"workspace_role_kind": "author",
|
||||||
|
"ignored_bindings": [],
|
||||||
|
"process_project_root": "/repo/Gitea-Tools",
|
||||||
|
"canonical_repo_root": "/repo/Gitea-Tools",
|
||||||
|
"roots_aligned": True,
|
||||||
|
"bound_worktree_missing": False,
|
||||||
|
"author_worktree_block": False,
|
||||||
|
"author_worktree_reasons": [],
|
||||||
|
"author_worktree_resolution": {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"bound_worktree_missing": False,
|
||||||
|
"workspace_path": worktree,
|
||||||
|
"workspace_binding_source": "worktree_path argument",
|
||||||
|
"reasons": [],
|
||||||
|
},
|
||||||
|
"path_exists": True,
|
||||||
|
"in_git_worktree_list": True,
|
||||||
|
"inspected_git_root": worktree,
|
||||||
|
}
|
||||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||||
with mock.patch.dict(
|
with mock.patch.object(
|
||||||
"os.environ",
|
mcp_server, "_session_author_lock_worktree", return_value=None
|
||||||
{"GITEA_TEST_PORCELAIN": ""},
|
|
||||||
clear=False,
|
|
||||||
):
|
):
|
||||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
with mock.patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
return_value=healthy_ctx,
|
||||||
|
):
|
||||||
|
with mock.patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"GITEA_TEST_PORCELAIN": ""},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+1112
-27
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user