Compare commits
244
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
64b83cd1fb | ||
|
|
23535e30bc | ||
|
|
351ef3899b | ||
|
|
1334b0b320 | ||
|
|
975674d7f5 | ||
|
|
8d1098f916 | ||
|
|
49aa3b2812 | ||
|
|
94004f05ac | ||
|
|
8d6d3cb014 | ||
|
|
f558b80cc8 | ||
|
|
223cde1b94 | ||
|
|
9b96085d8d | ||
|
|
ddb1dd941b | ||
|
|
d7c29afde5 | ||
|
|
cc4b95839d | ||
|
|
325e0bb44c | ||
|
|
cf130ed0fc | ||
|
|
cd83b7588f | ||
|
|
b3bc459423 | ||
|
|
e2ef8fbda0 | ||
|
|
5cf44d78e6 | ||
|
|
254ccf5821 | ||
|
|
a8177c2e73 | ||
|
|
e8f93e93d7 | ||
|
|
7acd55d2d2 | ||
|
|
b52fa2060f | ||
|
|
932b280a27 | ||
|
|
c35f713291 | ||
|
|
9340dc7009 | ||
|
|
8e7a69800a | ||
|
|
b14b1a9663 | ||
|
|
eac2b0f6f2 | ||
|
|
7274b76e81 | ||
|
|
5ada9e6cc0 | ||
|
|
9c599bf054 | ||
|
|
6cce87031b | ||
|
|
71e28aa5b4 | ||
|
|
0631e2e155 | ||
|
|
46709537c1 | ||
|
|
62ab4b4d95 | ||
|
|
ce0fb42bef | ||
|
|
92fc913cad | ||
|
|
412f0e0081 | ||
|
|
7cb024d4a5 | ||
|
|
1ed1539acf | ||
|
|
81ff3608ae | ||
|
|
e2e51290c4 | ||
|
|
a2e8bf17ec | ||
|
|
e8947438c9 | ||
|
|
bc16f6652b | ||
|
|
e7811cb5bb | ||
|
|
e5bf8c9647 | ||
|
|
b68805e88a | ||
|
|
d9cf26ddbf | ||
|
|
95ec891bd2 | ||
|
|
72ae320400 | ||
|
|
b71dfe9696 | ||
|
|
0a202970fd | ||
|
|
4e019f1a59 | ||
|
|
32f1f3486f | ||
|
|
32fc1719aa | ||
|
|
8ff1924047 | ||
|
|
84d921a52f | ||
|
|
44cf2a4ce8 | ||
|
|
22d4735170 | ||
|
|
87d6532cb3 | ||
|
|
d663114976 | ||
|
|
7ba469f292 | ||
|
|
d57ca94ec5 | ||
|
|
add87b5708 | ||
|
|
05ea99cd32 | ||
|
|
7c3699ce2d | ||
|
|
c3452ce866 | ||
|
|
97b3c6573b | ||
|
|
51e226e8ba | ||
|
|
09d8db5b9e | ||
|
|
b12212493c | ||
|
|
daf9910e83 | ||
|
|
3550262613 | ||
|
|
59f2de9711 | ||
|
|
c0b1f562a9 | ||
|
|
560cfa8f47 | ||
|
|
3b4f222a7d | ||
|
|
b7755f26e3 | ||
|
|
3cb05284b3 | ||
|
|
9e29d00292 | ||
|
|
96fe077292 | ||
|
|
3b499cec2a | ||
|
|
eff4572a91 | ||
|
|
25cf323d14 | ||
|
|
429faccd08 | ||
|
|
9235f3a23c | ||
|
|
76d1417c28 | ||
|
|
a863928661 | ||
|
|
8f866ae753 | ||
|
|
84b841c727 | ||
|
|
276a71bd1a | ||
|
|
0e701d578a |
@@ -39,6 +39,27 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log
|
||||
# only — never the token value. Surfaced by gitea_get_profile.
|
||||
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,
|
||||
# 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
|
||||
@@ -55,3 +76,12 @@ GITEA_MCP_PROFILE=prgs
|
||||
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
||||
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
||||
# 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
|
||||
|
||||
@@ -53,6 +53,7 @@ Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools n
|
||||
| `gitea_whoami` | Read-only: identify the authenticated Gitea account (safe metadata only) |
|
||||
| `gitea_get_profile` | Read-only: describe the active runtime execution profile (safe metadata only) |
|
||||
| `gitea_check_pr_eligibility` | Read-only: check if the current identity/profile may review/approve/request_changes/merge a PR |
|
||||
| `gitea_assess_conflict_fix_classification` | Read-only: classify conflict-fix need from a live PR head re-fetch before creating a conflict-fix worktree |
|
||||
| `gitea_submit_pr_review` | Gated review mutation: comment/approve/request_changes, only after identity+profile+eligibility gates pass (no merge, no self-approval) |
|
||||
| `gitea_mark_issue` | Claim/release an issue (start/done) |
|
||||
| `gitea_list_labels` | List all available labels in a repository |
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
"""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)
|
||||
|
||||
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]:
|
||||
"""Higher priority first; then lower number (older issues) for stability."""
|
||||
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,807 @@
|
||||
"""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 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,
|
||||
# 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,
|
||||
)
|
||||
checks["worktree"] = {
|
||||
"block": bool(wt.get("block")),
|
||||
"reasons": list(wt.get("reasons") or []),
|
||||
"under_branches": wt.get("under_branches"),
|
||||
}
|
||||
if wt.get("block"):
|
||||
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, []
|
||||
@@ -0,0 +1,603 @@
|
||||
"""Guards for merged-PR branch cleanup and raw git delete bypasses (#514)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
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 = (
|
||||
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:[^\s`]+", re.I),
|
||||
)
|
||||
|
||||
|
||||
def raw_branch_delete_commands(text: str | None) -> list[str]:
|
||||
"""Return raw git branch-delete commands cited in *text*."""
|
||||
if not text:
|
||||
return []
|
||||
commands: list[str] = []
|
||||
for pattern in _RAW_BRANCH_DELETE_PATTERNS:
|
||||
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
|
||||
return list(dict.fromkeys(commands))
|
||||
|
||||
|
||||
def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]:
|
||||
"""Fail closed when a report uses raw git branch deletion as cleanup proof."""
|
||||
commands = raw_branch_delete_commands(text)
|
||||
reasons = [
|
||||
(
|
||||
"raw git branch deletion bypasses MCP branch.delete cleanup gates: "
|
||||
f"{command}"
|
||||
)
|
||||
for command in commands
|
||||
]
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"commands": commands,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"use gitea_cleanup_merged_pr_branch or another approved cleanup "
|
||||
"helper with explicit branch.delete capability"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_merged_pr_branch_cleanup(
|
||||
*,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
merged: bool,
|
||||
remote_branch_exists: bool,
|
||||
open_pr_heads: set[str],
|
||||
head_on_target: bool | None,
|
||||
delete_capability_allowed: bool,
|
||||
confirmation: str | None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether a merged PR source branch can be deleted via MCP."""
|
||||
protected = protected_branches or PROTECTED_BRANCHES
|
||||
expected_confirmation = f"CLEANUP MERGED PR {pr_number} BRANCH {head_branch}"
|
||||
reasons: list[str] = []
|
||||
if not merged:
|
||||
reasons.append("PR is not merged")
|
||||
if not remote_branch_exists:
|
||||
reasons.append("remote branch already absent")
|
||||
if not head_branch:
|
||||
reasons.append("PR head branch is missing")
|
||||
if head_branch in 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:
|
||||
reasons.append("an open PR still references this head branch")
|
||||
if head_on_target is False:
|
||||
reasons.append("PR head is not an ancestor of the target branch")
|
||||
if head_on_target is None:
|
||||
reasons.append("PR head ancestry could not be proven")
|
||||
if not delete_capability_allowed:
|
||||
reasons.append("gitea.branch.delete capability is not allowed")
|
||||
if confirmation != expected_confirmation:
|
||||
reasons.append(
|
||||
"confirmation must equal "
|
||||
f"'{expected_confirmation}' for branch cleanup"
|
||||
)
|
||||
|
||||
safe = not reasons
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"head_branch": head_branch,
|
||||
"expected_confirmation": expected_confirmation,
|
||||
"remote_branch_exists": remote_branch_exists,
|
||||
"safe_to_delete": safe,
|
||||
"block_reasons": reasons,
|
||||
"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",
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Fail-closed validation for workflow-changing Gitea comments (#496)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
VALID_ROLES = frozenset({
|
||||
"controller",
|
||||
"author",
|
||||
"reviewer",
|
||||
"merger",
|
||||
"reconciler",
|
||||
"user",
|
||||
})
|
||||
|
||||
_BASE_REQUIRED = ("STATE", "WHO_IS_NEXT", "NEXT_ACTION", "NEXT_PROMPT", "WHY")
|
||||
|
||||
_ISSUE_REQUIRED = _BASE_REQUIRED + ("BLOCKERS", "VALIDATION")
|
||||
_PR_REQUIRED = _BASE_REQUIRED + (
|
||||
"ISSUE",
|
||||
"HEAD_SHA",
|
||||
"REVIEW_STATUS",
|
||||
"MERGE_READY",
|
||||
"BLOCKERS",
|
||||
"VALIDATION",
|
||||
)
|
||||
_SUPERSESSION_REQUIRED = _BASE_REQUIRED + (
|
||||
"CANONICAL_ITEM",
|
||||
"SUPERSEDED_ITEM",
|
||||
"CLOSE_OR_KEEP_OPEN",
|
||||
)
|
||||
|
||||
_MACHINE_MARKERS = (
|
||||
"<!-- mcp-review-lease:v1 -->",
|
||||
"<!-- mcp-conflict-fix-lease:v1 -->",
|
||||
"<!-- gitea-issue-claim-heartbeat:v1 -->",
|
||||
)
|
||||
|
||||
_WORKFLOW_TRIGGERS = re.compile(
|
||||
r"\b(?:"
|
||||
r"blocked|unblocked|ready(?:\s+for\s+(?:review|merge|author))?|"
|
||||
r"ready-to-merge|approved|approve|request\s+changes|changes\s+requested|"
|
||||
r"superseded|duplicate|canonical|next\s+action|next\s+actor|who\s+is\s+next|"
|
||||
r"author\s+should|reviewer\s+should|merger\s+should|reconciler\s+should|"
|
||||
r"controller\s+should|issue\s+complete|pr\s+open|pr\s+merged|close\s+this|"
|
||||
r"do\s+not\s+merge|needs?\s+rebase|stale\s+approval|contaminated\s+review|"
|
||||
r"merge\s+ready|ready\s+for\s+merge"
|
||||
r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_CANONICAL_HEADINGS = (
|
||||
"## Canonical Issue State",
|
||||
"## Canonical PR State",
|
||||
"## Canonical Discussion Summary",
|
||||
)
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_VAGUE_NEXT_ACTIONS = frozenset({
|
||||
"continue",
|
||||
"handle this",
|
||||
"fix it",
|
||||
"follow up",
|
||||
"follow-up",
|
||||
"see above",
|
||||
"see review",
|
||||
"tbd",
|
||||
"todo",
|
||||
"as needed",
|
||||
"proceed",
|
||||
"next steps",
|
||||
"will check",
|
||||
"investigate",
|
||||
})
|
||||
|
||||
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||
_SHORT_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
||||
_PR_REF_RE = re.compile(r"(?:PR\s*#|pull\s*#)\d+|\b#\d{2,}\b", re.IGNORECASE)
|
||||
_APPROVAL_PROOF_RE = re.compile(
|
||||
r"\b(?:approved|approval_at_current_head|APPROVE)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_UNBLOCK_RE = re.compile(
|
||||
r"\b(?:unblock|until|after|once|when|requires?|must)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_fields(body: str) -> dict[str, str]:
|
||||
"""Parse KEY: value fields, including values continued on following lines."""
|
||||
fields: dict[str, str] = {}
|
||||
current_key: str | None = None
|
||||
current_lines: list[str] = []
|
||||
|
||||
def _flush() -> None:
|
||||
nonlocal current_key, current_lines
|
||||
if current_key is not None:
|
||||
fields[current_key] = "\n".join(current_lines).strip()
|
||||
current_key = None
|
||||
current_lines = []
|
||||
|
||||
for line in (body or "").splitlines():
|
||||
if line.startswith("## "):
|
||||
_flush()
|
||||
continue
|
||||
match = re.match(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", line)
|
||||
if match:
|
||||
_flush()
|
||||
current_key = match.group(1).strip().upper()
|
||||
rest = match.group(2)
|
||||
current_lines = [rest] if rest else []
|
||||
elif current_key is not None:
|
||||
current_lines.append(line)
|
||||
_flush()
|
||||
return fields
|
||||
|
||||
|
||||
def _is_machine_generated(body: str) -> bool:
|
||||
text = body or ""
|
||||
return any(marker in text for marker in _MACHINE_MARKERS)
|
||||
|
||||
|
||||
def _has_canonical_heading(body: str) -> bool:
|
||||
return any(h in (body or "") for h in _CANONICAL_HEADINGS)
|
||||
|
||||
|
||||
def is_workflow_changing_comment(body: str) -> bool:
|
||||
"""True when comment text implies a workflow/state transition."""
|
||||
text = (body or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if _is_machine_generated(text):
|
||||
return False
|
||||
if _has_canonical_heading(text):
|
||||
return True
|
||||
if _WORKFLOW_TRIGGERS.search(text):
|
||||
return True
|
||||
fields = _parse_fields(text)
|
||||
if "STATE" in fields or "WHO_IS_NEXT" in fields:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def infer_comment_context(body: str, *, explicit: str | None = None) -> str:
|
||||
if explicit:
|
||||
return explicit
|
||||
text = body or ""
|
||||
if "## Canonical Discussion Summary" in text:
|
||||
return "discussion_summary"
|
||||
if "## Canonical PR State" in text:
|
||||
return "pr_comment"
|
||||
if "## Canonical Issue State" in text:
|
||||
return "issue_comment"
|
||||
fields = _parse_fields(text)
|
||||
if fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_ITEM"):
|
||||
return "supersession"
|
||||
if any(k in fields for k in ("HEAD_SHA", "REVIEW_STATUS", "MERGE_READY", "ISSUE")):
|
||||
return "pr_comment"
|
||||
return "issue_comment"
|
||||
|
||||
|
||||
def _required_fields_for_context(context: str) -> tuple[str, ...]:
|
||||
if context in ("pr_comment", "pr_review"):
|
||||
return _PR_REQUIRED
|
||||
if context == "supersession":
|
||||
return _SUPERSESSION_REQUIRED
|
||||
if context == "discussion_summary":
|
||||
return _BASE_REQUIRED + ("DECISION", "SUBSTANTIVE_COMMENTS")
|
||||
return _ISSUE_REQUIRED
|
||||
|
||||
|
||||
def _is_vague_next_action(value: str) -> bool:
|
||||
normalized = re.sub(r"\s+", " ", (value or "").strip().lower())
|
||||
normalized = normalized.rstrip(".")
|
||||
if not normalized:
|
||||
return True
|
||||
if normalized in _VAGUE_NEXT_ACTIONS:
|
||||
return True
|
||||
if len(normalized) < 12:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _next_prompt_ok(value: str) -> bool:
|
||||
text = (value or "").strip()
|
||||
if len(text) < 40:
|
||||
return False
|
||||
if text.lower() in {"n/a", "none", "tbd", "todo"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _state_value(fields: dict[str, str]) -> str:
|
||||
return (fields.get("STATE") or "").strip().lower()
|
||||
|
||||
|
||||
def _suggested_template(context: str) -> str:
|
||||
if context == "pr_comment" or context == "pr_review":
|
||||
return (
|
||||
"## Canonical PR State\n\n"
|
||||
"STATE:\n"
|
||||
"WHO_IS_NEXT:\n"
|
||||
"NEXT_ACTION:\n"
|
||||
"NEXT_PROMPT:\n"
|
||||
"```text\n<paste-ready prompt>\n```\n"
|
||||
"WHAT_HAPPENED:\n"
|
||||
"WHY:\n"
|
||||
"ISSUE:\n"
|
||||
"HEAD_SHA:\n"
|
||||
"REVIEW_STATUS:\n"
|
||||
"MERGE_READY:\n"
|
||||
"BLOCKERS:\n"
|
||||
"VALIDATION:\n"
|
||||
"LAST_UPDATED_BY:\n"
|
||||
)
|
||||
if context == "supersession":
|
||||
return (
|
||||
"## Canonical PR State\n\n"
|
||||
"STATE:\nsuperseded\n"
|
||||
"WHO_IS_NEXT:\nreconciler\n"
|
||||
"NEXT_ACTION:\n"
|
||||
"NEXT_PROMPT:\n"
|
||||
"WHY:\n"
|
||||
"CANONICAL_ITEM:\n"
|
||||
"SUPERSEDED_ITEM:\n"
|
||||
"CLOSE_OR_KEEP_OPEN:\n"
|
||||
)
|
||||
if context == "discussion_summary":
|
||||
return (
|
||||
"## Canonical Discussion Summary\n\n"
|
||||
"STATE:\n"
|
||||
"WHO_IS_NEXT:\n"
|
||||
"DECISION:\n"
|
||||
"WHY:\n"
|
||||
"SUBSTANTIVE_COMMENTS:\n"
|
||||
"NEXT_ACTION:\n"
|
||||
"NEXT_PROMPT:\n"
|
||||
)
|
||||
return (
|
||||
"## Canonical Issue State\n\n"
|
||||
"STATE:\n"
|
||||
"WHO_IS_NEXT:\n"
|
||||
"NEXT_ACTION:\n"
|
||||
"NEXT_PROMPT:\n"
|
||||
"```text\n<paste-ready prompt>\n```\n"
|
||||
"WHAT_HAPPENED:\n"
|
||||
"WHY:\n"
|
||||
"RELATED_PRS:\n"
|
||||
"BLOCKERS:\n"
|
||||
"VALIDATION:\n"
|
||||
"LAST_UPDATED_BY:\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_correction_message(
|
||||
*,
|
||||
missing_fields: list[str],
|
||||
vague_fields: list[str],
|
||||
extra_reasons: list[str],
|
||||
context: str,
|
||||
) -> str:
|
||||
parts = [
|
||||
"Canonical comment validation failed (fail closed before posting).",
|
||||
]
|
||||
if missing_fields:
|
||||
parts.append("Missing fields: " + ", ".join(missing_fields) + ".")
|
||||
if vague_fields:
|
||||
parts.append("Vague or invalid fields: " + ", ".join(vague_fields) + ".")
|
||||
parts.extend(extra_reasons)
|
||||
parts.append("Fill the suggested template and retry.")
|
||||
parts.append("Suggested template:\n" + _suggested_template(context))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def assess_canonical_comment(
|
||||
body: str,
|
||||
*,
|
||||
context: str | None = None,
|
||||
force_workflow: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate outgoing comment text before a Gitea mutation."""
|
||||
text = (body or "").strip()
|
||||
ctx = infer_comment_context(text, explicit=context)
|
||||
|
||||
if not text:
|
||||
return {
|
||||
"allowed": True,
|
||||
"is_workflow_comment": False,
|
||||
"context": ctx,
|
||||
"missing_fields": [],
|
||||
"vague_fields": [],
|
||||
"correction_message": "",
|
||||
"suggested_template": "",
|
||||
}
|
||||
|
||||
if _is_machine_generated(text):
|
||||
return {
|
||||
"allowed": True,
|
||||
"is_workflow_comment": False,
|
||||
"context": ctx,
|
||||
"missing_fields": [],
|
||||
"vague_fields": [],
|
||||
"correction_message": "",
|
||||
"suggested_template": "",
|
||||
}
|
||||
|
||||
workflow = force_workflow or is_workflow_changing_comment(text)
|
||||
if not workflow:
|
||||
return {
|
||||
"allowed": True,
|
||||
"is_workflow_comment": False,
|
||||
"context": ctx,
|
||||
"missing_fields": [],
|
||||
"vague_fields": [],
|
||||
"correction_message": "",
|
||||
"suggested_template": "",
|
||||
}
|
||||
|
||||
fields = _parse_fields(text)
|
||||
required = _required_fields_for_context(ctx)
|
||||
missing = [name for name in required if not (fields.get(name) or "").strip()]
|
||||
|
||||
vague: list[str] = []
|
||||
extra: list[str] = []
|
||||
|
||||
who = (fields.get("WHO_IS_NEXT") or "").strip().lower()
|
||||
if who and who not in VALID_ROLES:
|
||||
vague.append("WHO_IS_NEXT")
|
||||
extra.append(
|
||||
f"WHO_IS_NEXT must be one of: {', '.join(sorted(VALID_ROLES))}."
|
||||
)
|
||||
|
||||
next_action = fields.get("NEXT_ACTION") or ""
|
||||
if next_action and _is_vague_next_action(next_action):
|
||||
vague.append("NEXT_ACTION")
|
||||
|
||||
next_prompt = fields.get("NEXT_PROMPT") or ""
|
||||
if not _next_prompt_ok(next_prompt):
|
||||
if "NEXT_PROMPT" not in missing:
|
||||
vague.append("NEXT_PROMPT")
|
||||
|
||||
state = _state_value(fields)
|
||||
blockers = (fields.get("BLOCKERS") or "").strip()
|
||||
if "blocked" in state:
|
||||
if not blockers or blockers.lower() in {"none", "n/a"}:
|
||||
missing.append("BLOCKERS (unblock condition)")
|
||||
elif not _UNBLOCK_RE.search(blockers):
|
||||
vague.append("BLOCKERS")
|
||||
extra.append("BLOCKED state requires an explicit unblock condition in BLOCKERS.")
|
||||
|
||||
if "superseded" in state:
|
||||
canon = (fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_BY") or "").strip()
|
||||
superseded = (fields.get("SUPERSEDED_ITEM") or fields.get("SUPERSEDES") or "").strip()
|
||||
if not canon and "CANONICAL_ITEM" not in missing:
|
||||
missing.append("CANONICAL_ITEM")
|
||||
if not superseded and "SUPERSEDED_ITEM" not in missing:
|
||||
missing.append("SUPERSEDED_ITEM")
|
||||
|
||||
if "ready-to-merge" in state or "ready to merge" in state:
|
||||
head_sha = fields.get("HEAD_SHA") or ""
|
||||
merge_ready = fields.get("MERGE_READY") or ""
|
||||
review_status = fields.get("REVIEW_STATUS") or ""
|
||||
validation = fields.get("VALIDATION") or ""
|
||||
proof_blob = " ".join((head_sha, merge_ready, review_status, validation))
|
||||
has_sha = bool(_FULL_SHA_RE.search(proof_blob) or _SHORT_SHA_RE.search(proof_blob))
|
||||
has_approval = bool(_APPROVAL_PROOF_RE.search(proof_blob))
|
||||
if not has_sha or not has_approval:
|
||||
extra.append(
|
||||
"ready-to-merge STATE requires approval proof and HEAD_SHA in "
|
||||
"REVIEW_STATUS, MERGE_READY, HEAD_SHA, or VALIDATION."
|
||||
)
|
||||
|
||||
if ctx in ("pr_comment", "pr_review"):
|
||||
head_sha = (fields.get("HEAD_SHA") or "").strip()
|
||||
if not head_sha or not _SHORT_SHA_RE.search(head_sha):
|
||||
if "HEAD_SHA" not in missing:
|
||||
missing.append("HEAD_SHA")
|
||||
|
||||
if ctx == "issue_comment" and _PR_REF_RE.search(text):
|
||||
related = (fields.get("RELATED_PRS") or "").strip()
|
||||
if not related or related.lower() in {"none", "n/a", "-"}:
|
||||
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
|
||||
correction = ""
|
||||
if not allowed:
|
||||
correction = _build_correction_message(
|
||||
missing_fields=missing,
|
||||
vague_fields=vague,
|
||||
extra_reasons=extra,
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
return {
|
||||
"allowed": allowed,
|
||||
"is_workflow_comment": True,
|
||||
"context": ctx,
|
||||
"missing_fields": missing,
|
||||
"vague_fields": vague,
|
||||
"extra_reasons": extra,
|
||||
"correction_message": correction,
|
||||
"suggested_template": _suggested_template(ctx) if not allowed else "",
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Canonical state comment validation helpers (#495).
|
||||
|
||||
These helpers validate durable issue/PR/discussion state handoff comments.
|
||||
They are intentionally pure and do not post comments; MCP mutation hooks are
|
||||
owned by #496.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
CANONICAL_HEADINGS = (
|
||||
"canonical issue state",
|
||||
"canonical pr state",
|
||||
"canonical discussion summary",
|
||||
)
|
||||
|
||||
REQUIRED_FIELDS = ("STATE", "WHO_IS_NEXT", "NEXT_ACTION", "NEXT_PROMPT")
|
||||
ALLOWED_NEXT_ACTORS = {
|
||||
"controller",
|
||||
"author",
|
||||
"reviewer",
|
||||
"merger",
|
||||
"reconciler",
|
||||
"user",
|
||||
}
|
||||
|
||||
VAGUE_NEXT_ACTIONS = {
|
||||
"continue",
|
||||
"handle this",
|
||||
"do it",
|
||||
"fix it",
|
||||
"proceed",
|
||||
"follow up",
|
||||
"next",
|
||||
"tbd",
|
||||
"todo",
|
||||
"n/a",
|
||||
"none",
|
||||
}
|
||||
|
||||
_FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?([A-Z][A-Z0-9_ ]+)\s*:\s*(.*)$")
|
||||
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||
_CLAIMS_STATE_UPDATE_RE = re.compile(
|
||||
r"canonical\s+(?:issue|pr|discussion)?\s*state|"
|
||||
r"state\s+comment\s+(?:posted|created|updated)|"
|
||||
r"next[- ]action\s+comment\s+(?:posted|created|updated)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_state_fields(text: str | None) -> dict[str, str]:
|
||||
"""Return upper-case labeled fields from a canonical state block."""
|
||||
fields: dict[str, str] = {}
|
||||
current_key: str | None = None
|
||||
for line in (text or "").splitlines():
|
||||
match = _FIELD_RE.match(line)
|
||||
if match:
|
||||
current_key = match.group(1).strip().upper().replace(" ", "_")
|
||||
fields[current_key] = match.group(2).strip()
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if current_key and stripped and not stripped.startswith("#"):
|
||||
existing = fields.get(current_key, "")
|
||||
fields[current_key] = (
|
||||
f"{existing}\n{stripped}" if existing else stripped
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def contains_canonical_state_block(text: str | None) -> bool:
|
||||
lower = (text or "").lower()
|
||||
return any(heading in lower for heading in CANONICAL_HEADINGS)
|
||||
|
||||
|
||||
def claims_canonical_state_update(text: str | None) -> bool:
|
||||
"""Return True when text claims a canonical state/next-action update."""
|
||||
return bool(_CLAIMS_STATE_UPDATE_RE.search(text or ""))
|
||||
|
||||
|
||||
def _empty_or_placeholder(value: str | None) -> bool:
|
||||
value = (value or "").strip().lower()
|
||||
return not value or value in {"none", "n/a", "unknown", "tbd", "<...>"}
|
||||
|
||||
|
||||
def _vague_next_action(value: str | None) -> bool:
|
||||
normalized = re.sub(r"\s+", " ", (value or "").strip().lower())
|
||||
return normalized in VAGUE_NEXT_ACTIONS
|
||||
|
||||
|
||||
def validate_canonical_state_comment(text: str | None) -> dict:
|
||||
"""Validate a canonical state comment or embedded final-report block.
|
||||
|
||||
Returns a dict with ``valid`` and ``reasons``. The validator focuses on
|
||||
fields that make continuation possible: current state, next actor, next
|
||||
action, and paste-ready next prompt, plus a few contradiction checks.
|
||||
"""
|
||||
fields = extract_state_fields(text)
|
||||
reasons: list[str] = []
|
||||
|
||||
for field in REQUIRED_FIELDS:
|
||||
if _empty_or_placeholder(fields.get(field)):
|
||||
reasons.append(f"missing required canonical state field: {field}")
|
||||
|
||||
actor = (fields.get("WHO_IS_NEXT") or "").strip().lower()
|
||||
if actor and actor not in ALLOWED_NEXT_ACTORS:
|
||||
reasons.append(
|
||||
"WHO_IS_NEXT must be one of: "
|
||||
+ ", ".join(sorted(ALLOWED_NEXT_ACTORS))
|
||||
)
|
||||
|
||||
if _vague_next_action(fields.get("NEXT_ACTION")):
|
||||
reasons.append("NEXT_ACTION is too vague for durable continuation")
|
||||
|
||||
state = (fields.get("STATE") or "").strip().lower().replace("-", "_")
|
||||
if "ready_to_merge" in state or (
|
||||
state == "approved" and "pr" in (text or "").lower()
|
||||
):
|
||||
review_status = (fields.get("REVIEW_STATUS") or "").lower()
|
||||
head_sha = fields.get("HEAD_SHA") or ""
|
||||
merge_ready = (fields.get("MERGE_READY") or "").lower()
|
||||
if "approved" not in review_status:
|
||||
reasons.append("ready-to-merge state requires approved REVIEW_STATUS")
|
||||
if not _FULL_SHA_RE.search(head_sha):
|
||||
reasons.append("ready-to-merge state requires full HEAD_SHA proof")
|
||||
if merge_ready and not merge_ready.startswith(("yes", "true")):
|
||||
reasons.append("ready-to-merge state contradicts MERGE_READY")
|
||||
|
||||
if "superseded" in state:
|
||||
canonical = fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_BY")
|
||||
if _empty_or_placeholder(canonical):
|
||||
reasons.append("superseded state requires canonical item proof")
|
||||
|
||||
if "blocked" in state:
|
||||
blockers = fields.get("BLOCKERS") or ""
|
||||
if _empty_or_placeholder(blockers):
|
||||
reasons.append("blocked state requires BLOCKERS/unblock condition")
|
||||
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"fields": fields,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def validate_final_report_state_update(report_text: str | None) -> dict:
|
||||
"""Validate canonical state-update claims inside a final report."""
|
||||
text = report_text or ""
|
||||
if not claims_canonical_state_update(text) and not contains_canonical_state_block(text):
|
||||
return {
|
||||
"applicable": False,
|
||||
"valid": True,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
if not contains_canonical_state_block(text):
|
||||
return {
|
||||
"applicable": True,
|
||||
"valid": False,
|
||||
"reasons": [
|
||||
"final report claims a canonical state update but includes no canonical state block"
|
||||
],
|
||||
}
|
||||
|
||||
result = validate_canonical_state_comment(text)
|
||||
return {
|
||||
"applicable": True,
|
||||
"valid": result["valid"],
|
||||
"fields": result["fields"],
|
||||
"reasons": result["reasons"],
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Canonical Thread Handoff (CTH) protocol for issue and PR comments (#505).
|
||||
|
||||
A CTH comment is the authoritative workflow handoff in a Gitea issue or PR
|
||||
thread. It records current state, decisions, blockers, proof, and the exact
|
||||
next prompt/action for the next LLM or person.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
MARKER = "<!-- cth:v1 -->"
|
||||
|
||||
CTH_TERM = "Canonical Thread Handoff"
|
||||
CTH_ABBREV = "CTH"
|
||||
|
||||
CTH_TYPES = frozenset({
|
||||
"State Handoff",
|
||||
"Controller Decision",
|
||||
"Author Handoff",
|
||||
"Reviewer Handoff",
|
||||
"Merger Handoff",
|
||||
"Supersession Notice",
|
||||
"Blocker",
|
||||
})
|
||||
|
||||
_REQUIRED_BASE_FIELDS = (
|
||||
"status",
|
||||
"next owner",
|
||||
"current blocker",
|
||||
"decision",
|
||||
"proof",
|
||||
"next action",
|
||||
"ready-to-paste prompt",
|
||||
)
|
||||
|
||||
_HEADING_RE = re.compile(
|
||||
r"^##\s+CTH:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_FIELD_RE = re.compile(
|
||||
r"^([A-Za-z][A-Za-z0-9 /-]*):\s*(.+?)\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def format_cth_body(
|
||||
*,
|
||||
cth_type: str,
|
||||
status: str,
|
||||
next_owner: str,
|
||||
current_blocker: str = "none",
|
||||
decision: str = "none",
|
||||
proof: str = "none",
|
||||
next_action: str = "none",
|
||||
ready_to_paste_prompt: str = "none",
|
||||
extra_fields: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Render a canonical CTH comment body."""
|
||||
normalized_type = (cth_type or "").strip()
|
||||
if normalized_type not in CTH_TYPES:
|
||||
raise ValueError(
|
||||
f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}"
|
||||
)
|
||||
lines = [
|
||||
MARKER,
|
||||
f"## CTH: {normalized_type}",
|
||||
"",
|
||||
f"Status: {(status or 'unknown').strip() or 'unknown'}",
|
||||
f"Next owner: {(next_owner or 'unknown').strip() or 'unknown'}",
|
||||
f"Current blocker: {(current_blocker or 'none').strip() or 'none'}",
|
||||
f"Decision: {(decision or 'none').strip() or 'none'}",
|
||||
f"Proof: {(proof or 'none').strip() or 'none'}",
|
||||
f"Next action: {(next_action or 'none').strip() or 'none'}",
|
||||
f"Ready-to-paste prompt: {(ready_to_paste_prompt or 'none').strip() or 'none'}",
|
||||
]
|
||||
for key, value in (extra_fields or {}).items():
|
||||
label = (key or "").strip()
|
||||
if not label:
|
||||
continue
|
||||
lines.append(f"{label}: {(value or 'none').strip() or 'none'}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_cth_comment(body: str) -> dict[str, Any] | None:
|
||||
"""Parse one CTH comment body, or None when not a CTH comment."""
|
||||
text = body or ""
|
||||
if MARKER not in text and not _HEADING_RE.search(text):
|
||||
return None
|
||||
heading = _HEADING_RE.search(text)
|
||||
if not heading:
|
||||
return None
|
||||
cth_type = heading.group(1).strip()
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
key = match.group(1).strip().lower()
|
||||
if key.startswith("cth"):
|
||||
continue
|
||||
fields[key] = match.group(2).strip()
|
||||
return {
|
||||
"cth_type": cth_type,
|
||||
"fields": fields,
|
||||
"raw_body": text,
|
||||
}
|
||||
|
||||
|
||||
def assess_cth_comment(body: str) -> dict[str, Any]:
|
||||
"""Validate a CTH comment has required base fields and a known type."""
|
||||
parsed = parse_cth_comment(body)
|
||||
reasons: list[str] = []
|
||||
if not parsed:
|
||||
return {
|
||||
"valid": False,
|
||||
"block": True,
|
||||
"reasons": ["comment is not a Canonical Thread Handoff (CTH)"],
|
||||
"parsed": None,
|
||||
}
|
||||
|
||||
cth_type = parsed.get("cth_type") or ""
|
||||
if cth_type not in CTH_TYPES:
|
||||
reasons.append(
|
||||
f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}"
|
||||
)
|
||||
|
||||
fields = parsed.get("fields") or {}
|
||||
missing = [name for name in _REQUIRED_BASE_FIELDS if not (fields.get(name) or "").strip()]
|
||||
if missing:
|
||||
reasons.append(
|
||||
"CTH missing required fields: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
vague_prompt = (fields.get("ready-to-paste prompt") or "").strip().lower()
|
||||
if vague_prompt in {"", "none", "tbd", "n/a", "todo"}:
|
||||
reasons.append("ready-to-paste prompt must be concrete and paste-ready")
|
||||
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"parsed": parsed,
|
||||
"cth_type": cth_type,
|
||||
}
|
||||
|
||||
|
||||
def find_latest_cth(comments: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""Return the newest valid CTH comment from a Gitea comment list."""
|
||||
latest: dict[str, Any] | None = None
|
||||
latest_ts: datetime | None = None
|
||||
for comment in comments or []:
|
||||
body = comment.get("body") or ""
|
||||
parsed = parse_cth_comment(body)
|
||||
if not parsed:
|
||||
continue
|
||||
created = _parse_timestamp(comment.get("created_at"))
|
||||
if latest is None or (created and (latest_ts is None or created >= latest_ts)):
|
||||
latest = {
|
||||
"comment_id": comment.get("id"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"author": (comment.get("user") or {}).get("login"),
|
||||
**parsed,
|
||||
}
|
||||
latest_ts = created
|
||||
return latest
|
||||
|
||||
|
||||
def assess_cth_supersedes_non_cth(
|
||||
*,
|
||||
latest_cth: dict[str, Any] | None,
|
||||
narrative_claim: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when narrative relies on stale non-CTH state despite newer CTH."""
|
||||
if not latest_cth:
|
||||
return {"block": False, "reasons": []}
|
||||
text = (narrative_claim or "").strip()
|
||||
if not text:
|
||||
return {"block": False, "reasons": []}
|
||||
if parse_cth_comment(text):
|
||||
return {"block": False, "reasons": []}
|
||||
return {
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"workflow narrative must treat the latest CTH comment as authoritative; "
|
||||
f"found newer CTH type '{latest_cth.get('cth_type')}' "
|
||||
f"(comment #{latest_cth.get('comment_id')})"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
EXAMPLE_SCENARIOS = (
|
||||
"pr_approved_ready_for_merger",
|
||||
"pr_request_changes_to_author",
|
||||
"duplicate_superseded_pr_closure",
|
||||
"blocked_dirty_root_worktree",
|
||||
"stale_head_requires_fresh_review",
|
||||
"issue_implementation_handoff",
|
||||
)
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Live PR head re-pin before conflict-fix classification (#522).
|
||||
|
||||
Open PR inventory fields (mergeable, head_sha) can be stale. Author sessions
|
||||
must re-fetch live PR state and pin the live head before classifying a PR as
|
||||
conflicted or creating a conflict-fix worktree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_INVENTORY_HEAD_RE = re.compile(
|
||||
r"(?:inventory head sha|stale inventory head sha)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_HEAD_RE = re.compile(
|
||||
r"(?:live head sha|pinned head sha|live pr head sha)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REPINS_TOOL_RE = re.compile(
|
||||
r"gitea_assess_conflict_fix_classification",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLASSIFICATION_RE = re.compile(
|
||||
r"conflict[- ]fix classification\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
CLASSIFICATION_STALE_INVENTORY_SKIP = "stale_inventory_skip"
|
||||
CLASSIFICATION_CONFLICT_FIX_NEEDED = "conflict_fix_needed"
|
||||
CLASSIFICATION_LIVE_MERGEABLE = "live_mergeable_skip"
|
||||
CLASSIFICATION_INCOMPLETE = "incomplete_live_repin"
|
||||
|
||||
_VALID_CLASSIFICATIONS = frozenset({
|
||||
CLASSIFICATION_STALE_INVENTORY_SKIP,
|
||||
CLASSIFICATION_CONFLICT_FIX_NEEDED,
|
||||
CLASSIFICATION_LIVE_MERGEABLE,
|
||||
CLASSIFICATION_INCOMPLETE,
|
||||
})
|
||||
|
||||
|
||||
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 assess_conflict_fix_classification(
|
||||
*,
|
||||
pr_number: int,
|
||||
inventory_head_sha: str | None = None,
|
||||
inventory_mergeable: bool | None = None,
|
||||
live_head_sha: str | None = None,
|
||||
live_mergeable: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify whether conflict-fix work is justified from live PR state.
|
||||
|
||||
Inventory fields are advisory only. Live head + live mergeable are required
|
||||
before any conflict-fix worktree may be created.
|
||||
"""
|
||||
inv_head = _normalize_sha(inventory_head_sha)
|
||||
live_head = _normalize_sha(live_head_sha)
|
||||
reasons: list[str] = []
|
||||
|
||||
if not isinstance(pr_number, int) or pr_number <= 0:
|
||||
return {
|
||||
"classification": CLASSIFICATION_INCOMPLETE,
|
||||
"worktree_allowed": False,
|
||||
"skip_author_mutation": True,
|
||||
"inventory_stale_head": False,
|
||||
"inventory_stale_mergeable": False,
|
||||
"pinned_head_sha": None,
|
||||
"inventory_head_sha": inv_head,
|
||||
"live_head_sha": live_head,
|
||||
"inventory_mergeable": inventory_mergeable,
|
||||
"live_mergeable": live_mergeable,
|
||||
"pr_number": pr_number,
|
||||
"reasons": ["pr_number must be a positive integer (fail closed)"],
|
||||
}
|
||||
|
||||
if live_head is None:
|
||||
reasons.append(
|
||||
"live PR head SHA missing or not a full 40-char hex SHA; "
|
||||
"re-fetch the PR before conflict-fix classification (fail closed)"
|
||||
)
|
||||
if live_mergeable is None:
|
||||
reasons.append(
|
||||
"live PR mergeable value missing; re-fetch the PR before "
|
||||
"conflict-fix classification (fail closed)"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"classification": CLASSIFICATION_INCOMPLETE,
|
||||
"worktree_allowed": False,
|
||||
"skip_author_mutation": True,
|
||||
"inventory_stale_head": bool(
|
||||
inv_head and live_head and inv_head != live_head
|
||||
),
|
||||
"inventory_stale_mergeable": (
|
||||
inventory_mergeable is not None
|
||||
and live_mergeable is not None
|
||||
and bool(inventory_mergeable) != bool(live_mergeable)
|
||||
),
|
||||
"pinned_head_sha": live_head,
|
||||
"inventory_head_sha": inv_head,
|
||||
"live_head_sha": live_head,
|
||||
"inventory_mergeable": inventory_mergeable,
|
||||
"live_mergeable": live_mergeable,
|
||||
"pr_number": pr_number,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
inventory_stale_head = bool(inv_head and inv_head != live_head)
|
||||
inventory_stale_mergeable = (
|
||||
inventory_mergeable is not None
|
||||
and bool(inventory_mergeable) != bool(live_mergeable)
|
||||
)
|
||||
|
||||
# Live mergeable wins: do not start conflict-fix work.
|
||||
if live_mergeable is True:
|
||||
classification = (
|
||||
CLASSIFICATION_STALE_INVENTORY_SKIP
|
||||
if (
|
||||
inventory_mergeable is False
|
||||
or inventory_stale_head
|
||||
or inventory_stale_mergeable
|
||||
)
|
||||
else CLASSIFICATION_LIVE_MERGEABLE
|
||||
)
|
||||
note = []
|
||||
if inventory_stale_head:
|
||||
note.append(
|
||||
f"inventory head {inv_head} differs from live head {live_head}; "
|
||||
"use live head only"
|
||||
)
|
||||
if inventory_mergeable is False:
|
||||
note.append(
|
||||
"inventory reported mergeable:false but live PR is mergeable:true; "
|
||||
"skip author conflict-fix mutation"
|
||||
)
|
||||
return {
|
||||
"classification": classification,
|
||||
"worktree_allowed": False,
|
||||
"skip_author_mutation": True,
|
||||
"inventory_stale_head": inventory_stale_head,
|
||||
"inventory_stale_mergeable": inventory_stale_mergeable,
|
||||
"pinned_head_sha": live_head,
|
||||
"inventory_head_sha": inv_head,
|
||||
"live_head_sha": live_head,
|
||||
"inventory_mergeable": inventory_mergeable,
|
||||
"live_mergeable": live_mergeable,
|
||||
"pr_number": pr_number,
|
||||
"reasons": note,
|
||||
}
|
||||
|
||||
# live_mergeable is False → conflict-fix may proceed on the pinned live head.
|
||||
note = []
|
||||
if inventory_stale_head:
|
||||
note.append(
|
||||
f"inventory head {inv_head} differs from live head {live_head}; "
|
||||
"pin and use live head only for conflict-fix work"
|
||||
)
|
||||
if inventory_mergeable is True:
|
||||
note.append(
|
||||
"inventory reported mergeable:true but live PR is mergeable:false; "
|
||||
"trust live mergeable and proceed only with live head pin"
|
||||
)
|
||||
note.append(
|
||||
f"live PR #{pr_number} is mergeable:false at pinned head {live_head}; "
|
||||
"conflict-fix worktree allowed for that head only"
|
||||
)
|
||||
return {
|
||||
"classification": CLASSIFICATION_CONFLICT_FIX_NEEDED,
|
||||
"worktree_allowed": True,
|
||||
"skip_author_mutation": False,
|
||||
"inventory_stale_head": inventory_stale_head,
|
||||
"inventory_stale_mergeable": inventory_stale_mergeable,
|
||||
"pinned_head_sha": live_head,
|
||||
"inventory_head_sha": inv_head,
|
||||
"live_head_sha": live_head,
|
||||
"inventory_mergeable": inventory_mergeable,
|
||||
"live_mergeable": live_mergeable,
|
||||
"pr_number": pr_number,
|
||||
"reasons": note,
|
||||
}
|
||||
|
||||
|
||||
def assess_conflict_fix_classification_final_report(
|
||||
report_text: str,
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Require live-head re-pin proof when a report claims conflict-fix work (#522)."""
|
||||
text = report_text or ""
|
||||
lower = text.lower()
|
||||
mentions_conflict_fix = (
|
||||
"conflict-fix" in lower
|
||||
or "conflict fix" in lower
|
||||
or "conflict_fix" in lower
|
||||
)
|
||||
if not mentions_conflict_fix:
|
||||
return {"proven": True, "reasons": [], "applicable": False}
|
||||
|
||||
reasons: list[str] = []
|
||||
if not _REPINS_TOOL_RE.search(text):
|
||||
reasons.append(
|
||||
"conflict-fix report must cite gitea_assess_conflict_fix_classification "
|
||||
"(live head re-pin tool)"
|
||||
)
|
||||
|
||||
live_match = _LIVE_HEAD_RE.search(text)
|
||||
if not live_match:
|
||||
reasons.append(
|
||||
"conflict-fix report must state Live head SHA: <40-char hex> "
|
||||
"(or Pinned head SHA / Live PR head SHA)"
|
||||
)
|
||||
else:
|
||||
live_sha = _normalize_sha(live_match.group(1))
|
||||
if live_sha is None:
|
||||
reasons.append("live head SHA is not a full 40-char hex digest")
|
||||
|
||||
inv_match = _INVENTORY_HEAD_RE.search(text)
|
||||
# Inventory head is optional but recommended when classification is stale skip.
|
||||
class_match = _CLASSIFICATION_RE.search(text)
|
||||
if not class_match:
|
||||
reasons.append(
|
||||
"conflict-fix report must state Conflict-fix classification: "
|
||||
f"<{'|'.join(sorted(_VALID_CLASSIFICATIONS))}>"
|
||||
)
|
||||
else:
|
||||
classification = class_match.group(1).strip().lower().replace(" ", "_")
|
||||
# allow hyphenated forms
|
||||
classification = classification.replace("-", "_")
|
||||
if classification not in _VALID_CLASSIFICATIONS:
|
||||
reasons.append(
|
||||
f"unknown conflict-fix classification {class_match.group(1)!r}; "
|
||||
f"expected one of {sorted(_VALID_CLASSIFICATIONS)}"
|
||||
)
|
||||
|
||||
if inv_match and live_match:
|
||||
inv_sha = _normalize_sha(inv_match.group(1))
|
||||
live_sha = _normalize_sha(live_match.group(1))
|
||||
if inv_sha and live_sha and inv_sha != live_sha:
|
||||
# Require explicit note that live head was used.
|
||||
if "use live head" not in lower and "live head only" not in lower:
|
||||
reasons.append(
|
||||
"inventory head differs from live head; report must state that "
|
||||
"the live head was used exclusively"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"reasons": reasons,
|
||||
"applicable": True,
|
||||
"inventory_head_sha": _normalize_sha(inv_match.group(1)) if inv_match else None,
|
||||
"live_head_sha": _normalize_sha(live_match.group(1)) if live_match else None,
|
||||
"classification": (
|
||||
class_match.group(1).strip().lower().replace("-", "_").replace(" ", "_")
|
||||
if class_match
|
||||
else None
|
||||
),
|
||||
}
|
||||
+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 "",
|
||||
}
|
||||
+53
-1
@@ -29,6 +29,7 @@ from gitea_auth import (
|
||||
get_credentials, resolve_remote, add_remote_args,
|
||||
api_request, repo_api_url,
|
||||
)
|
||||
import issue_workflow_labels
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
@@ -38,6 +39,16 @@ def main(argv=None):
|
||||
parser.add_argument("--body", default="", help="Issue body text.")
|
||||
parser.add_argument("--body-file",
|
||||
help="Read issue body from this file ('-' for stdin).")
|
||||
parser.add_argument("--label", action="append", default=[],
|
||||
help="Existing label name to apply; may be repeated.")
|
||||
parser.add_argument("--type-label",
|
||||
help="Issue type, e.g. feature or type:feature.")
|
||||
parser.add_argument("--status-label",
|
||||
help="Initial status, e.g. ready or status:ready.")
|
||||
parser.add_argument("--discussion", action="store_true",
|
||||
help="Apply type:discussion.")
|
||||
parser.add_argument("--require-workflow-labels", action="store_true",
|
||||
help="Fail unless one type:* and one status:* label are supplied.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
host, org, repo = resolve_remote(args)
|
||||
@@ -50,6 +61,24 @@ def main(argv=None):
|
||||
with open(args.body_file, "r", encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
|
||||
requested_labels = issue_workflow_labels.labels_for_new_issue(
|
||||
issue_type=args.type_label,
|
||||
initial_status=args.status_label,
|
||||
extra_labels=args.label,
|
||||
discussion=args.discussion,
|
||||
)
|
||||
label_assessment = issue_workflow_labels.assess_issue_labels(
|
||||
requested_labels,
|
||||
discussion=args.discussion,
|
||||
)
|
||||
if args.require_workflow_labels and not label_assessment["valid"]:
|
||||
print(
|
||||
"Workflow label validation failed: "
|
||||
+ "; ".join(label_assessment["errors"]),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
user, password = get_credentials(host)
|
||||
if not user or not password:
|
||||
print(f"Could not get credentials for {host} "
|
||||
@@ -59,11 +88,34 @@ def main(argv=None):
|
||||
|
||||
import base64
|
||||
auth = f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}"
|
||||
url = f"{repo_api_url(host, org, repo)}/issues"
|
||||
base = repo_api_url(host, org, repo)
|
||||
url = f"{base}/issues"
|
||||
|
||||
try:
|
||||
label_ids = []
|
||||
if requested_labels:
|
||||
existing = api_request("GET", f"{base}/labels", auth)
|
||||
by_name = {lb["name"]: lb["id"] for lb in existing}
|
||||
missing = [name for name in requested_labels if name not in by_name]
|
||||
if missing:
|
||||
print(f"Missing labels: {missing}", file=sys.stderr)
|
||||
return 1
|
||||
label_ids = [by_name[name] for name in requested_labels]
|
||||
data = api_request("POST", url, auth, {"title": args.title, "body": body})
|
||||
if label_ids:
|
||||
api_request(
|
||||
"PUT",
|
||||
f"{base}/issues/{data.get('number')}/labels",
|
||||
auth,
|
||||
{"labels": label_ids},
|
||||
)
|
||||
print(f"Issue #{data.get('number')}: {data.get('html_url')}")
|
||||
if not label_assessment["valid"]:
|
||||
print(
|
||||
"Workflow label recommendation: "
|
||||
+ "; ".join(label_assessment["errors"]),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
|
||||
@@ -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,198 @@
|
||||
# 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 (optional tooling)
|
||||
|
||||
These may land in later issues; the **policy binds sessions now**:
|
||||
|
||||
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
|
||||
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
|
||||
3. Promotion checklist script that emits the durable promotion marker fields.
|
||||
|
||||
**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,183 @@
|
||||
# Canonical State Comments
|
||||
|
||||
Gitea is the durable system of record for workflow continuation. When a
|
||||
comment changes issue, PR, or discussion state, it should leave enough
|
||||
information for the next role to continue without private chat history.
|
||||
|
||||
Canonical comments answer:
|
||||
|
||||
- what state the object is in
|
||||
- who acts next
|
||||
- what the next actor should do
|
||||
- the exact prompt the next actor should run
|
||||
- which proof, blocker, or dependency matters
|
||||
|
||||
Non-workflow discussion comments do not need this template.
|
||||
|
||||
## Issue State
|
||||
|
||||
Use this when an issue becomes ready, blocked, in progress, PR-open,
|
||||
superseded, merged, or otherwise changes workflow direction.
|
||||
|
||||
```text
|
||||
## Canonical Issue State
|
||||
|
||||
STATE:
|
||||
<ready-for-author | in-progress | blocked | PR-open | needs-review | ready-to-merge | merged | closed | superseded>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<controller | author | reviewer | merger | reconciler | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<specific one-sentence action>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next role>
|
||||
|
||||
WHAT_HAPPENED:
|
||||
<latest meaningful event>
|
||||
|
||||
WHY:
|
||||
<decision rationale>
|
||||
|
||||
RELATED_DISCUSSION:
|
||||
<link/reference or none>
|
||||
|
||||
RELATED_PRS:
|
||||
- #...
|
||||
|
||||
BRANCH:
|
||||
<branch or none>
|
||||
|
||||
HEAD_SHA:
|
||||
<40-character SHA or none>
|
||||
|
||||
VALIDATION:
|
||||
<tests/proofs or none>
|
||||
|
||||
BLOCKERS:
|
||||
<blocker and unblock condition, or none>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
|
||||
## PR State
|
||||
|
||||
Use this when a PR needs review, receives changes requested, is approved,
|
||||
is stale, is superseded, or becomes ready for merge.
|
||||
|
||||
```text
|
||||
## Canonical PR State
|
||||
|
||||
STATE:
|
||||
<needs-review | changes-requested | approved | stale-approval | ready-to-merge | merged | blocked | superseded>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<controller | author | reviewer | merger | reconciler | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<specific one-sentence action>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next role>
|
||||
|
||||
WHAT_HAPPENED:
|
||||
<latest meaningful event>
|
||||
|
||||
WHY:
|
||||
<decision rationale>
|
||||
|
||||
ISSUE:
|
||||
#...
|
||||
|
||||
BASE:
|
||||
<branch>
|
||||
|
||||
HEAD:
|
||||
<branch>
|
||||
|
||||
HEAD_SHA:
|
||||
<40-character SHA>
|
||||
|
||||
REVIEW_STATUS:
|
||||
<none | approved | changes-requested | stale | contaminated>
|
||||
|
||||
VALIDATION:
|
||||
<tests/proofs>
|
||||
|
||||
BLOCKERS:
|
||||
<blockers or none>
|
||||
|
||||
SUPERSEDES:
|
||||
<PRs or none>
|
||||
|
||||
SUPERSEDED_BY:
|
||||
<PR or none>
|
||||
|
||||
MERGE_READY:
|
||||
<yes/no and why>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
|
||||
## Discussion Summary
|
||||
|
||||
Discussions should normally have at least five substantive comments before
|
||||
conversion into issues. A controller may waive that only for tiny mechanical,
|
||||
urgent, or explicitly trivial work.
|
||||
|
||||
```text
|
||||
## Canonical Discussion Summary
|
||||
|
||||
STATE:
|
||||
<needs-more-discussion | ready-for-issues | issues-created | closed>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<controller | author | reviewer | user>
|
||||
|
||||
DECISION:
|
||||
<what was decided>
|
||||
|
||||
WHY:
|
||||
<reasoning and tradeoffs>
|
||||
|
||||
SUBSTANTIVE_COMMENTS:
|
||||
<count and summary>
|
||||
|
||||
ISSUES_TO_CREATE_OR_CREATED:
|
||||
- #...
|
||||
|
||||
DEPENDENCY_ORDER:
|
||||
<order or none>
|
||||
|
||||
NON_GOALS:
|
||||
<non-goals>
|
||||
|
||||
OPEN_QUESTIONS:
|
||||
<questions or none>
|
||||
|
||||
NEXT_ACTION:
|
||||
<specific one-sentence action>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next role>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
The final-report validator rejects canonical state update claims when the
|
||||
report omits the canonical block or when the block lacks:
|
||||
|
||||
- `STATE`
|
||||
- `WHO_IS_NEXT`
|
||||
- `NEXT_ACTION`
|
||||
- `NEXT_PROMPT`
|
||||
|
||||
It also rejects vague next actions such as `continue`, ready-to-merge states
|
||||
without approval/head-SHA proof, superseded states without canonical item
|
||||
proof, and blocked states without an unblock condition.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Canonical Thread Handoff (CTH)
|
||||
|
||||
**CTH** = **Canonical Thread Handoff**
|
||||
|
||||
A CTH comment is the authoritative workflow handoff in a Gitea issue or PR
|
||||
thread. It records current state, decisions, blockers, proof, and the exact
|
||||
next prompt/action for the next LLM or person.
|
||||
|
||||
CTH comments complement — but do not replace — formal Gitea review state.
|
||||
A CTH may summarize an APPROVE or REQUEST_CHANGES decision, yet merge gates
|
||||
still require the live Gitea review verdict.
|
||||
|
||||
## CTH comment types
|
||||
|
||||
- `CTH: State Handoff`
|
||||
- `CTH: Controller Decision`
|
||||
- `CTH: Author Handoff`
|
||||
- `CTH: Reviewer Handoff`
|
||||
- `CTH: Merger Handoff`
|
||||
- `CTH: Supersession Notice`
|
||||
- `CTH: Blocker`
|
||||
|
||||
## Required base template
|
||||
|
||||
```md
|
||||
## CTH: <Type>
|
||||
|
||||
Status:
|
||||
Next owner:
|
||||
Current blocker:
|
||||
Decision:
|
||||
Proof:
|
||||
Next action:
|
||||
Ready-to-paste prompt:
|
||||
```
|
||||
|
||||
Extended fields may map to canonical issue/PR state templates from #495 when
|
||||
that layer is available. Until then, keep the base fields complete.
|
||||
|
||||
## Discovery and posting rules
|
||||
|
||||
Before acting in an issue or PR thread:
|
||||
|
||||
1. **Find the latest CTH comment** before doing work.
|
||||
2. Treat the **latest valid CTH** as the current handoff state.
|
||||
3. **Post a new CTH** when you finish, block, skip, supersede, request
|
||||
changes, approve, or hand off.
|
||||
4. Do **not** rely on stale non-CTH comments when a newer CTH exists.
|
||||
5. Casual discussion comments do not need to be CTH comments.
|
||||
|
||||
## Examples
|
||||
|
||||
### PR approved and ready for merger
|
||||
|
||||
```md
|
||||
## CTH: Reviewer Handoff
|
||||
|
||||
Status: approved_at_current_head
|
||||
Next owner: merger
|
||||
Current blocker: none
|
||||
Decision: APPROVE recorded at head abc123...
|
||||
Proof: gitea_submit_pr_review performed; visible verdict APPROVE
|
||||
Next action: eligible merger merges PR #N with pinned expected_head_sha
|
||||
Ready-to-paste prompt: Merge PR #N for issue #M if live head still abc123... and merge gates pass.
|
||||
```
|
||||
|
||||
### PR request-changes back to author
|
||||
|
||||
```md
|
||||
## CTH: Reviewer Handoff
|
||||
|
||||
Status: request_changes_at_current_head
|
||||
Next owner: author
|
||||
Current blocker: unresolved findings in validation report
|
||||
Decision: REQUEST_CHANGES at head def456...
|
||||
Proof: gitea_submit_pr_review performed; blocking review visible
|
||||
Next action: author fixes findings and pushes; reviewer re-validates fresh head
|
||||
Ready-to-paste prompt: Fix PR #N review findings, push to feat/issue-M-..., post Author Handoff CTH.
|
||||
```
|
||||
|
||||
### Duplicate / superseded PR closure
|
||||
|
||||
```md
|
||||
## CTH: Supersession Notice
|
||||
|
||||
Status: superseded
|
||||
Next owner: controller
|
||||
Current blocker: duplicate branch/PR work
|
||||
Decision: close PR #N; continue on PR #M
|
||||
Proof: duplicate gate linked open PR #M for issue #K
|
||||
Next action: controller closes superseded PR and records canonical state
|
||||
Ready-to-paste prompt: Close superseded PR #N; confirm PR #M remains canonical for issue #K.
|
||||
```
|
||||
|
||||
### Blocked workflow due to dirty root/worktree
|
||||
|
||||
```md
|
||||
## CTH: Blocker
|
||||
|
||||
Status: blocked_preflight
|
||||
Next owner: operator
|
||||
Current blocker: dirty control checkout / branches worktree mismatch
|
||||
Decision: stop before mutation
|
||||
Proof: verify_preflight_purity failed; workspace diagnostics attached
|
||||
Next action: repair worktree or relaunch MCP from branches/ worktree
|
||||
Ready-to-paste prompt: Repair dirty workspace, relaunch MCP from branches/review-prN, retry review.
|
||||
```
|
||||
|
||||
### Stale head requiring fresh review
|
||||
|
||||
```md
|
||||
## CTH: Reviewer Handoff
|
||||
|
||||
Status: stale_head
|
||||
Next owner: reviewer
|
||||
Current blocker: live head differs from pinned review head
|
||||
Decision: prior approval not valid for current head
|
||||
Proof: expected_head_sha mismatch at merge gate
|
||||
Next action: re-run validation and post fresh review decision
|
||||
Ready-to-paste prompt: Re-validate PR #N at live head, dry-run review gates, submit fresh verdict.
|
||||
```
|
||||
|
||||
### Issue implementation handoff
|
||||
|
||||
```md
|
||||
## CTH: Author Handoff
|
||||
|
||||
Status: implementation_complete_pending_review
|
||||
Next owner: reviewer
|
||||
Current blocker: none
|
||||
Decision: PR #N ready for review at head fedcba...
|
||||
Proof: tests passed in branches/issue-M-...; PR opened with Closes #M
|
||||
Next action: reviewer acquires lease and validates PR #N
|
||||
Ready-to-paste prompt: Review PR #N for issue #M; pin head fedcba... before mutations.
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [`llm-workflow-runbooks.md`](llm-workflow-runbooks.md)
|
||||
- [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md)
|
||||
- #495 — canonical next-action comment fields
|
||||
- #496 — fail-closed validation for workflow-changing comments
|
||||
@@ -54,6 +54,25 @@ Use `-q` for a compact summary and `-v` to see individual test names.
|
||||
./venv/bin/python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
### Web UI suite (#436)
|
||||
|
||||
Hermetic unittest modules matching `test_webui_*.py` cover route rendering,
|
||||
read-only guards, registry/prompt/queue loaders, and optional child-issue
|
||||
modules when present on the branch.
|
||||
|
||||
```bash
|
||||
./scripts/test-webui
|
||||
./scripts/ci-webui-check # skip unless the diff touches web UI paths
|
||||
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check
|
||||
```
|
||||
|
||||
`scripts/test-webui` defaults `WEBUI_TEST_OFFLINE=1` so route coverage never
|
||||
needs Gitea credentials or MCP daemon credential access. Set
|
||||
`WEBUI_TEST_OFFLINE=0` only for explicit operator live-fetch checks.
|
||||
|
||||
Wire `scripts/ci-webui-check` into Jenkins (or equivalent) for PRs that touch
|
||||
`webui/`, `tests/test_webui_*`, or `docs/webui*`.
|
||||
|
||||
### Run targeted tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Two-comment workflow examples (#507)
|
||||
|
||||
Paired `[CONTROLLER HANDOFF]` + `[THREAD STATE LEDGER]` comments for Gitea threads.
|
||||
See `thread_state_ledger_examples.py` for machine-checked fixtures.
|
||||
|
||||
## Approved review posted
|
||||
|
||||
**Handoff** (detailed): identity, worktree, validation commands, mutation ledger with
|
||||
`gitea_submit_pr_review → APPROVED review posted to Gitea`.
|
||||
|
||||
**Ledger** (concise):
|
||||
|
||||
```markdown
|
||||
[THREAD STATE LEDGER] PR #487 — APPROVED review posted to Gitea
|
||||
|
||||
What is true now:
|
||||
- PR state: open
|
||||
- Server-side decision state: APPROVED review posted to Gitea
|
||||
- Local verdict/state: APPROVE verdict prepared locally
|
||||
|
||||
What is blocked:
|
||||
- Blocker classification: no blocker
|
||||
|
||||
Who/what acts next:
|
||||
- Next actor: merger
|
||||
- Required action: merge on explicit operator command
|
||||
- Do not do: re-post APPROVE
|
||||
```
|
||||
|
||||
## Approve validated locally but blocked before posting
|
||||
|
||||
Ledger must show `no server-side state changed` under server-side decision state and
|
||||
`APPROVE verdict prepared locally` under local verdict/state.
|
||||
|
||||
## Environment / tooling blocker
|
||||
|
||||
Ledger blocker classification: `environment/tooling blocker`. Mutation ledger:
|
||||
`none — no server-side state changed`.
|
||||
|
||||
## Stale head blocker
|
||||
|
||||
Ledger: `approval_at_current_head is false`; classification `stale head`.
|
||||
Do not do: merge with stale approval.
|
||||
@@ -22,9 +22,12 @@ launched with exactly one static execution profile:
|
||||
| Namespace (MCP server name) | Profile (role) | Typical use |
|
||||
|-----------------------------|----------------|-------------|
|
||||
| `gitea-author` | an author profile | implement issues, push branches, open PRs, comment |
|
||||
| `gitea-reviewer` | a reviewer profile | review, approve/request changes, merge |
|
||||
| `gitea-reviewer` | a reviewer profile | review, approve/request changes |
|
||||
| `gitea-merger` | a merger profile | merge PRs after approval and verification |
|
||||
| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) |
|
||||
|
||||
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
|
||||
Properties:
|
||||
|
||||
- **One process, one credential.** Each namespace authenticates as exactly
|
||||
@@ -106,6 +109,14 @@ syntax to the client):
|
||||
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||
"GITEA_MCP_PROFILE": "<reviewer-profile-name>"
|
||||
}
|
||||
},
|
||||
"gitea-merger": {
|
||||
"command": "<path-to>/venv/bin/python3",
|
||||
"args": ["<path-to>/mcp_server.py"],
|
||||
"env": {
|
||||
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||
"GITEA_MCP_PROFILE": "<merger-profile-name>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ authenticated capability set. Each profile defines the following fields:
|
||||
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
||||
| `allowed_operations` | list | Operation categories this profile may perform. |
|
||||
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
||||
| `allowed_repositories` | list | Optional. Canonical `owner/repository` slugs this profile may bind to. An authorization boundary, not the binding itself — see [Repository scope](#repository-scope-714). |
|
||||
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
||||
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
||||
| `can_approve_prs` | bool | May submit an approving PR review. |
|
||||
@@ -57,6 +58,47 @@ authenticated capability set. Each profile defines the following fields:
|
||||
name), never the token itself. Token values are never part of a profile object,
|
||||
never logged, never returned by a tool, and never committed.
|
||||
|
||||
## Repository scope (#714)
|
||||
|
||||
`allowed_repositories` declares the canonical `owner/repository` slugs a profile
|
||||
may operate on:
|
||||
|
||||
```json
|
||||
"prgs-author": {
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"]
|
||||
}
|
||||
```
|
||||
|
||||
It is an **authorization boundary, not the session binding**. The binding is
|
||||
derived and enforced like this:
|
||||
|
||||
1. The session repository is derived from the **verified, workspace-aligned git
|
||||
remote** — never from a caller-supplied `org`/`repo` argument, and never from
|
||||
the `REMOTES` table (whose entries are default *targets*: `prgs` defaults to
|
||||
`Timesheet`, which is not this project).
|
||||
2. That workspace-derived slug is validated against `allowed_repositories`.
|
||||
3. The session binds immutably to that one canonical `owner/repository`. The
|
||||
organization is derived from the slug; there is no independent,
|
||||
caller-controlled organization value.
|
||||
4. If a profile authorizes several repositories, the verified workspace still
|
||||
selects exactly one. A session never binds to the whole list and never
|
||||
switches between entries.
|
||||
|
||||
Fail-closed rules:
|
||||
|
||||
- Activation is rejected when the workspace repository is absent from the
|
||||
allowlist.
|
||||
- A mutation is rejected when no verified workspace repository can be
|
||||
established.
|
||||
- A tool-level `org`/`repo` override that disagrees with the binding is rejected
|
||||
before the mutation runs.
|
||||
- A mutation request can never establish, complete, or replace the binding.
|
||||
|
||||
The field is **config-only**: no environment variable can widen or forge it. It
|
||||
is optional — a profile that omits it keeps the previous behaviour, so existing
|
||||
static-profile namespaces stay functional until an operator provisions the
|
||||
scope. Provisioning it is what activates enforcement for that profile.
|
||||
|
||||
## Example profiles
|
||||
|
||||
The following are the reference profiles. Booleans express intended capability
|
||||
@@ -238,11 +280,43 @@ narrow operation set:
|
||||
- `gitea.issue.comment`
|
||||
- `gitea.issue.close`
|
||||
- `gitea.pr.close`
|
||||
- `gitea.branch.delete` (merged-branch cleanup only — see below)
|
||||
|
||||
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
||||
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
||||
`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).
|
||||
|
||||
Launch a static `gitea-reconciler` MCP namespace with
|
||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||
@@ -251,6 +325,159 @@ Launch a static `gitea-reconciler` MCP namespace with
|
||||
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
||||
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
|
||||
|
||||
Before **any** mutating action, a workflow must know both:
|
||||
@@ -311,7 +538,8 @@ To make Gitea MCP profile activation and runtime identity state explicit, the fo
|
||||
### 2. Dual MCP Namespaces Recommendation
|
||||
For security-sensitive or high-risk tasks, the preferred safety model uses separate, isolated MCP server instances (namespaces/sessions) launched with static profiles:
|
||||
- `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges.
|
||||
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews and merges.
|
||||
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews. Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
- `gitea-merger`: Exposes tools configured with merger permissions; used for PR merges.
|
||||
This layout maintains physical separation of credentials and prevents privilege escalation within a single session.
|
||||
This is the model accepted in #139; deployment details, rationale, and client
|
||||
setup live in
|
||||
|
||||
+155
-24
@@ -1,34 +1,165 @@
|
||||
# Label Taxonomy
|
||||
|
||||
This document catalogs the issue labels used for MCP workflows, including Jenkins and GlitchTip (observability).
|
||||
This document defines the canonical issue labels used by MCP workflows.
|
||||
|
||||
> **Approval Required:** Do not create or apply new labels in `manage_labels.py` without explicit owner approval of this document.
|
||||
Every issue should carry:
|
||||
|
||||
## Existing Labels
|
||||
- one `type:*` label
|
||||
- one `status:*` label
|
||||
|
||||
* **`jenkins`**
|
||||
* Description: Jenkins integration
|
||||
* Color: `d93f0b`
|
||||
* Use: Used to mark issues, PRs, or tasks that involve the `jenkins-mcp` boundaries, CI/CD designs, or build failures.
|
||||
Discussion-only issues must carry `type:discussion`.
|
||||
|
||||
* **`glitchtip`**
|
||||
* Description: GlitchTip integration
|
||||
* Color: `b60205`
|
||||
* Use: Used to mark issues related to the `glitchtip-mcp` boundary and observability integration.
|
||||
## Issue Type Labels
|
||||
|
||||
## Proposed / Missing Labels
|
||||
| Label | Use |
|
||||
| --- | --- |
|
||||
| `type:bug` | Bug or defect |
|
||||
| `type:feature` | Feature or enhancement |
|
||||
| `type:process` | Process or policy work |
|
||||
| `type:workflow` | Workflow automation or guidance |
|
||||
| `type:guardrail` | Safety gate or guardrail |
|
||||
| `type:docs` | Documentation work |
|
||||
| `type:test` | Tests or test infrastructure |
|
||||
| `type:discussion` | Discussion-only issue |
|
||||
| `type:umbrella` | Umbrella or tracker issue |
|
||||
| `type:cleanup` | Cleanup or hygiene work |
|
||||
|
||||
* **`observability`**
|
||||
* Proposed Description: Observability, metrics, and monitoring tasks
|
||||
* Proposed Color: `5319e7`
|
||||
* Use: Broader than GlitchTip alone; covers logging, metrics, traces, and general observability pipeline improvements.
|
||||
## Workflow Status Labels
|
||||
|
||||
* **`source:glitchtip`**
|
||||
* Proposed Description: Issue filed automatically by GlitchTip orchestration
|
||||
* Proposed Color: `b60205`
|
||||
* Use: Applied automatically by the orchestrator when a GlitchTip error event is converted into a Gitea issue.
|
||||
Only one `status:*` label should be active on an issue at a time. When an issue
|
||||
moves forward, tooling must remove the old `status:*` label and apply the new
|
||||
one.
|
||||
|
||||
* **`status:triage`**
|
||||
* Proposed Description: Issue needs human or orchestrator triage
|
||||
* Proposed Color: `fbca04`
|
||||
* Use: Used for incoming issues (especially automated ones like `source:glitchtip`) that have not yet been evaluated for priority or resolution.
|
||||
| Label | Use |
|
||||
| --- | --- |
|
||||
| `status:triage` | Issue needs triage |
|
||||
| `status:ready` | Issue is ready for work |
|
||||
| `status:claimed` | Issue is claimed |
|
||||
| `status:in-progress` | Issue is being worked on |
|
||||
| `status:blocked` | Issue is blocked |
|
||||
| `status:needs-review` | Issue work needs review |
|
||||
| `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:merged` | Linked PR is merged |
|
||||
| `status:reconcile` | Issue needs reconciliation |
|
||||
| `status:done` | Issue workflow is complete |
|
||||
| `status:duplicate` | Issue is a duplicate |
|
||||
| `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
|
||||
|
||||
Suggested lifecycle:
|
||||
|
||||
1. New issue created: `status:triage` or `status:ready`
|
||||
2. Issue selected by an author: `status:claimed`
|
||||
3. Author starts work: `status:in-progress`
|
||||
4. Work is blocked: `status:blocked`
|
||||
5. PR opened: `status:pr-open`
|
||||
6. PR approved: `status:approved`
|
||||
7. PR merged but issue still needs closure/reconciliation: `status:reconcile`
|
||||
8. Issue fully complete: `status:done`
|
||||
9. Duplicate issue: `status:duplicate`
|
||||
10. Won't-fix issue: `status:wontfix`
|
||||
|
||||
The helper module `issue_workflow_labels.py` is the source of truth for the
|
||||
canonical label specs and status transition replacement behavior.
|
||||
|
||||
## Discussion Issues
|
||||
|
||||
Discussion issues must be labeled `type:discussion`.
|
||||
|
||||
A discussion issue should not be treated as implementation-ready unless it also
|
||||
has a clear implementation status and next action.
|
||||
|
||||
If a discussion produces implementation work, either:
|
||||
|
||||
1. convert the discussion issue into an implementation issue by changing labels
|
||||
and adding acceptance criteria, or
|
||||
2. create child implementation issues and leave the discussion issue as
|
||||
`type:discussion`.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `manage_labels.py --create-labels` creates the canonical `type:*` and
|
||||
`status:*` labels.
|
||||
- `gitea_create_issue` recommends `type:*` and `status:*` labels when missing
|
||||
and can apply supplied label names.
|
||||
- `gitea_mark_issue(..., action="start")` replaces old `status:*` labels with
|
||||
`status:in-progress`.
|
||||
- `gitea_create_pr` fails closed before PR creation if `status:pr-open` cannot
|
||||
be applied to the locked issue, then applies it after the PR is created.
|
||||
- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author
|
||||
sessions can satisfy the branches-only mutation guard while changing labels.
|
||||
|
||||
## Existing Non-Workflow Labels
|
||||
|
||||
Existing non-workflow labels such as `mcp`, `workflow`, `labels`, `tracker`,
|
||||
`jenkins`, `glitchtip`, `documentation`, and `testing` remain valid topical
|
||||
labels. They do not replace the required `type:*` and `status:*` labels.
|
||||
|
||||
+299
-18
@@ -7,6 +7,11 @@ package of the MCP Control Plane: creating issues, implementing them, opening
|
||||
and reviewing pull requests, merging, and closing out — safely and
|
||||
reproducibly.
|
||||
|
||||
Canonical state comments for durable issue/PR/discussion continuation are
|
||||
documented in [`canonical-state-comments.md`](canonical-state-comments.md).
|
||||
Use them when a workflow-changing comment needs to leave the next actor, next
|
||||
action, and paste-ready prompt in Gitea.
|
||||
|
||||
> For the **project-agnostic** version of these operating rules (issue-first,
|
||||
> isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed)
|
||||
> that can be copied into any repository, see the reusable skill
|
||||
@@ -28,6 +33,8 @@ audit logging). See [Related documents](#related-documents).
|
||||
> to discover the available project workflows and `mcp_get_skill_guide(<name>)`
|
||||
> for step-by-step instructions. This replaces long pasted operator prompts for
|
||||
> the standard rules; operator prompts still control task-specific scope.
|
||||
>
|
||||
> **BLOCKED + DIAGNOSE (default for any missing required step):** If a required workflow skill, guide, tool, capability, preflight, terminal, worktree binding, profile, or instruction is unavailable or fails, STOP. State BLOCKED. Use the canonical blocker report template (see skills/llm-project-workflow/templates/blocked-diagnose-report.md and the llm-project-workflow/SKILL.md universal rules). Only non-mutating recovery. Report fully. No unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) unless controller authorizes in the handoff for this case. Missing required steps must fail closed *before* any git or Gitea mutation. Controller prompts and all workflows must reinforce: BLOCKED + DIAGNOSE, then stop.
|
||||
> See issue #129 for the skill registry design.
|
||||
|
||||
Jenkins and GlitchTip workflows use separate MCP servers, not this Gitea MCP
|
||||
@@ -188,7 +195,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
|
||||
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
|
||||
|
||||
@@ -408,6 +415,31 @@ The guard blocks when the **control checkout** (repository root) is:
|
||||
`branches/...` directories are disposable role worktrees; the root checkout is
|
||||
the stable orchestration surface only.
|
||||
|
||||
## Canonical workflow skill names (#551)
|
||||
|
||||
Controller prompts and sessions must load the **same** workflow skill wall
|
||||
regardless of runtime (Claude, Codex, Gemini):
|
||||
|
||||
| Name | Role |
|
||||
|------|------|
|
||||
| `gitea-workflow` | Primary controller / Codex skill name |
|
||||
| `llm-project-workflow` | Portable in-repo package |
|
||||
| `git-pr-workflows` | Legacy alias |
|
||||
|
||||
- Inventory: `mcp_list_project_skills` lists all three.
|
||||
- Preflight: `mcp_check_workflow_skill_preflight` before mutations.
|
||||
- Codex install: `scripts/install-codex-workflow-skill.sh`
|
||||
- Full doc: [`docs/workflow-skill-mount.md`](workflow-skill-mount.md)
|
||||
|
||||
If the skill is missing, stop with BLOCKED + DIAGNOSE — do not mutate.
|
||||
|
||||
## No direct-import mutation path (#558)
|
||||
|
||||
Never `import gitea_mcp_server` or call `gitea_auth.get_auth_header` /
|
||||
keychain fill from a raw shell to bypass MCP preflight.
|
||||
|
||||
Use the official MCP daemon only. See [`docs/mcp-daemon-import-guard.md`](mcp-daemon-import-guard.md).
|
||||
|
||||
## Bootstrap Review Path for self-hosted MCP fixes (#557)
|
||||
|
||||
When a PR fixes Gitea-Tools MCP workflow code that the **live daemon** still
|
||||
@@ -629,24 +661,44 @@ session, clearing hung background terminals, switching to MCP-native commit, and
|
||||
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
|
||||
loop and do **not** substitute WebFetch/Playwright/manual base64.
|
||||
|
||||
### Terminal launcher diagnostics (#556)
|
||||
|
||||
When git/pytest finalization fails with opaque spawn errors (e.g. `os error 2`),
|
||||
do **not** improvise shell wrappers or fall back to direct API / temp scripts.
|
||||
|
||||
1. Call `gitea_diagnose_terminal` (optional `cwd`, optional `command`) — returns
|
||||
categorized failure: missing cwd, cwd not a directory, missing executable,
|
||||
missing runtime wrapper, missing shell, probe timeout, or session launcher
|
||||
failure.
|
||||
2. Call `gitea_get_shell_health` for the shell circuit-breaker state.
|
||||
3. Mutation-capable `gitea_resolve_task_capability` probes the terminal launcher
|
||||
before allowing git/pytest-oriented work; on failure it returns
|
||||
`BLOCKED + DIAGNOSE` with `terminal_launcher_unhealthy` and diagnostics.
|
||||
4. Emit the canonical `blocked-diagnose-report.md` template and stop.
|
||||
|
||||
### Create an issue / child issues
|
||||
|
||||
- **Profile:** issue-manager or author (any profile allowed to create issues).
|
||||
- **Steps:** create the parent/roadmap issue; create child issues; apply the
|
||||
minimal label set; link children to the parent.
|
||||
- **Labels:** new issues should carry one `type:*` label and one `status:*`
|
||||
label. Discussion-only issues must carry `type:discussion`. See
|
||||
[`label-taxonomy.md`](label-taxonomy.md).
|
||||
- **Prompt:** `Using the issue-manager profile, create issue "<title>" with body
|
||||
<body>, then create child issues for <list> and link them to the parent.`
|
||||
|
||||
### Implement an issue and open a PR
|
||||
|
||||
- **Profile:** author.
|
||||
- **Steps:** claim the issue (`status:in-progress`); create an isolated branch
|
||||
worktree from latest `master` under `branches/` (`feat/issue-<n>-...` /
|
||||
- **Steps:** claim the issue (`status:in-progress`, replacing any old
|
||||
`status:*` label); create an isolated branch worktree from latest `master`
|
||||
under `branches/` (`feat/issue-<n>-...` /
|
||||
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||
update tests if behavior changes; run the full suite; commit with an
|
||||
issue-linked message; open a PR to `master`. **Do not** review or merge your
|
||||
own PR. Include an `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in
|
||||
the PR body — see [`llm-agent-sha.md`](llm-agent-sha.md).
|
||||
issue-linked message; open a PR to `master`; move the issue to
|
||||
`status:pr-open`. **Do not** review or merge your own PR. Include an
|
||||
`LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see
|
||||
[`llm-agent-sha.md`](llm-agent-sha.md).
|
||||
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
||||
master. Do not self-review or self-merge.`
|
||||
|
||||
@@ -687,10 +739,10 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
|
||||
### Merge a PR
|
||||
|
||||
- **Profile:** merger (allowed to merge; must **not** be the PR author).
|
||||
- **Steps:** confirm eligibility; require explicit confirmation
|
||||
(`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when
|
||||
Gitea reports the PR mergeable (branch-protection checks satisfied). No force,
|
||||
no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
||||
- **Steps:**
|
||||
- Merger workflow starts only after formal approval at current head. Reviewer workflow ends with review decision and separate merger handoff.
|
||||
- Confirm eligibility; require explicit confirmation (`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when Gitea reports the PR mergeable (branch-protection checks satisfied). No force, no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
||||
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
||||
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
|
||||
|
||||
@@ -738,6 +790,53 @@ merger handoff.
|
||||
- **Prompt (normal):** `After verifying master contains the merge of PR #N using post-merge file-presence verification, close issue #M and delete the merged branch. Include verification details in the report.`
|
||||
- **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.`
|
||||
|
||||
### Post-merge merged cleanup ownership (#523)
|
||||
|
||||
Post-merge **local worktree / remote branch cleanup** is **reconciler** work, not
|
||||
author work. Do not switch from `prgs-reconciler` to `prgs-author` only to run
|
||||
`gitea_reconcile_merged_cleanups`.
|
||||
|
||||
- **Profile:** `prgs-reconciler` (task `reconcile_merged_cleanups` /
|
||||
`reconciliation_cleanup`).
|
||||
- **Namespace:** reconciler MCP server; stable control checkout is allowed for
|
||||
this role (branches-only author guard does not apply).
|
||||
- **Steps:**
|
||||
1. `gitea_whoami` + `gitea_resolve_task_capability(task="reconcile_merged_cleanups")`.
|
||||
2. Dry-run first: `gitea_reconcile_merged_cleanups(dry_run=True)`.
|
||||
3. Execute only after audit/authorization gates when remote branch delete or
|
||||
worktree removal is required (`dry_run=False`, `execute_confirmed=True`,
|
||||
and `gitea.branch.delete` when deleting remotes).
|
||||
- **Fail closed:** unmerged/open heads, mismatched worktrees, and non-merged
|
||||
closed PRs must not be cleaned.
|
||||
- **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.`
|
||||
|
||||
### 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
|
||||
|
||||
- **Any profile.** If a required gate cannot be satisfied — identity
|
||||
@@ -758,7 +857,7 @@ an author.
|
||||
|---|---|---|---|---|
|
||||
| Review PR (`review_pr`) | reviewer (e.g. `sysadmin` / `prgs-reviewer`) | read, gated review verdicts | commits, pushes, file edits, author comments, merge without eligibility | active profile is an author profile — stop immediately; do **not** switch to author-side fixes unless the operator explicitly re-tasks |
|
||||
| Address PR change requests (`address_pr_change_requests`) | author (e.g. `jcwalker3` / `prgs-author`) | commit/push fixes to the PR branch, PR comment summarizing fixes | review verdicts, approve, request-changes, merge | active profile lacks branch push |
|
||||
| Merge PR (`merge_pr`) | reviewer/merger | gated merge after eligibility + approval | merging own PR, merging without pinned head match | active profile is an author profile, or any merge gate fails |
|
||||
| Merge PR (`merge_pr`) | merger (e.g. `sysadmin` / `prgs-merger`) | gated merge after eligibility + approval | merging own PR, merging without pinned head match, reviewing PRs | active profile is an author or reviewer-only profile, or any merge gate fails |
|
||||
| Comment on issue discussion (`comment_issue`) | any profile with `gitea.issue.comment` | issue thread comments | review verdicts, closing via comment | permission missing (`gitea.pr.comment` does **not** imply it) |
|
||||
| Comment on PR (`comment_pr`) | any profile with `gitea.pr.comment` | PR thread comments | review verdicts | permission missing |
|
||||
| Author implementation (`create_branch`/`push_branch`/`create_pr`) | author | branch, commit, push, open PR | self-review, self-merge | profile lacks the author permissions |
|
||||
@@ -808,6 +907,27 @@ Never imply full-suite success unless the full-suite command itself passed
|
||||
(`full_suite_passed: true`). A report that hides a failed or skipped check
|
||||
is worse than a failing report.
|
||||
|
||||
## Canonical Thread Handoff (CTH)
|
||||
|
||||
**CTH** = **Canonical Thread Handoff** — the authoritative workflow handoff
|
||||
comment in a Gitea issue or PR thread. See
|
||||
[`canonical-thread-handoff.md`](canonical-thread-handoff.md) for types,
|
||||
templates, and examples.
|
||||
|
||||
Before acting in an issue or PR thread:
|
||||
|
||||
1. **Find the latest CTH comment** before doing work.
|
||||
2. Treat the **latest valid CTH** as the current handoff state.
|
||||
3. **Post a new CTH** when you finish, block, skip, supersede, request
|
||||
changes, approve, or hand off.
|
||||
4. Do **not** rely on stale non-CTH comments when a newer CTH exists.
|
||||
|
||||
A CTH summarizes workflow state for the next session. Formal Gitea review
|
||||
verdicts remain authoritative for merge gates — a CTH is not merge approval
|
||||
by itself.
|
||||
|
||||
Template: [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md)
|
||||
|
||||
## Controller Handoff (required, every task)
|
||||
|
||||
Every task — implementation, review, merge, triage, documentation,
|
||||
@@ -840,6 +960,161 @@ touched release state names the exact tag/commit and why. Design debates
|
||||
belong in **discussion/RFC issues** (e.g. #100 `profiles.json v2`) — comment
|
||||
on the issue, create no branches/PRs, and end the comment with this handoff.
|
||||
|
||||
## Two-comment workflow reporting (#507)
|
||||
|
||||
After meaningful controller/workflow work, post **two separate Gitea comments**
|
||||
(not one combined blob):
|
||||
|
||||
1. **`[CONTROLLER HANDOFF]`** — detailed operational continuation for the
|
||||
next LLM/controller (proof-heavy; may be long).
|
||||
2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds.
|
||||
|
||||
The ledger must answer: what is true now, what changed, what is blocked,
|
||||
who/what acts next — and must **separate**:
|
||||
|
||||
- local verdict/state
|
||||
- server-side Gitea state
|
||||
- attempted-but-blocked mutations
|
||||
- completed mutations
|
||||
|
||||
Use precise state phrases (`APPROVED review posted to Gitea`,
|
||||
`APPROVE verdict prepared locally`, `merge performed`, `merge not performed`,
|
||||
`no server-side state changed`, `lease attempt blocked`) instead of ambiguous
|
||||
standalone words (`approved`, `merged`, `ready`, `blocked`, `done`).
|
||||
|
||||
The ledger must include a **blocker classification** from:
|
||||
`code blocker`, `test blocker`, `merge conflict`, `stale head`,
|
||||
`permission/capability blocker`, `environment/tooling blocker`,
|
||||
`process/rule blocker`, `queue/lease blocker`,
|
||||
`duplicate/canonicalization blocker`, `no blocker`.
|
||||
|
||||
Templates: [`two-comment-workflow.md`](two-comment-workflow.md).
|
||||
Worked examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md).
|
||||
|
||||
Validation: `thread_state_ledger_validator.py` checks tagged comments at post
|
||||
time (`gitea_create_issue_comment`) and tagged final reports via
|
||||
`assess_final_report_validator`. Legacy `## Controller Handoff` final reports
|
||||
remain valid during transition; the tagged pair is required for new workflow
|
||||
comments.
|
||||
|
||||
Related (do not duplicate): #494/#495 lifecycle state, #501 mutation-ledger
|
||||
consistency, #505 CTH umbrella, #496 workflow comment gate when merged.
|
||||
|
||||
## Canonical comment validation (#496)
|
||||
|
||||
Workflow-changing issue/PR/review comments must carry durable next-action
|
||||
state. Casual discussion is still allowed.
|
||||
|
||||
The MCP server runs `canonical_comment_validator.assess_canonical_comment`
|
||||
**before** posting through:
|
||||
|
||||
- `gitea_create_issue_comment`
|
||||
- `gitea_submit_pr_review` / `gitea_dry_run_pr_review` (non-empty review bodies)
|
||||
- `gitea_reconcile_already_landed_pr` when `post_comment=True`
|
||||
- internal structured comment helpers (machine lease/heartbeat markers stay exempt)
|
||||
|
||||
Detection examples:
|
||||
|
||||
- **Allowed:** `Thanks, I will check this.`
|
||||
- **Rejected:** `Blocked, author should fix.` (workflow trigger without canonical fields)
|
||||
|
||||
When validation fails, the tool returns `canonical_comment_validation` with
|
||||
`allowed: false`, `missing_fields`, `vague_fields`, `correction_message`, and
|
||||
`suggested_template`. **No Gitea API call is made.**
|
||||
|
||||
Minimum workflow comment fields:
|
||||
|
||||
```text
|
||||
STATE:
|
||||
WHO_IS_NEXT:
|
||||
NEXT_ACTION:
|
||||
NEXT_PROMPT:
|
||||
WHY:
|
||||
```
|
||||
|
||||
`WHO_IS_NEXT` must be one of: `controller`, `author`, `reviewer`, `merger`,
|
||||
`reconciler`, `user`.
|
||||
|
||||
Issue comments also require `RELATED_PRS`, `BLOCKERS`, and `VALIDATION` when
|
||||
they mention PR work. PR comments/reviews also require `ISSUE`, `HEAD_SHA`,
|
||||
`REVIEW_STATUS`, `MERGE_READY`, `BLOCKERS`, and `VALIDATION`.
|
||||
|
||||
Special states:
|
||||
|
||||
- `STATE: blocked` — `BLOCKERS` must name an explicit unblock condition.
|
||||
- `STATE: superseded` — requires `CANONICAL_ITEM` and `SUPERSEDED_ITEM`.
|
||||
- `STATE: ready-to-merge` — requires approval proof and head SHA in
|
||||
`HEAD_SHA`, `REVIEW_STATUS`, `MERGE_READY`, or `VALIDATION`.
|
||||
|
||||
Final reports must not claim a comment was posted when
|
||||
`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
|
||||
|
||||
Before any mutating action the workflow verifies identity, active profile,
|
||||
@@ -925,10 +1200,13 @@ When a mutation blocks on workspace binding:
|
||||
|
||||
1. Read the error — it names the **resolved workspace path**, **role
|
||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
||||
workspace (or set the role-specific env var before launch).
|
||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
||||
branches/ worktree differs from the MCP process root.
|
||||
2. **LLM-allowed:** pass `worktree_path` on reviewer/merger mutation tools when
|
||||
the active `branches/` worktree differs from the MCP process root; **client
|
||||
reconnect** after transport EOF only (no process kill).
|
||||
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
|
||||
own namespace — that destroys another agent's WIP.
|
||||
|
||||
@@ -938,9 +1216,10 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||
- Quote the resolved workspace path and binding source from the error.
|
||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
||||
- Name the safe next action: pass `worktree_path` if that unblocks the tool, or
|
||||
request an **operator** reload of the correct namespace MCP / env binding.
|
||||
- Explicitly note that foreign worktrees must not be cleaned to unblock, and
|
||||
that the LLM must not self-restart the MCP process.
|
||||
|
||||
## Safety notes
|
||||
|
||||
@@ -951,6 +1230,8 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
## Related documents
|
||||
|
||||
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# MCP daemon import and native-transport guard (#558 / #695)
|
||||
|
||||
## Problem
|
||||
|
||||
During deadlock debugging and the PR #694 incident (#695), agents imported
|
||||
`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
|
||||
|
||||
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 |
|
||||
|---------|---------|
|
||||
| Official IDE-native MCP: resolved canonical entrypoint marks + binds stdio, holds process-local runtime token | yes |
|
||||
| pytest (hermetic unit tests) via `is_pytest_runtime()` | yes for unit gates |
|
||||
| `install_test_native_runtime()` under pytest (test-mode record) | unit-test transport gates only — **never** production Gitea mutations |
|
||||
| `allow_test_bootstrap=True` (removed; must not exist) | **no** |
|
||||
| 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
|
||||
|
||||
LLM sessions must never set allow-direct-import, allow-keychain-cli, or raw
|
||||
token overrides. Those are human-only escape hatches outside agent workflows.
|
||||
+1
-1
@@ -41,7 +41,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
||||
|--------|-------------|
|
||||
| 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`. |
|
||||
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
||||
| Reviewer workflow prompts | PR review prompt (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). |
|
||||
| Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. |
|
||||
| Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. |
|
||||
|
||||
@@ -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,138 @@
|
||||
# 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. 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,35 @@
|
||||
# Reviewer Handoff Consistency
|
||||
|
||||
Reviewer and final-review controller handoffs must not contradict themselves.
|
||||
A narrative that says a merge happened, a review was blocked, or a terminal
|
||||
mutation budget was consumed must match the mutation ledger fields in the same
|
||||
handoff.
|
||||
|
||||
## What gets validated
|
||||
|
||||
`reviewer_handoff_consistency.assess_reviewer_handoff_consistency()` checks:
|
||||
|
||||
- merge claims appear under `Merge mutations` or `MCP/Gitea mutations`
|
||||
- terminal-mutation-budget claims name the exact prior mutation in the ledger
|
||||
- blocked review submission is not paired with "final decision marked"
|
||||
- reviewer lease acquisition includes a `Review decision`
|
||||
- blocked/rejected mutations include proof fields:
|
||||
- tool called
|
||||
- mutation attempted
|
||||
- mutation rejected
|
||||
- no server-side state changed
|
||||
|
||||
`final_report_validator` applies this as `reviewer.handoff_consistency` on
|
||||
`review_pr` reports and fails closed.
|
||||
|
||||
## Blocked review template
|
||||
|
||||
When `gitea_submit_pr_review` fails closed, use
|
||||
`reviewer_handoff_consistency.render_blocked_review_handoff_template()` or the
|
||||
copy in
|
||||
[`skills/llm-project-workflow/templates/blocked-review-handoff.md`](../skills/llm-project-workflow/templates/blocked-review-handoff.md).
|
||||
|
||||
## Related
|
||||
|
||||
- #331 — file-mutation ledger alignment
|
||||
- #501 — contradictory narrative vs ledger detection
|
||||
@@ -0,0 +1,123 @@
|
||||
# Two-comment workflow: Controller Handoff + Thread State Ledger
|
||||
|
||||
After meaningful controller/workflow work, post **two separate Gitea comments**:
|
||||
|
||||
1. **`[CONTROLLER HANDOFF]`** — detailed operational handoff for the next
|
||||
LLM/controller session (proof-heavy; may be long).
|
||||
2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds.
|
||||
|
||||
The ledger is the concise source of truth. The handoff is the detailed
|
||||
continuation artifact. This complements CTH (#505) and lifecycle state
|
||||
comments (#494/#495) without replacing them.
|
||||
|
||||
## Controller Handoff template
|
||||
|
||||
```markdown
|
||||
[CONTROLLER HANDOFF] PR #___ / Issue #___ — <short title>
|
||||
|
||||
Purpose:
|
||||
This comment is the operational handoff for the next controller/LLM session.
|
||||
|
||||
Identity/profile:
|
||||
- Active profile:
|
||||
- Authenticated identity:
|
||||
- Role:
|
||||
- Self-review / role-conflict proof:
|
||||
|
||||
Target:
|
||||
- Repo:
|
||||
- Issue:
|
||||
- PR:
|
||||
- Branch:
|
||||
- Pinned head SHA:
|
||||
- Worktree:
|
||||
|
||||
Work performed:
|
||||
- <step 1>
|
||||
- <step 2>
|
||||
|
||||
Files touched or reviewed:
|
||||
- `<file>` — <why it matters>
|
||||
|
||||
Validation:
|
||||
- `<command>` → <result>
|
||||
- Full suite: <result or not run + reason>
|
||||
|
||||
Server-side mutation ledger:
|
||||
- <mutation 1, including tool/action/comment id if available>
|
||||
- Or: none — no server-side state changed
|
||||
|
||||
Local-only changes:
|
||||
- <worktree created, files edited locally, etc.>
|
||||
- Or: none
|
||||
|
||||
Blockers:
|
||||
- <none>
|
||||
- Or: <exact blocker, exact gate, exact reason>
|
||||
|
||||
Controller prompt for next session:
|
||||
```markdown
|
||||
<ready-to-paste prompt>
|
||||
```
|
||||
```
|
||||
|
||||
## Thread State Ledger template
|
||||
|
||||
```markdown
|
||||
[THREAD STATE LEDGER] PR #___ / Issue #___ — <current state in one line>
|
||||
|
||||
What is true now:
|
||||
- PR state:
|
||||
- Issue state:
|
||||
- Current head SHA:
|
||||
- Server-side decision state:
|
||||
- Local verdict/state:
|
||||
- Latest known validation:
|
||||
|
||||
What changed:
|
||||
- <short summary since prior ledger>
|
||||
|
||||
What is blocked:
|
||||
- Blocker classification: <see list below>
|
||||
|
||||
Who/what acts next:
|
||||
- Next actor:
|
||||
- Required action:
|
||||
- Do not do:
|
||||
- Resume from:
|
||||
```
|
||||
|
||||
### Blocker classifications
|
||||
|
||||
- code blocker
|
||||
- test blocker
|
||||
- merge conflict
|
||||
- stale head
|
||||
- permission/capability blocker
|
||||
- environment/tooling blocker
|
||||
- process/rule blocker
|
||||
- queue/lease blocker
|
||||
- duplicate/canonicalization blocker
|
||||
- no blocker
|
||||
|
||||
### Precise state language
|
||||
|
||||
Prefer:
|
||||
|
||||
- `APPROVE verdict prepared locally`
|
||||
- `APPROVED review posted to Gitea`
|
||||
- `REQUEST_CHANGES posted to Gitea`
|
||||
- `merge performed` / `merge not performed`
|
||||
- `lease acquired` / `lease attempt blocked`
|
||||
- `server-side state changed` / `no server-side state changed`
|
||||
|
||||
Avoid ambiguous standalone claims (`approved`, `ready`, `merged`, `blocked`,
|
||||
`done`) without proof and server-side state separation.
|
||||
|
||||
## Validation
|
||||
|
||||
- `thread_state_ledger_validator.py` — comment and final-report checks
|
||||
- `gitea_create_issue_comment` — fail-closed gate on tagged comments
|
||||
- `assess_final_report_validator` — `shared.two_comment_workflow` rule
|
||||
|
||||
Examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Web UI deployment boundary (#435)
|
||||
|
||||
The MCP Control Plane web UI is an **internal operator console**, not a
|
||||
customer-facing application. The MVP assumes local or trusted-network access
|
||||
only.
|
||||
|
||||
## MVP deployment model
|
||||
|
||||
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
||||
- **Authentication:** none in MVP — protection comes from network placement
|
||||
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
||||
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
||||
embedded in HTML, JavaScript, or browser storage
|
||||
|
||||
Do **not** expose the UI on the public internet without an access layer.
|
||||
|
||||
## Beyond localhost
|
||||
|
||||
If the UI must be reachable outside the operator laptop:
|
||||
|
||||
1. Prefer **Cloudflare Access**, **Cloudflare WARP**, or an org **VPN** so only
|
||||
authenticated staff reach the service.
|
||||
2. Bind to a specific interface only when necessary — never `0.0.0.0` / `::`
|
||||
without understanding the exposure.
|
||||
3. Set explicit override env vars only after access controls are in place:
|
||||
- `WEBUI_ALLOW_PUBLIC_BIND=1` — acknowledges all-interface bind (`0.0.0.0`, `::`)
|
||||
- `WEBUI_ALLOW_REMOTE_BIND=1` — acknowledges a non-loopback host
|
||||
|
||||
Startup **refuses** all-interface binds unless `WEBUI_ALLOW_PUBLIC_BIND=1`.
|
||||
Non-loopback binds log a warning unless `WEBUI_ALLOW_REMOTE_BIND=1`.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address |
|
||||
| `WEBUI_PORT` | `8765` | Listen port |
|
||||
| `WEBUI_REPO_ROOT` | repository root | Workflow/schema hash root for prompt library |
|
||||
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry file path |
|
||||
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config path (optional) |
|
||||
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (optional) |
|
||||
| `WEBUI_ALLOW_PUBLIC_BIND` | unset | Acknowledge `0.0.0.0` / `::` bind |
|
||||
| `WEBUI_ALLOW_REMOTE_BIND` | unset | Acknowledge non-loopback bind |
|
||||
|
||||
Gitea credentials (`GITEA_TOKEN_*`, `.env.*`, keychain refs) are read only on
|
||||
the server when a page needs live Gitea data (e.g. `/queue`). They are not
|
||||
shipped to the browser.
|
||||
|
||||
## Health / deployment metadata
|
||||
|
||||
`GET /health` includes a `deployment` object with bind disposition, runtime
|
||||
assumption paths, and the client-secret policy. Use it to verify an instance is
|
||||
configured for internal-only operation.
|
||||
|
||||
## Non-goals (MVP)
|
||||
|
||||
- Full SSO or session login in the UI
|
||||
- Hosting on the public internet without Access/VPN/WARP
|
||||
- Embedding Gitea tokens in the frontend bundle
|
||||
+105
-5
@@ -29,6 +29,13 @@ Optional environment variables:
|
||||
|----------|---------|---------|
|
||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
||||
| `WEBUI_PORT` | `8765` | Listen port |
|
||||
| `WEBUI_REPO_ROOT` | repository root | Prompt library workflow hash root |
|
||||
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry path |
|
||||
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config (never sent to browser) |
|
||||
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (server-side only) |
|
||||
|
||||
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
|
||||
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
||||
|
||||
## Routes (MVP)
|
||||
|
||||
@@ -45,13 +52,27 @@ Optional environment variables:
|
||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
||||
| `/runtime` | MCP runtime health and stale detection (#430) |
|
||||
| `/api/runtime` | JSON runtime health export |
|
||||
| `/audit` | Stub — report audit paste (#431) |
|
||||
| `/worktrees` | Stub — hygiene dashboard (#432) |
|
||||
| `/audit` | Report audit paste + validator preview (#431) |
|
||||
| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) |
|
||||
| `/worktrees` | Worktree hygiene dashboard (#432) |
|
||||
| `/api/worktrees` | JSON worktree scan with classifications and anomalies |
|
||||
| `/actions` | Gated write-action registry — all disabled in MVP (#434) |
|
||||
| `/api/actions` | JSON action registry with capability metadata |
|
||||
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
|
||||
| `/leases` | Lease and collision visibility (#433) |
|
||||
| `/api/leases` | JSON lease/collision export |
|
||||
|
||||
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
||||
`read-only-mvp`.
|
||||
Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
||||
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
|
||||
local validator preview only (no Gitea mutations, no server-side storage).
|
||||
|
||||
## Report audit (#431)
|
||||
|
||||
Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
|
||||
reuses `final_report_validator` and review schema checks to surface missing
|
||||
proof fields, wrong validation vocabulary, mutation contradictions, and a
|
||||
suggested next prompt or issue-comment draft. Task kind can be auto-detected
|
||||
or selected explicitly.
|
||||
|
||||
## Project registry (#427)
|
||||
|
||||
@@ -84,6 +105,84 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched,
|
||||
If credentials are missing or the fetch fails, the page shows an explicit error
|
||||
instead of an empty queue (fail closed).
|
||||
|
||||
## Gated actions (#434)
|
||||
|
||||
`/actions` registers future write actions (claim, comment, review, merge,
|
||||
delete branch, create PR/issue). Each entry declares the MCP tool, required
|
||||
permission, and profile role from `task_capability_map.py` — aligned with
|
||||
`gitea_resolve_task_capability`. Buttons are disabled; previews always render
|
||||
a mutation ledger. Direct `attempt_action` calls fail closed without invoking
|
||||
MCP tools.
|
||||
|
||||
## Worktree hygiene (#432)
|
||||
|
||||
`/worktrees` scans local `branches/` directories and registered git worktrees.
|
||||
Each entry is classified (`active-pr`, `active-issue`, `dirty`, `stale-clean`,
|
||||
`detached-review`, `unsafe-unknown`, `orphan`). Missing preserved worktrees
|
||||
referenced by the issue lock file are flagged as anomalies (#404). The page
|
||||
includes a copy/paste canonical cleanup prompt only — no deletion actions.
|
||||
|
||||
Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root).
|
||||
|
||||
## Lease visibility (#433)
|
||||
|
||||
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
||||
in-progress claim inventory (#268), reviewer PR lease comments when present
|
||||
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
|
||||
and duplicate local branches per issue. Links to collision-history backend
|
||||
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
|
||||
|
||||
## Runtime health (#430)
|
||||
|
||||
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
|
||||
project: active profile and role kind, authenticated identity (when credentials
|
||||
are available), config model/mode, local vs remote `master` SHA sync, shell
|
||||
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
||||
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||
no tokens or MCP restart actions are exposed.
|
||||
|
||||
## Deployment boundary (#435)
|
||||
|
||||
MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused**
|
||||
unless `WEBUI_ALLOW_PUBLIC_BIND=1`. Non-loopback hosts log a warning unless
|
||||
`WEBUI_ALLOW_REMOTE_BIND=1`. `GET /health` exposes `deployment` metadata.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_deployment_boundary.py -q
|
||||
|
||||
## Tests (#436)
|
||||
|
||||
Run the full hermetic web UI suite (all `test_webui_*.py` modules):
|
||||
|
||||
```bash
|
||||
./scripts/test-webui
|
||||
```
|
||||
|
||||
CI / Jenkins multibranch can call the path-filtered gate (runs only when the
|
||||
diff touches `webui/`, `tests/test_webui_*`, or web UI docs/scripts):
|
||||
|
||||
```bash
|
||||
./scripts/ci-webui-check
|
||||
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check # always run
|
||||
```
|
||||
|
||||
`scripts/test-webui` sets `WEBUI_TEST_OFFLINE=1` by default. In that mode the
|
||||
queue, lease, and runtime routes use empty offline snapshots instead of Gitea
|
||||
credentials, so CI can run without MCP daemon credential access. Set
|
||||
`WEBUI_TEST_OFFLINE=0` only when deliberately validating live fetch behavior.
|
||||
|
||||
Or invoke unittest directly:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -p 'test_webui_*.py' -q
|
||||
```
|
||||
|
||||
## Lease visibility (#433)
|
||||
|
||||
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
||||
@@ -104,5 +203,6 @@ no tokens or MCP restart actions are exposed.
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||
```
|
||||
```
|
||||
|
||||
@@ -16,12 +16,21 @@ bind an authenticated Gitea identity to an allowed operation set.
|
||||
- **Allowed:** branch create/push, PR create, issue comment/create/close, repo commit, read.
|
||||
- **Forbidden:** PR approve, merge, request_changes.
|
||||
|
||||
### Reviewer / merger
|
||||
### Reviewer
|
||||
|
||||
- **Profile:** `prgs-reviewer`
|
||||
- **Typical identity:** `sysadmin`
|
||||
- **Allowed:** PR review/approve/merge/request_changes, issue comment, read.
|
||||
- **Forbidden:** branch push, PR create, repo commit.
|
||||
- **Allowed:** PR review/approve/request_changes, issue comment, read.
|
||||
- **Forbidden:** branch push, PR create, repo commit, PR merge.
|
||||
|
||||
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
|
||||
### Merger
|
||||
|
||||
- **Profile:** `prgs-merger`
|
||||
- **Typical identity:** `sysadmin`
|
||||
- **Allowed:** PR merge, issue comment, read.
|
||||
- **Forbidden:** branch push, PR create, repo commit, PR approve, PR review, PR request_changes.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -29,4 +30,5 @@ Always pass `remote` explicitly on tool calls. The server default is `dadeschool
|
||||
1. `gitea_whoami` — confirm authenticated user and profile.
|
||||
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
||||
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`.
|
||||
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
|
||||
|
||||
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>"`.
|
||||
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`.
|
||||
8. **Redaction** — Tokens, passwords, and keychain material never appear in tool output.
|
||||
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).
|
||||
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. **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).
|
||||
@@ -0,0 +1,68 @@
|
||||
# Workflow skill mount across runtimes (#551)
|
||||
|
||||
## Problem
|
||||
|
||||
Controller prompts require **`gitea-workflow`**, but:
|
||||
|
||||
- Claude may load `~/.claude/skills/gitea-workflow`
|
||||
- Codex often has **no** `~/.codex/skills/gitea-workflow`
|
||||
- The portable package in-repo is `skills/llm-project-workflow`
|
||||
- `mcp_list_project_skills` historically listed operational guides only, not
|
||||
the workflow router
|
||||
|
||||
Sessions then either **block** incorrectly or **proceed without** the workflow
|
||||
wall.
|
||||
|
||||
## Canonical names (must resolve to the same skill)
|
||||
|
||||
| Name | Use |
|
||||
|------|-----|
|
||||
| `gitea-workflow` | **Primary** controller / Codex skill name |
|
||||
| `llm-project-workflow` | Portable in-repo package name |
|
||||
| `git-pr-workflows` | Legacy alias |
|
||||
|
||||
Source of truth: `skills/llm-project-workflow/SKILL.md`
|
||||
In-repo alias stub: `skills/gitea-workflow/SKILL.md`
|
||||
|
||||
## Codex install
|
||||
|
||||
From a `branches/` worktree (or any clone of the repo):
|
||||
|
||||
```bash
|
||||
./scripts/install-codex-workflow-skill.sh
|
||||
# optional:
|
||||
./scripts/install-codex-workflow-skill.sh --dry-run
|
||||
./scripts/install-codex-workflow-skill.sh --skills-dir "$HOME/.codex/skills"
|
||||
```
|
||||
|
||||
This symlinks the portable package under all three names. **Restart Codex**
|
||||
after install.
|
||||
|
||||
## MCP discovery
|
||||
|
||||
- `mcp_list_project_skills` includes `gitea-workflow`, `llm-project-workflow`,
|
||||
and `git-pr-workflows`.
|
||||
- `mcp_get_skill_guide("<name>")` returns the same router steps for each.
|
||||
- `mcp_check_workflow_skill_preflight` proves the in-repo skill file exists and
|
||||
reports Codex mount status.
|
||||
|
||||
## Preflight rule
|
||||
|
||||
Before any git or Gitea mutation:
|
||||
|
||||
1. Call `mcp_check_workflow_skill_preflight`.
|
||||
2. If `blocked` / `workflow_skill_ready` is false → **BLOCKED + DIAGNOSE**; do
|
||||
not mutate.
|
||||
3. Load the skill by **any** canonical name and follow the router.
|
||||
|
||||
Missing Codex mount alone does not block if the in-repo skill is present and
|
||||
loaded via MCP/docs; operators should still install the Codex symlink so
|
||||
prompt names resolve natively.
|
||||
|
||||
## Controller prompts
|
||||
|
||||
Prefer:
|
||||
|
||||
> Invoke skill `gitea-workflow` (alias of `llm-project-workflow`).
|
||||
|
||||
Do not require a name that is only available on one runtime.
|
||||
+318
-8
@@ -11,8 +11,12 @@ import inspect
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
import branch_cleanup_guard
|
||||
import issue_acceptance_gate
|
||||
import issue_lock_provenance
|
||||
import merger_lease_adoption
|
||||
import reviewer_handoff_consistency
|
||||
import thread_state_ledger_validator
|
||||
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||
from review_proofs import (
|
||||
@@ -29,31 +33,40 @@ from validation_status_vocabulary import assess_validation_status_vocabulary
|
||||
|
||||
FINAL_REPORT_TASK_KINDS = frozenset({
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"reconcile_already_landed",
|
||||
"author_issue",
|
||||
"work_issue",
|
||||
"issue_filing",
|
||||
"issue_selection",
|
||||
"inventory",
|
||||
"controller_close",
|
||||
})
|
||||
|
||||
_TASK_KIND_ALIASES = {
|
||||
"review": "review_pr",
|
||||
"review-merge-pr": "review_pr",
|
||||
"merge": "merge_pr",
|
||||
"merge_pr": "merge_pr",
|
||||
"reconcile-landed-pr": "reconcile_already_landed",
|
||||
"reconcile_already_landed": "reconcile_already_landed",
|
||||
"work-issue": "work_issue",
|
||||
"create_issue": "issue_filing",
|
||||
"close_issue": "controller_close",
|
||||
"close-issue": "controller_close",
|
||||
"controller-close": "controller_close",
|
||||
}
|
||||
|
||||
_HANDOFF_ROLE_BY_TASK = {
|
||||
"review_pr": "review",
|
||||
"merge_pr": "merger",
|
||||
"reconcile_already_landed": None,
|
||||
"author_issue": "author",
|
||||
"work_issue": "author",
|
||||
"issue_filing": "issue_filing",
|
||||
"issue_selection": None,
|
||||
"inventory": "inventory",
|
||||
"controller_close": None,
|
||||
}
|
||||
|
||||
_LEGACY_WORKSPACE_MUTATIONS_RE = re.compile(
|
||||
@@ -121,16 +134,22 @@ _TARGET_BRANCH_SHA_RE = re.compile(
|
||||
r"target branch sha\s*:\s*[0-9a-f]{40}",
|
||||
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(
|
||||
r"workflow[- ]load helper result\s*:",
|
||||
r"workflow[-_ ]load[-_ ]helper[-_ ]result\s*[:=]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_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,
|
||||
)
|
||||
_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,
|
||||
)
|
||||
_WORKFLOW_FILE_VIEW_NARRATIVE_RE = re.compile(
|
||||
@@ -195,6 +214,23 @@ _PAGINATION_PROOF_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GIT_FETCH_RE = re.compile(r"\bgit\s+fetch\b", re.IGNORECASE)
|
||||
_CANONICAL_VALIDATION_REJECTED_RE = re.compile(
|
||||
r"canonical comment validation failed|"
|
||||
r"canonical_comment_validation|"
|
||||
r'"allowed"\s*:\s*false',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_COMMENT_POSTED_CLAIM_RE = re.compile(
|
||||
r"(?:issue comments posted\s*:\s+(?!none\b)\S|"
|
||||
r"pr comments posted\s*:\s+(?!none\b)\S|"
|
||||
r"comment_id\s*[:=]\s*\d+|"
|
||||
r"gitea comment (?:was )?posted|"
|
||||
r"posted (?:issue|pr|canonical) (?:state )?comment|"
|
||||
r"comment posted successfully|"
|
||||
r"mcp/gitea mutations\s*:\s*[^;\n]*comment posted|"
|
||||
r"reconciliation mutations\s*:\s*[^;\n]*comment posted)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_READONLY_DIAG_RE = re.compile(
|
||||
r"read[- ]only diagnostics\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
@@ -214,6 +250,59 @@ def _normalize_task_kind(task_kind: str | None) -> str:
|
||||
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(
|
||||
rule_id: str,
|
||||
severity: str,
|
||||
@@ -381,6 +470,43 @@ def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]:
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_canonical_comment_post_claim(
|
||||
report_text: str,
|
||||
*,
|
||||
action_log: list[dict] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""#496: reports must not claim comment posted when validator rejected."""
|
||||
text = report_text or ""
|
||||
rejected_in_report = bool(_CANONICAL_VALIDATION_REJECTED_RE.search(text))
|
||||
rejected_in_log = False
|
||||
if action_log:
|
||||
for entry in _iter_action_entries(action_log):
|
||||
validation = entry.get("canonical_comment_validation") or {}
|
||||
if validation.get("allowed") is False:
|
||||
rejected_in_log = True
|
||||
break
|
||||
result = entry.get("result") or {}
|
||||
nested = result.get("canonical_comment_validation") or {}
|
||||
if nested.get("allowed") is False:
|
||||
rejected_in_log = True
|
||||
break
|
||||
if not (rejected_in_report or rejected_in_log):
|
||||
return []
|
||||
if not _COMMENT_POSTED_CLAIM_RE.search(text):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"shared.canonical_comment_post_claim",
|
||||
"block",
|
||||
"MCP/Gitea mutations",
|
||||
"report claims a Gitea comment was posted while canonical comment "
|
||||
"validation rejected the workflow comment",
|
||||
"do not claim comment posted when validator fail-closed; repair "
|
||||
"the canonical comment body and retry posting",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_legacy_workspace_mutations(
|
||||
report_text: str,
|
||||
*,
|
||||
@@ -408,9 +534,13 @@ def _rule_reviewer_vague_mutations_none(
|
||||
action_log: list[dict] | None = None,
|
||||
mutations_observed: bool = False,
|
||||
) -> 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(
|
||||
e.get("performed") is not False and not e.get("gated_rejected")
|
||||
for e in (action_log or [])
|
||||
e.get("performed") is True and not e.get("gated_rejected")
|
||||
for e in _iter_action_entries(action_log)
|
||||
)
|
||||
if not (mutations_observed or performed):
|
||||
return []
|
||||
@@ -469,7 +599,7 @@ def _rule_reviewer_git_fetch_readonly(
|
||||
text = report_text or ""
|
||||
fetch_observed = any(
|
||||
_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)
|
||||
if not fetch_observed:
|
||||
return []
|
||||
@@ -598,6 +728,27 @@ def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
|
||||
)
|
||||
|
||||
|
||||
def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from conflict_fix_classification import (
|
||||
assess_conflict_fix_classification_final_report,
|
||||
)
|
||||
|
||||
text = report_text or ""
|
||||
result = assess_conflict_fix_classification_final_report(text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"author.conflict_fix_classification_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Conflict-fix classification",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"call gitea_assess_conflict_fix_classification, state the live head "
|
||||
"SHA, and state the classification before creating a conflict-fix worktree"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from pr_work_lease import assess_conflict_fix_final_report
|
||||
|
||||
@@ -767,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(
|
||||
report_text: str,
|
||||
*,
|
||||
@@ -868,7 +1040,7 @@ def _rule_reviewer_target_branch_freshness(
|
||||
fields = _handoff_fields(text)
|
||||
fetch_reported = bool(_GIT_FETCH_RE.search(text)) or any(
|
||||
_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 branch" in key and "sha" in key and _FULL_SHA_RE.search(value)
|
||||
@@ -1111,6 +1283,25 @@ def _rule_reviewer_validation_structured(
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_handoff_consistency(report_text: str) -> list[dict[str, str]]:
|
||||
result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(
|
||||
report_text
|
||||
)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.handoff_consistency",
|
||||
result.get("reasons") or [],
|
||||
field="Review mutations",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"rewrite reviewer handoff so narrative claims match the mutation "
|
||||
"ledger; use the blocked-review handoff template when submission "
|
||||
"was rejected"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_mutation_ledger(
|
||||
report_text: str,
|
||||
*,
|
||||
@@ -1162,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]]:
|
||||
result = issue_acceptance_gate.validate_final_report_issue_acceptance(report_text)
|
||||
if not result.get("applicable") or result.get("valid"):
|
||||
@@ -1194,6 +1403,17 @@ def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, st
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_raw_branch_delete_bypass(report_text: str) -> list[dict[str, str]]:
|
||||
result = branch_cleanup_guard.assess_raw_branch_delete_report(report_text)
|
||||
if not result["block"]:
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.raw_branch_delete_bypass",
|
||||
result["reasons"],
|
||||
field="Cleanup mutations",
|
||||
severity="block",
|
||||
safe_next_action=result["safe_next_action"],
|
||||
)
|
||||
def _rule_reviewer_workflow_load_boundary(report_text: str) -> list[dict[str, str]]:
|
||||
"""#403: require structured workflow-load helper result, not file-view narrative."""
|
||||
if not report_text.strip():
|
||||
@@ -1277,6 +1497,29 @@ def _rule_reviewer_review_mutation(
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_two_comment_workflow(report_text: str) -> list[dict[str, str]]:
|
||||
"""#507: tagged Controller Handoff must pair with Thread State Ledger."""
|
||||
return thread_state_ledger_validator.findings_for_final_report(report_text)
|
||||
|
||||
|
||||
def _rule_shared_canonical_state_update(report_text: str) -> list[dict[str, str]]:
|
||||
from canonical_state_comments import validate_final_report_state_update
|
||||
|
||||
result = validate_final_report_state_update(report_text)
|
||||
if not result.get("applicable") or result.get("valid"):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"shared.canonical_state_update",
|
||||
"block",
|
||||
"Canonical state update",
|
||||
reason,
|
||||
"include a canonical state block with STATE, WHO_IS_NEXT, NEXT_ACTION, and NEXT_PROMPT",
|
||||
)
|
||||
for reason in (result.get("reasons") or ["invalid canonical state update"])
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
|
||||
|
||||
@@ -1324,18 +1567,30 @@ def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, st
|
||||
_SHARED_ISSUE_LOCK_RULES = (
|
||||
_rule_shared_issue_lock_external_state,
|
||||
_rule_shared_manual_lock_pr_override,
|
||||
_rule_shared_manual_lease_proof_handoff,
|
||||
_rule_shared_author_reviewer_same_run,
|
||||
_rule_shared_raw_branch_delete_bypass,
|
||||
_rule_shared_canonical_state_update,
|
||||
)
|
||||
|
||||
_SHARED_TWO_COMMENT_RULES = (
|
||||
_rule_shared_two_comment_workflow,
|
||||
)
|
||||
_SHARED_CLEANUP_PROOF_RULES = (
|
||||
_rule_shared_mcp_native_cleanup_proof,
|
||||
)
|
||||
|
||||
_SHARED_CANONICAL_COMMENT_RULES = (
|
||||
_rule_shared_canonical_comment_post_claim,
|
||||
)
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_pr": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
@@ -1348,12 +1603,14 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_linked_issue,
|
||||
_rule_reviewer_baseline_on_failure,
|
||||
_rule_reviewer_premerge_baseline_proof,
|
||||
_rule_reviewer_post_merge_validation,
|
||||
_rule_reviewer_validation_status_vocabulary,
|
||||
_rule_reviewer_main_checkout_baseline,
|
||||
_rule_reviewer_main_checkout_path,
|
||||
_rule_reviewer_already_landed_eligible,
|
||||
_rule_reviewer_already_landed_state,
|
||||
_rule_reviewer_target_branch_freshness,
|
||||
_rule_reviewer_handoff_consistency,
|
||||
_rule_reviewer_workflow_load_boundary,
|
||||
_rule_reviewer_mutation_ledger,
|
||||
_rule_reviewer_review_mutation,
|
||||
@@ -1362,10 +1619,23 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
*_SHARED_CLEANUP_PROOF_RULES,
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"merge_pr": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
_rule_reviewer_mutation_categories,
|
||||
_rule_reviewer_git_fetch_readonly,
|
||||
_rule_reviewer_linked_issue,
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"reconcile_already_landed": [
|
||||
_rule_reconcile_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
*_SHARED_CLEANUP_PROOF_RULES,
|
||||
_rule_reconcile_stale_author_fields,
|
||||
@@ -1383,6 +1653,8 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
],
|
||||
@@ -1390,9 +1662,12 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_shared_issue_acceptance_gate,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
_rule_conflict_fix_classification_proof,
|
||||
_rule_conflict_fix_push_proof,
|
||||
_rule_worktree_cleanup_audit_proof,
|
||||
],
|
||||
@@ -1400,12 +1675,16 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
"inventory": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_pagination_proof,
|
||||
],
|
||||
@@ -1413,8 +1692,17 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_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,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1510,6 +1798,12 @@ def assess_final_report_validator(
|
||||
checks: dict[str, Any] = {}
|
||||
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:
|
||||
checks["issue_filing"] = assess_issue_filing_final_report(
|
||||
report_text,
|
||||
@@ -1538,7 +1832,23 @@ def assess_final_report_validator(
|
||||
}
|
||||
|
||||
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)
|
||||
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"execution_profile": "example-author",
|
||||
"audit_label": "example-author",
|
||||
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
|
||||
"forbidden_operations": ["approve", "request_changes", "merge"]
|
||||
},
|
||||
@@ -52,8 +53,19 @@
|
||||
"execution_profile": "example-reviewer",
|
||||
"audit_label": "example-reviewer",
|
||||
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
||||
"allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes", "merge"],
|
||||
"forbidden_operations": ["branch", "commit", "push", "open_pr"]
|
||||
"allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes"],
|
||||
"forbidden_operations": ["branch", "commit", "push", "open_pr", "merge"]
|
||||
},
|
||||
"example-merger": {
|
||||
"enabled": true,
|
||||
"context": "example-context",
|
||||
"role": "merger",
|
||||
"username": "reviewer-user",
|
||||
"execution_profile": "example-merger",
|
||||
"audit_label": "example-merger",
|
||||
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
||||
"allowed_operations": ["read", "comment", "issue.comment", "merge"],
|
||||
"forbidden_operations": ["branch", "commit", "push", "open_pr", "approve", "review", "request_changes"]
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
@@ -63,7 +75,8 @@
|
||||
"default_owner": "Example-Org",
|
||||
"default_repo": "Example-Repo",
|
||||
"default_author_profile": "example-author",
|
||||
"default_reviewer_profile": "example-reviewer"
|
||||
"default_reviewer_profile": "example-reviewer",
|
||||
"default_merger_profile": "example-merger"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
|
||||
+268
-24
@@ -20,14 +20,15 @@ from dotenv import dotenv_values, load_dotenv
|
||||
|
||||
import gitea_config
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Load standard .env if present
|
||||
load_dotenv()
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, ".env"))
|
||||
|
||||
# Dictionary to store configurations parsed dynamically from .env.* files
|
||||
DYNAMIC_CONFIGS = {}
|
||||
|
||||
# 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*")):
|
||||
# Skip directories and the example template
|
||||
if os.path.basename(env_path) == ".env.example":
|
||||
@@ -104,6 +105,10 @@ def get_credentials(host):
|
||||
|
||||
# 3. Optional fallback to macOS Keychain via git credential fill
|
||||
if not user and not password and os.environ.get("GITEA_USE_KEYCHAIN") == "1":
|
||||
# #558: block raw keychain dumps outside the sanctioned MCP daemon.
|
||||
import mcp_daemon_guard
|
||||
|
||||
mcp_daemon_guard.assert_keychain_access_allowed()
|
||||
cmd_parts = ["git", "creden" + "tial", "fi" + "ll"]
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
@@ -116,6 +121,8 @@ def get_credentials(host):
|
||||
user = line.split("=", 1)[1]
|
||||
elif line.startswith("password="):
|
||||
password = line.split("=", 1)[1]
|
||||
except mcp_daemon_guard.UnsanctionedRuntimeError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -124,6 +131,11 @@ def get_credentials(host):
|
||||
|
||||
def get_auth_header(host):
|
||||
"""Return an ``Authorization`` header value for *host*."""
|
||||
# #558: resolving credentials for API mutation must not happen via ad-hoc
|
||||
# direct imports that bypass the MCP daemon preflight wall.
|
||||
import mcp_daemon_guard
|
||||
|
||||
mcp_daemon_guard.assert_sanctioned_mutation_runtime("get_auth_header")
|
||||
host_key = host.lower().strip()
|
||||
|
||||
# 1. Try Token-based auth from dynamic configs
|
||||
@@ -170,11 +182,51 @@ def get_auth_header(host):
|
||||
|
||||
def resolve_remote(args):
|
||||
"""Given parsed argparse args with --remote/--host/--org/--repo,
|
||||
return (host, org, repo) with overrides applied."""
|
||||
return (host, org, repo) with overrides applied.
|
||||
|
||||
#714 / #530: when the caller omits org and/or repo, prefer the
|
||||
workspace-aligned git remote over REMOTES defaults (e.g. bare
|
||||
``--remote prgs`` must not force Timesheet when the checkout is
|
||||
Gitea-Tools). Explicit --org/--repo always win.
|
||||
"""
|
||||
profile = REMOTES[args.remote]
|
||||
host = args.host or profile["host"]
|
||||
org = args.org or profile["org"]
|
||||
repo = args.repo or profile["repo"]
|
||||
org_explicit = getattr(args, "org", None) is not None
|
||||
repo_explicit = getattr(args, "repo", None) is not None
|
||||
org = args.org if org_explicit else profile["org"]
|
||||
repo = args.repo if repo_explicit else profile["repo"]
|
||||
if not org_explicit or not repo_explicit:
|
||||
try:
|
||||
import remote_repo_guard
|
||||
import subprocess
|
||||
# Prefer the named remote URL when present; fall back to origin.
|
||||
url = None
|
||||
for remote_name in (args.remote, "origin"):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "remote", "get-url", remote_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and (proc.stdout or "").strip():
|
||||
url = proc.stdout.strip()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
# Allow tests to inject a deterministic remote URL without Git.
|
||||
env_url = os.environ.get("GITEA_TEST_WORKSPACE_REMOTE_URL")
|
||||
if env_url:
|
||||
url = env_url
|
||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||
if parsed:
|
||||
if not org_explicit:
|
||||
org = parsed[0]
|
||||
if not repo_explicit:
|
||||
repo = parsed[1]
|
||||
except Exception:
|
||||
pass
|
||||
return host, org, repo
|
||||
|
||||
|
||||
@@ -231,6 +283,192 @@ def _redact(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):
|
||||
"""Return *url* with the given query parameters added or overridden.
|
||||
|
||||
@@ -303,23 +541,24 @@ def api_request(method, url, auth_header, payload=None, *,
|
||||
"""Make an authenticated JSON request to the Gitea API.
|
||||
|
||||
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
|
||||
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
|
||||
using capped jittered exponential backoff. Successful responses are
|
||||
unchanged.
|
||||
|
||||
All failures are converted to a ``RuntimeError`` with a clear, secret
|
||||
-redacted message (no raw stack traces or credential material):
|
||||
Failures raise typed exceptions with **fixed messages only** (#699). HTTP
|
||||
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.
|
||||
502/503/504 upstream errors get an explicit "Gitea upstream unavailable"
|
||||
message.
|
||||
- Timeouts and network/DNS failures (``URLError`` / ``TimeoutError``) surface
|
||||
a generic "network error contacting Gitea" message.
|
||||
- A malformed (non-JSON) success body surfaces a "malformed JSON response"
|
||||
message rather than a raw decode error.
|
||||
- HTTP 401 → :class:`GiteaAuthError` (``auth_invalid_token``)
|
||||
- HTTP 403 → :class:`GiteaAuthzError` (scope or denied)
|
||||
- 502/503/504 → :class:`GiteaHttpError` (``upstream_unavailable``)
|
||||
- Other non-429 HTTP → :class:`GiteaHttpError` (``http_error``)
|
||||
- Timeouts / DNS / ``URLError`` → :class:`GiteaNetworkError`
|
||||
- Malformed success JSON → plain ``RuntimeError`` (programming/protocol;
|
||||
not reclassified as authentication)
|
||||
|
||||
The ``*_func`` parameters and ``timeout`` are injection points for
|
||||
deterministic testing.
|
||||
@@ -357,22 +596,23 @@ def api_request(method, url, auth_header, payload=None, *,
|
||||
error_body = e.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
error_body = ""
|
||||
detail = _redact(error_body).strip()
|
||||
if e.code in (502, 503, 504):
|
||||
msg = f"HTTP {e.code}: Gitea upstream unavailable"
|
||||
raise RuntimeError(f"{msg}: {detail}" if detail else msg) from e
|
||||
raise RuntimeError(f"HTTP {e.code}: {detail}") from e
|
||||
# Classify from status (+ local body hint). Body is not embedded.
|
||||
try:
|
||||
raise_for_http_status(e.code, error_body)
|
||||
except GiteaClientError:
|
||||
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:
|
||||
reason = getattr(e, "reason", e)
|
||||
raise RuntimeError(
|
||||
f"network error contacting Gitea: {_redact(reason)}"
|
||||
) from e
|
||||
# Fixed message only — do not embed URLError reason (may leak paths).
|
||||
raise GiteaNetworkError(reason_code="network_error") from e
|
||||
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except ValueError as e:
|
||||
# Programming/protocol failure — not authentication.
|
||||
raise RuntimeError("malformed JSON response from Gitea") from e
|
||||
|
||||
|
||||
@@ -558,6 +798,10 @@ def get_profile():
|
||||
"profile_name": name,
|
||||
"allowed_operations": ops,
|
||||
"forbidden_operations": forbidden,
|
||||
# #714 repository authorization boundary. Config-only on purpose: an
|
||||
# environment variable must never widen or forge the set of
|
||||
# repositories a session may bind to.
|
||||
"allowed_repositories": _json_list("allowed_repositories"),
|
||||
"audit_label": audit_label,
|
||||
"token_source_name": token_source,
|
||||
"auth_source_type": auth_type,
|
||||
|
||||
@@ -475,6 +475,33 @@ def _require_enabled(kind, name, obj):
|
||||
return enabled
|
||||
|
||||
|
||||
_REPO_SCOPE_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
|
||||
|
||||
|
||||
def _validate_allowed_repositories(name, raw):
|
||||
"""Validate the optional per-profile repository authorization scope (#714).
|
||||
|
||||
``allowed_repositories`` is an authorization boundary of canonical
|
||||
``owner/repository`` slugs. It is not the session binding: the verified
|
||||
workspace repository selects exactly one entry at bind time. Absent means
|
||||
"no repository scope configured" and is allowed, so existing profiles keep
|
||||
working until an operator provisions the field.
|
||||
"""
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, list):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' allowed_repositories must be a list of "
|
||||
"'owner/repository' strings"
|
||||
)
|
||||
for entry in raw:
|
||||
if not isinstance(entry, str) or not _REPO_SCOPE_RE.match(entry.strip()):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' allowed_repositories entry {entry!r} is not "
|
||||
"a canonical 'owner/repository' slug"
|
||||
)
|
||||
|
||||
|
||||
def _reject_inline_secrets(kind, name, obj):
|
||||
for key in _INLINE_SECRET_KEYS:
|
||||
if key in obj:
|
||||
@@ -549,6 +576,7 @@ def _load_v2_contexts(data, path):
|
||||
forbidden = raw.get("forbidden_operations") or []
|
||||
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
||||
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
||||
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
|
||||
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
||||
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
||||
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
||||
|
||||
+9008
-433
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,788 @@
|
||||
"""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 _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, ...}
|
||||
|
||||
|
||||
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,
|
||||
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.
|
||||
|
||||
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,
|
||||
"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
|
||||
if existing:
|
||||
issue_number = int(existing["gitea_issue_number"])
|
||||
action = "updated_existing_link"
|
||||
outcome = OUTCOME_UPDATED
|
||||
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
|
||||
|
||||
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
|
||||
@@ -375,6 +375,64 @@ def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
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(
|
||||
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)
|
||||
)
|
||||
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 (
|
||||
f"Issue #{issue_number} has an expired {operation_type} lease on "
|
||||
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Canonical issue type/status label taxonomy and transition helpers (#513)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LabelSpec:
|
||||
name: str
|
||||
color: str
|
||||
description: str
|
||||
|
||||
|
||||
TYPE_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
LabelSpec("type:bug", "b60205", "Bug or defect"),
|
||||
LabelSpec("type:feature", "a2eeef", "Feature or enhancement"),
|
||||
LabelSpec("type:process", "5319e7", "Process or policy work"),
|
||||
LabelSpec("type:workflow", "c2e0c6", "Workflow automation or guidance"),
|
||||
LabelSpec("type:guardrail", "d93f0b", "Safety gate or guardrail"),
|
||||
LabelSpec("type:docs", "006b75", "Documentation work"),
|
||||
LabelSpec("type:test", "0e8a16", "Tests or test infrastructure"),
|
||||
LabelSpec("type:discussion", "bfdadc", "Discussion-only issue"),
|
||||
LabelSpec("type:umbrella", "c5def5", "Umbrella or tracker issue"),
|
||||
LabelSpec("type:cleanup", "ededed", "Cleanup or hygiene work"),
|
||||
)
|
||||
|
||||
STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
LabelSpec("status:triage", "fbca04", "Issue needs triage"),
|
||||
LabelSpec("status:ready", "0e8a16", "Issue is ready for work"),
|
||||
LabelSpec("status:claimed", "fefe2e", "Issue is claimed"),
|
||||
LabelSpec("status:in-progress", "fefe2e", "Issue is being worked on"),
|
||||
LabelSpec("status:blocked", "b60205", "Issue is blocked"),
|
||||
LabelSpec("status:needs-review", "0052cc", "Issue work needs review"),
|
||||
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:merged", "5319e7", "Linked PR is merged"),
|
||||
LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"),
|
||||
LabelSpec("status:done", "0e8a16", "Issue workflow is complete"),
|
||||
LabelSpec("status:duplicate", "cccccc", "Issue is a duplicate"),
|
||||
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, ...] = (
|
||||
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)
|
||||
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(
|
||||
spec.name for spec in CANONICAL_LABEL_SPECS
|
||||
)
|
||||
|
||||
STATUS_TRANSITIONS: dict[str, str] = {
|
||||
"triage": "status:triage",
|
||||
"ready": "status:ready",
|
||||
"claim": "status:claimed",
|
||||
"claimed": "status:claimed",
|
||||
"start": "status:in-progress",
|
||||
"start_work": "status:in-progress",
|
||||
"in_progress": "status:in-progress",
|
||||
"in-progress": "status:in-progress",
|
||||
"block": "status:blocked",
|
||||
"blocked": "status:blocked",
|
||||
"needs_review": "status:needs-review",
|
||||
"needs-review": "status:needs-review",
|
||||
"reviewing": "status:needs-review",
|
||||
"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",
|
||||
"merge_ready": "status:approved",
|
||||
"merge-ready": "status:approved",
|
||||
"merge": "status:reconcile",
|
||||
"merged": "status:reconcile",
|
||||
"reconcile": "status:reconcile",
|
||||
"done": "status:done",
|
||||
"complete": "status:done",
|
||||
"duplicate": "status:duplicate",
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
def label_name(label: str | Mapping[str, object]) -> str:
|
||||
"""Return a normalized label name from a Gitea label object or string."""
|
||||
if isinstance(label, str):
|
||||
return label.strip()
|
||||
value = label.get("name")
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def label_names(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||
"""Extract label names from a label list or issue-like mapping."""
|
||||
raw: object
|
||||
if isinstance(labels, Mapping):
|
||||
raw = labels.get("labels", [])
|
||||
else:
|
||||
raw = labels
|
||||
return [name for name in (label_name(label) for label in raw or []) if name]
|
||||
|
||||
|
||||
def type_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||
return [name for name in label_names(labels) if name.startswith("type:")]
|
||||
|
||||
|
||||
def status_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||
return [name for name in label_names(labels) if name.startswith("status:")]
|
||||
|
||||
|
||||
def canonical_status_label(status_or_transition: str) -> str:
|
||||
status = status_or_transition.strip()
|
||||
if status in STATUS_LABELS:
|
||||
return status
|
||||
normalized = status.lower().replace(" ", "-")
|
||||
try:
|
||||
return STATUS_TRANSITIONS[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"unknown workflow status or transition '{status_or_transition}'"
|
||||
) from exc
|
||||
|
||||
|
||||
def transition_status_labels(
|
||||
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
status_or_transition: str,
|
||||
) -> list[str]:
|
||||
"""Replace all active status labels with the requested canonical status."""
|
||||
new_status = canonical_status_label(status_or_transition)
|
||||
kept = [name for name in label_names(existing_labels) if not name.startswith("status:")]
|
||||
if new_status not in kept:
|
||||
kept.append(new_status)
|
||||
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(
|
||||
issue_type: str | None = None,
|
||||
initial_status: str | None = None,
|
||||
extra_labels: Sequence[str] | None = None,
|
||||
*,
|
||||
discussion: bool = False,
|
||||
) -> list[str]:
|
||||
names = list(extra_labels or [])
|
||||
if issue_type:
|
||||
type_name = issue_type if issue_type.startswith("type:") else f"type:{issue_type}"
|
||||
names.append(type_name)
|
||||
if discussion:
|
||||
names.append("type:discussion")
|
||||
if initial_status:
|
||||
names.append(canonical_status_label(initial_status))
|
||||
|
||||
result: list[str] = []
|
||||
for name in names:
|
||||
if name not in result:
|
||||
result.append(name)
|
||||
return result
|
||||
|
||||
|
||||
def assess_issue_labels(
|
||||
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
*,
|
||||
discussion: bool = False,
|
||||
require_type: bool = True,
|
||||
require_status: bool = True,
|
||||
) -> dict:
|
||||
names = label_names(labels)
|
||||
found_types = type_labels(names)
|
||||
found_statuses = status_labels(names)
|
||||
found_roles = role_labels(names)
|
||||
found_hazards = hazard_labels(names)
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if require_type and not found_types:
|
||||
errors.append("issue is missing a type:* label")
|
||||
if require_status and not found_statuses:
|
||||
errors.append("issue is missing a status:* label")
|
||||
if discussion and "type:discussion" not in found_types:
|
||||
errors.append("discussion issue is missing type:discussion")
|
||||
if len(found_statuses) > 1:
|
||||
errors.append(
|
||||
"issue has multiple active status:* labels: "
|
||||
+ ", ".join(found_statuses)
|
||||
)
|
||||
if len(found_roles) > 1:
|
||||
errors.append(
|
||||
"issue has multiple active role:* labels: " + ", ".join(found_roles)
|
||||
)
|
||||
|
||||
for name in found_types:
|
||||
if name not in TYPE_LABELS:
|
||||
warnings.append(f"unknown type label '{name}'")
|
||||
for name in found_statuses:
|
||||
if name not in STATUS_LABELS:
|
||||
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 {
|
||||
"valid": not errors,
|
||||
"labels": names,
|
||||
"type_labels": found_types,
|
||||
"status_labels": found_statuses,
|
||||
"role_labels": found_roles,
|
||||
"hazard_labels": found_hazards,
|
||||
"errors": errors,
|
||||
"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,
|
||||
}
|
||||
+17
-6
@@ -22,7 +22,8 @@ venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
|
||||
if os.path.exists(venv_python) and sys.executable != venv_python:
|
||||
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
|
||||
|
||||
HOST = "gitea.dadeschools.net"
|
||||
ORG = "Contractor"
|
||||
@@ -35,8 +36,10 @@ LABELS = [
|
||||
{"name": "epic", "color": "8250df", "description": ""},
|
||||
{"name": "important", "color": "fbca04", "description": ""},
|
||||
{"name": "nice-to-have", "color": "0e8a16", "description": ""},
|
||||
{"name": "status:in-progress", "color": "fefe2e",
|
||||
"description": "Issue is being worked on"},
|
||||
*[
|
||||
{"name": spec.name, "color": spec.color, "description": spec.description}
|
||||
for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS
|
||||
],
|
||||
]
|
||||
|
||||
# issue number -> label names to apply (one-off backfill)
|
||||
@@ -79,9 +82,17 @@ def api(method, path, auth, payload=None):
|
||||
|
||||
|
||||
def _labels_by_name(auth):
|
||||
"""Return {label name: id} for the repo's existing labels."""
|
||||
existing = api("GET", "/labels?limit=100", auth) or []
|
||||
return {lb["name"]: lb["id"] for lb in existing}
|
||||
"""Return {label name: id} for the repo's existing labels (all pages, #627)."""
|
||||
existing = api_get_all(f"{BASE_URL}/labels", auth) or []
|
||||
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):
|
||||
|
||||
+5
-5
@@ -17,7 +17,7 @@ if os.path.exists(venv_python) and sys.executable != venv_python:
|
||||
|
||||
from gitea_auth import (
|
||||
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"
|
||||
@@ -45,12 +45,12 @@ def main(argv=None):
|
||||
base = repo_api_url(host, org, repo)
|
||||
|
||||
try:
|
||||
# Find the label ID
|
||||
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
||||
# Paginated inventory (#627): Gitea caps single pages at 50.
|
||||
labels = api_get_all(f"{base}/labels", auth) or []
|
||||
label_id = None
|
||||
for lb in labels:
|
||||
if lb["name"] == LABEL_NAME:
|
||||
label_id = lb["id"]
|
||||
if lb.get("name") == LABEL_NAME:
|
||||
label_id = lb.get("id")
|
||||
break
|
||||
|
||||
if label_id is None:
|
||||
|
||||
+38
-1
@@ -115,7 +115,15 @@ Workflow:
|
||||
}
|
||||
|
||||
show_reviewer_prompts() {
|
||||
print_prompt_block "Reviewer — PR review" \
|
||||
while true; do
|
||||
printf '\n--- Reviewer workflow prompts ---\n'
|
||||
printf ' 1) Standard PR review\n'
|
||||
printf ' 2) Skip already-reviewed stale REQUEST_CHANGES PR and hand off to author\n'
|
||||
printf ' 0) Back\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1)
|
||||
print_prompt_block "Reviewer — PR review" \
|
||||
"You are the REVIEWER session for <org>/<repo>.
|
||||
|
||||
Goal: review PR #<P> only — do not merge unless explicitly switched to merger mode.
|
||||
@@ -126,6 +134,35 @@ Workflow:
|
||||
3. gitea_view_pr, validate scope, run required checks in the correct worktree.
|
||||
4. gitea_review_pr with approve, request-changes, or comment as warranted.
|
||||
5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs."
|
||||
;;
|
||||
2)
|
||||
print_prompt_block "Reviewer — skip already-reviewed stale REQUEST_CHANGES PR and hand off to author" \
|
||||
"You are the REVIEWER session for <org>/<repo>.
|
||||
|
||||
Goal: review PR #<P> only far enough to determine whether the current head already has a non-stale REQUEST_CHANGES verdict. If it does, do NOT submit another terminal review mutation — produce an author handoff and stop.
|
||||
|
||||
Workflow:
|
||||
1. gitea_view_pr; pin the current head SHA.
|
||||
2. Load prior reviews; find the latest REQUEST_CHANGES and the head SHA it was bound to.
|
||||
3. If that REQUEST_CHANGES is still bound to the current head (non-stale), do not submit another terminal review mutation. Confirm the binding and summarize the blockers.
|
||||
4. Run only the diagnostics needed to produce a useful author handoff — no full re-review.
|
||||
5. Duplicate/supersession check: confirm no newer canonical PR or superseding head changes the decision.
|
||||
6. Stop — no new review mutation.
|
||||
|
||||
Return:
|
||||
- selected PR and issue
|
||||
- current head SHA
|
||||
- prior REQUEST_CHANGES proof (review id + bound head SHA)
|
||||
- duplicate/supersession analysis
|
||||
- validation summary
|
||||
- why no new review mutation was submitted
|
||||
- corrected mutation ledger, including local worktree create/remove if used
|
||||
- author-ready fix prompt"
|
||||
;;
|
||||
0) return ;;
|
||||
*) printf 'Invalid choice.\n' ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_merger_prompts() {
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Sanctioned MCP daemon guards for imports and credential access (#558 / #695).
|
||||
|
||||
Direct ``import gitea_mcp_server`` from a shell bypasses native MCP transport.
|
||||
#558 introduced a daemon marker; #695 hardens it so:
|
||||
|
||||
- Environment variables alone cannot reconstruct a native session
|
||||
(``GITEA_MCP_SANCTIONED_DAEMON=1`` / ``GITEA_ALLOW_DIRECT_MCP_IMPORT=1`` are
|
||||
insufficient for mutation gates).
|
||||
- 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.
|
||||
|
||||
Manual deletion of session-state files is never a recovery path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import inspect
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SANCTIONED_DAEMON_ENV = "GITEA_MCP_SANCTIONED_DAEMON"
|
||||
ALLOW_DIRECT_IMPORT_ENV = "GITEA_ALLOW_DIRECT_MCP_IMPORT"
|
||||
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):
|
||||
"""Raised when mutation/credential code runs outside a native MCP daemon."""
|
||||
|
||||
|
||||
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())
|
||||
|
||||
|
||||
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:
|
||||
"""Backward-compatible name; #695 requires native transport, not env alone."""
|
||||
if is_production_native_mcp_transport():
|
||||
return True
|
||||
if is_pytest_runtime():
|
||||
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
|
||||
|
||||
|
||||
def assert_production_mutation_runtime(context: str = "mutation") -> None:
|
||||
"""Fail closed unless production native MCP transport is bound (#695).
|
||||
|
||||
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:
|
||||
"""Fail closed when mutation code runs outside native MCP transport (#695).
|
||||
|
||||
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
|
||||
# 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(
|
||||
f"Unsanctioned / non-native runtime blocked {context} (#695). "
|
||||
"Do not import gitea_mcp_server or call mutation helpers from a raw "
|
||||
"shell, offline runner, or ad-hoc script after native MCP failure. "
|
||||
"Stop and reconnect the official MCP daemon (mcp_server.py) over "
|
||||
"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}"
|
||||
)
|
||||
|
||||
|
||||
def assert_keychain_access_allowed() -> None:
|
||||
"""Fail closed for git-credential keychain fill outside sanctioned contexts."""
|
||||
if is_sanctioned_mcp_daemon():
|
||||
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 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(
|
||||
"Unsanctioned keychain/credential fill blocked (#558/#695). "
|
||||
"Token extraction via git-credential is only allowed inside the "
|
||||
f"official native MCP daemon or with explicit operator opt-in "
|
||||
f"{ALLOW_KEYCHAIN_CLI_ENV}=1 (never for offline mutation runners)."
|
||||
)
|
||||
|
||||
|
||||
def native_runtime_status() -> dict[str, Any]:
|
||||
"""LLM-safe native runtime status (no raw token)."""
|
||||
rt = _NATIVE_RUNTIME or {}
|
||||
return {
|
||||
"native_mcp_transport": is_native_mcp_transport(),
|
||||
"production_native_mcp_transport": is_production_native_mcp_transport(),
|
||||
"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,
|
||||
"allow_direct_import_env": ALLOW_DIRECT_IMPORT_ENV,
|
||||
"session_state_dir_env": SESSION_STATE_DIR_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({
|
||||
"gitea_cleanup_post_merge_moot_lease",
|
||||
"gitea_cleanup_obsolete_reviewer_comment_lease",
|
||||
"gitea_reconcile_merged_cleanups",
|
||||
"gitea_delete_branch",
|
||||
"gitea_cleanup_merged_pr_branch",
|
||||
|
||||
@@ -6,6 +6,10 @@ Runs over stdio. All tools authenticate via macOS keychain (git credential fill)
|
||||
import os
|
||||
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 (
|
||||
python_bytes_have_conflict_markers,
|
||||
skip_python_scan_walk_root,
|
||||
@@ -37,6 +41,19 @@ def check_conflict_markers():
|
||||
|
||||
check_conflict_markers()
|
||||
|
||||
# #558 / #695: claim the official entrypoint before loading mutation modules.
|
||||
# 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:
|
||||
import mcp_daemon_guard
|
||||
|
||||
mcp_daemon_guard.mark_sanctioned_daemon()
|
||||
except Exception:
|
||||
# Guard import failures must not hide conflict-marker infra_stop above;
|
||||
# gitea_mcp_server main also marks + binds when run as __main__.
|
||||
pass
|
||||
|
||||
# Execute the actual server logic via exec in this namespace.
|
||||
impl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gitea_mcp_server.py")
|
||||
try:
|
||||
|
||||
+466
-8
@@ -30,12 +30,84 @@ DEFAULT_TTL_HOURS = 4.0
|
||||
|
||||
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
||||
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._+-]+")
|
||||
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
||||
|
||||
|
||||
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()
|
||||
return raw or DEFAULT_STATE_DIR
|
||||
|
||||
@@ -78,6 +150,7 @@ def state_key(
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> str:
|
||||
"""Build durable filename key.
|
||||
|
||||
@@ -85,17 +158,20 @@ def state_key(
|
||||
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
|
||||
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
|
||||
# intentionally not part of the filename for session-scoped proofs.
|
||||
_ = (remote, org, repo)
|
||||
return "-".join(
|
||||
_sanitize_segment(part)
|
||||
for part in (
|
||||
kind,
|
||||
profile_identity or "unknown-profile",
|
||||
)
|
||||
)
|
||||
parts = [kind, profile_identity or "unknown-profile"]
|
||||
instance = (instance_id or "").strip()
|
||||
if instance:
|
||||
parts.append(instance)
|
||||
return "-".join(_sanitize_segment(part) for part in parts)
|
||||
|
||||
|
||||
def state_file_path(
|
||||
@@ -106,6 +182,7 @@ def state_file_path(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
name = state_key(
|
||||
@@ -114,6 +191,7 @@ def state_file_path(
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
return os.path.join(root, f"{name}.json")
|
||||
|
||||
@@ -192,6 +270,16 @@ def _write_json(path: str, data: dict[str, Any]) -> None:
|
||||
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(
|
||||
record: dict[str, Any] | None,
|
||||
*,
|
||||
@@ -234,7 +322,9 @@ def identity_match_reasons(
|
||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||
else:
|
||||
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(
|
||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||
)
|
||||
@@ -243,6 +333,113 @@ def identity_match_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(
|
||||
*,
|
||||
kind: str,
|
||||
@@ -251,6 +448,7 @@ def load_state(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load durable state payload when identity checks pass."""
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
@@ -261,6 +459,7 @@ def load_state(
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
state_dir=state_dir,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
@@ -306,6 +505,7 @@ def save_state(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Persist or clear durable state for the given session identity key."""
|
||||
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_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_instance = (
|
||||
(instance_id or "").strip()
|
||||
or str((payload or {}).get("instance_id") or "").strip()
|
||||
or None
|
||||
)
|
||||
|
||||
root = _ensure_state_dir(state_dir)
|
||||
path = state_file_path(
|
||||
@@ -327,6 +532,7 @@ def save_state(
|
||||
repo=key_repo,
|
||||
profile_identity=profile,
|
||||
state_dir=root,
|
||||
instance_id=key_instance,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
@@ -354,6 +560,28 @@ def save_state(
|
||||
body["org"] = key_org
|
||||
if key_repo is not None:
|
||||
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 = {
|
||||
"kind": kind,
|
||||
@@ -365,8 +593,12 @@ def save_state(
|
||||
"recorded_at": body["recorded_at"],
|
||||
"updated_at": body["updated_at"],
|
||||
"writer_pid": body["writer_pid"],
|
||||
"session_state_dir": body.get("session_state_dir"),
|
||||
"transport": body.get("transport"),
|
||||
"payload": body,
|
||||
}
|
||||
if key_instance:
|
||||
envelope["instance_id"] = key_instance
|
||||
_write_json(path, envelope)
|
||||
return dict(body)
|
||||
|
||||
@@ -379,6 +611,7 @@ def clear_state(
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> None:
|
||||
save_state(
|
||||
kind=kind,
|
||||
@@ -388,4 +621,229 @@ def clear_state(
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
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
|
||||
``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.
|
||||
"""
|
||||
|
||||
@@ -12,6 +13,7 @@ def assess_merge_approval_head(
|
||||
*,
|
||||
current_head_sha: str | None,
|
||||
latest_by_reviewer: dict,
|
||||
quarantined_review_ids: set | None = None,
|
||||
) -> dict:
|
||||
"""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.
|
||||
latest_by_reviewer: Map of reviewer login → review entry dicts with
|
||||
``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:
|
||||
dict with ``approval_at_current_head``, ``latest_approved_head_sha``,
|
||||
and ``stale_approval_block_reason`` (set when merge must fail closed).
|
||||
"""
|
||||
current = (current_head_sha or "").strip()
|
||||
approved_entries = [
|
||||
entry
|
||||
for entry in (latest_by_reviewer or {}).values()
|
||||
if (entry.get("verdict") or "").upper() == "APPROVED"
|
||||
and not entry.get("dismissed")
|
||||
]
|
||||
blocked_ids = {int(x) for x in (quarantined_review_ids or set()) if x is not None}
|
||||
|
||||
def _rid(entry: dict):
|
||||
raw = entry.get("review_id", entry.get("id"))
|
||||
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(
|
||||
(entry.get("reviewed_head_sha") or "").strip() == current
|
||||
for entry in approved_entries
|
||||
@@ -47,7 +74,13 @@ def assess_merge_approval_head(
|
||||
)[-1]
|
||||
latest_approved = (latest_entry.get("reviewed_head_sha") or "").strip() or 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 = (
|
||||
f"stale approval: approved SHA '{latest_approved}' does not match "
|
||||
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,
|
||||
"latest_approved_head_sha": latest_approved,
|
||||
"stale_approval_block_reason": reason,
|
||||
"quarantined_approvals_at_current_head": len(quarantined_at_head),
|
||||
}
|
||||
+191
-29
@@ -17,6 +17,7 @@ import issue_lock_store
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
|
||||
ISSUE_MARKER_RE = re.compile(r"(?:^|[-_/])issue-(\d+)(?:[-_/]|$)", re.IGNORECASE)
|
||||
# Reviewer scratch folders: review-pr<N> or review-pr<N>-submit (#534).
|
||||
REVIEWER_SCRATCH_NAME_RE = re.compile(
|
||||
r"^review-pr(?P<pr>\d+)(?:-submit)?$",
|
||||
@@ -41,6 +42,59 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
|
||||
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
|
||||
|
||||
|
||||
def extract_issue_marker(*values: str | None) -> int | None:
|
||||
"""Return the first issue marker found in branch/path-like values."""
|
||||
for value in values:
|
||||
match = ISSUE_MARKER_RE.search(value or "")
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def list_local_worktrees(project_root: str) -> list[dict[str, Any]]:
|
||||
"""Parse ``git worktree list --porcelain`` into structured entries."""
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
current: dict[str, Any] | None = None
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
entries.append(current)
|
||||
current = None
|
||||
|
||||
for raw_line in (res.stdout or "").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
flush()
|
||||
continue
|
||||
if line.startswith("worktree "):
|
||||
flush()
|
||||
current = {"path": line[9:].strip()}
|
||||
elif current is not None and line.startswith("HEAD "):
|
||||
current["head_sha"] = line[5:].strip()
|
||||
elif current is not None and line.startswith("branch "):
|
||||
ref = line[7:].strip()
|
||||
current["branch_ref"] = ref
|
||||
current["branch"] = (
|
||||
ref[len("refs/heads/"):]
|
||||
if ref.startswith("refs/heads/")
|
||||
else ref
|
||||
)
|
||||
elif current is not None and line == "detached":
|
||||
current["detached"] = True
|
||||
flush()
|
||||
return entries
|
||||
|
||||
|
||||
def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
|
||||
"""Parse ``git worktree list --porcelain`` into structured records."""
|
||||
res = subprocess.run(
|
||||
@@ -82,14 +136,115 @@ def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
|
||||
def discover_local_worktrees(project_root: str) -> dict[str, str]:
|
||||
"""Return a mapping from branch name to absolute worktree path (#528)."""
|
||||
mapping: dict[str, str] = {}
|
||||
for record in _git_worktree_porcelain(project_root):
|
||||
branch_name = record.get("branch")
|
||||
path = record.get("worktree")
|
||||
if branch_name and path:
|
||||
mapping[str(branch_name)] = str(path)
|
||||
for entry in list_local_worktrees(project_root):
|
||||
branch = entry.get("branch")
|
||||
path = entry.get("path")
|
||||
if branch and path:
|
||||
mapping[str(branch)] = str(path)
|
||||
return mapping
|
||||
|
||||
|
||||
def _is_under_branches(project_root: str, path: str) -> bool:
|
||||
branches_root = os.path.realpath(os.path.join(project_root, "branches"))
|
||||
real_path = os.path.realpath(path)
|
||||
return real_path == branches_root or real_path.startswith(branches_root + os.sep)
|
||||
|
||||
|
||||
def _head_is_safe_for_cleanup(
|
||||
*,
|
||||
project_root: str,
|
||||
candidate_head: str | None,
|
||||
pr_head_sha: str | None,
|
||||
target_ref: str | None,
|
||||
) -> bool:
|
||||
if not candidate_head:
|
||||
return False
|
||||
if pr_head_sha and candidate_head == pr_head_sha:
|
||||
return True
|
||||
if target_ref:
|
||||
return is_head_ancestor_of_ref(project_root, candidate_head, target_ref) is True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_cleanup_worktree_state(
|
||||
*,
|
||||
project_root: str,
|
||||
head_branch: str,
|
||||
issue_number: int | None,
|
||||
pr_head_sha: str | None = None,
|
||||
target_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Find the exact or safe alias worktree used for local cleanup (#532)."""
|
||||
expected_path = resolve_worktree_path(project_root, head_branch)
|
||||
state = read_local_worktree_state(expected_path)
|
||||
state["worktree_path"] = expected_path
|
||||
state["expected_worktree_path"] = expected_path
|
||||
state["discovered_worktree_path"] = expected_path if state.get("exists") else None
|
||||
state["match_type"] = "derived_path" if state.get("exists") else "none"
|
||||
state["candidate_paths"] = []
|
||||
state["alias_block_reasons"] = []
|
||||
if state.get("exists"):
|
||||
return state
|
||||
|
||||
branch_issue = extract_issue_marker(head_branch)
|
||||
wanted_issue = issue_number or branch_issue
|
||||
candidates: list[dict[str, Any]] = []
|
||||
unsafe_matches: list[str] = []
|
||||
|
||||
for entry in list_local_worktrees(project_root):
|
||||
path = entry.get("path") or ""
|
||||
if not path or not _is_under_branches(project_root, path):
|
||||
continue
|
||||
branch = entry.get("branch") or ""
|
||||
path_issue = extract_issue_marker(os.path.basename(path), path)
|
||||
entry_issue = extract_issue_marker(branch) or path_issue
|
||||
branch_match = branch == head_branch
|
||||
issue_match = wanted_issue is not None and entry_issue == wanted_issue
|
||||
if not (branch_match or issue_match):
|
||||
continue
|
||||
safe_head = _head_is_safe_for_cleanup(
|
||||
project_root=project_root,
|
||||
candidate_head=entry.get("head_sha"),
|
||||
pr_head_sha=pr_head_sha,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
if not safe_head and branch_match and not pr_head_sha and not target_ref:
|
||||
safe_head = True
|
||||
if not safe_head:
|
||||
unsafe_matches.append(path)
|
||||
continue
|
||||
candidates.append(entry)
|
||||
|
||||
state["candidate_paths"] = [entry.get("path") for entry in candidates]
|
||||
if len(candidates) == 1:
|
||||
path = candidates[0]["path"]
|
||||
alias_state = read_local_worktree_state(path)
|
||||
alias_state["worktree_path"] = path
|
||||
alias_state["expected_worktree_path"] = expected_path
|
||||
alias_state["discovered_worktree_path"] = path
|
||||
alias_state["match_type"] = (
|
||||
"branch" if candidates[0].get("branch") == head_branch else "alias"
|
||||
)
|
||||
alias_state["candidate_paths"] = [path]
|
||||
alias_state["alias_block_reasons"] = []
|
||||
alias_state["alias_verified"] = True
|
||||
return alias_state
|
||||
if len(candidates) > 1:
|
||||
state["alias_block_reasons"].append(
|
||||
"ambiguous local worktree aliases: " + ", ".join(
|
||||
sorted(str(entry.get("path")) for entry in candidates)
|
||||
)
|
||||
)
|
||||
state["match_type"] = "ambiguous_alias"
|
||||
elif unsafe_matches:
|
||||
state["alias_block_reasons"].append(
|
||||
"matching local worktree aliases did not point to the PR head or target branch: "
|
||||
+ ", ".join(sorted(unsafe_matches))
|
||||
)
|
||||
state["match_type"] = "unsafe_alias"
|
||||
return state
|
||||
|
||||
|
||||
def parse_reviewer_scratch_folder(folder_name: str) -> int | None:
|
||||
"""Return PR number for a reviewer scratch folder name, else None (#534)."""
|
||||
match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip())
|
||||
@@ -323,6 +478,7 @@ def assess_local_worktree_cleanup(
|
||||
exists = bool(worktree_state.get("exists"))
|
||||
if not merged:
|
||||
reasons.append("PR is not merged")
|
||||
reasons.extend(worktree_state.get("alias_block_reasons") or [])
|
||||
if not exists:
|
||||
reasons.append("local worktree not present")
|
||||
if exists and worktree_state.get("clean") is False:
|
||||
@@ -333,7 +489,11 @@ def assess_local_worktree_cleanup(
|
||||
)
|
||||
if exists:
|
||||
current_branch = worktree_state.get("current_branch")
|
||||
if current_branch and current_branch != head_branch:
|
||||
if (
|
||||
current_branch
|
||||
and current_branch != head_branch
|
||||
and not worktree_state.get("alias_verified")
|
||||
):
|
||||
reasons.append(
|
||||
f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
|
||||
)
|
||||
@@ -345,6 +505,10 @@ def assess_local_worktree_cleanup(
|
||||
"pr_number": pr_number,
|
||||
"head_branch": head_branch,
|
||||
"worktree_path": worktree_state.get("worktree_path"),
|
||||
"expected_worktree_path": worktree_state.get("expected_worktree_path"),
|
||||
"discovered_worktree_path": worktree_state.get("discovered_worktree_path"),
|
||||
"match_type": worktree_state.get("match_type"),
|
||||
"candidate_paths": worktree_state.get("candidate_paths") or [],
|
||||
"worktree_exists": exists,
|
||||
"worktree_clean": worktree_state.get("clean"),
|
||||
"safe_to_remove_worktree": safe,
|
||||
@@ -363,7 +527,7 @@ def build_pr_cleanup_entry(
|
||||
delete_capability_allowed: bool,
|
||||
issue_lock_path: str | None = None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
worktree_map: dict[str, str] | None = None,
|
||||
target_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
pr_number = int(pr["number"])
|
||||
head_branch = pr.get("head") or ""
|
||||
@@ -372,18 +536,16 @@ def build_pr_cleanup_entry(
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
merged = bool(pr.get("merged_at"))
|
||||
|
||||
discovered_path = worktree_map.get(head_branch) if worktree_map else None
|
||||
derived_path = resolve_worktree_path(project_root, head_branch)
|
||||
if discovered_path:
|
||||
worktree_path = discovered_path
|
||||
match_type = "branch"
|
||||
else:
|
||||
worktree_path = derived_path
|
||||
match_type = "path"
|
||||
|
||||
worktree_state = read_local_worktree_state(worktree_path)
|
||||
worktree_state["worktree_path"] = worktree_path
|
||||
issue_number = extract_linked_issue(title, body) or extract_issue_marker(head_branch)
|
||||
head_payload = pr.get("head") if isinstance(pr.get("head"), dict) else {}
|
||||
pr_head_sha = head_payload.get("sha") if isinstance(head_payload, dict) else None
|
||||
worktree_state = resolve_cleanup_worktree_state(
|
||||
project_root=project_root,
|
||||
head_branch=head_branch,
|
||||
issue_number=issue_number,
|
||||
pr_head_sha=pr_head_sha,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
|
||||
|
||||
remote = assess_remote_branch_cleanup(
|
||||
@@ -404,11 +566,9 @@ def build_pr_cleanup_entry(
|
||||
worktree_state=worktree_state,
|
||||
active_lock=active_lock,
|
||||
)
|
||||
local["match_type"] = match_type
|
||||
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"issue_number": extract_linked_issue(title, body),
|
||||
"issue_number": issue_number,
|
||||
"title": title,
|
||||
"head_branch": head_branch,
|
||||
"merge_commit_sha": pr.get("merge_commit_sha"),
|
||||
@@ -429,11 +589,11 @@ def build_reconciliation_report(
|
||||
delete_capability_allowed: bool,
|
||||
issue_lock_path: str | None = None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
target_ref: str | None = None,
|
||||
active_reviewer_leases: dict[int, bool] | None = None,
|
||||
pr_states: dict[int, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
open_heads = collect_open_pr_heads(open_prs)
|
||||
worktree_map = discover_local_worktrees(project_root)
|
||||
entries: list[dict[str, Any]] = []
|
||||
for pr in closed_prs or []:
|
||||
if not pr.get("merged_at"):
|
||||
@@ -452,7 +612,7 @@ def build_reconciliation_report(
|
||||
delete_capability_allowed=delete_capability_allowed,
|
||||
issue_lock_path=issue_lock_path,
|
||||
protected_branches=protected_branches,
|
||||
worktree_map=worktree_map,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -505,10 +665,12 @@ def build_reconciliation_report(
|
||||
}
|
||||
|
||||
|
||||
def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
|
||||
worktree_map = discover_local_worktrees(project_root)
|
||||
discovered_path = worktree_map.get(branch)
|
||||
worktree_path = discovered_path or resolve_worktree_path(project_root, branch)
|
||||
def remove_local_worktree(
|
||||
project_root: str,
|
||||
branch: str,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
worktree_path = worktree_path or resolve_worktree_path(project_root, branch)
|
||||
if not os.path.isdir(worktree_path):
|
||||
return {
|
||||
"success": False,
|
||||
@@ -593,4 +755,4 @@ def remove_reviewer_scratch_worktree(
|
||||
"message": f"removed reviewer scratch worktree {real}",
|
||||
"worktree_path": real,
|
||||
"author_worktree_cleanup": False,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ after formal approval at the current PR head.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -17,11 +18,13 @@ DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
|
||||
|
||||
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||
SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease"
|
||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||
|
||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||
SOURCE_ADOPT,
|
||||
SOURCE_ACQUIRE,
|
||||
SOURCE_ACQUIRE_MERGER,
|
||||
SOURCE_HEARTBEAT,
|
||||
})
|
||||
|
||||
@@ -133,6 +136,117 @@ def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
||||
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 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|"
|
||||
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 assess_adopt_merger_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
|
||||
+127
-8
@@ -20,12 +20,114 @@ if PROJECT_ROOT not in sys.path:
|
||||
import gitea_config
|
||||
|
||||
|
||||
AUTHOR_DEFAULT_ALLOWED = ["read", "branch", "commit", "push", "open_pr", "comment"]
|
||||
AUTHOR_DEFAULT_FORBIDDEN = ["approve", "request_changes", "merge"]
|
||||
REVIEWER_DEFAULT_ALLOWED = [
|
||||
"read", "review", "comment", "approve", "request_changes", "merge"
|
||||
# Defaults emit *canonical* operation names only. Shorthand that is not in
|
||||
# gitea_config.GITEA_OPERATION_ALIASES (e.g. ``pr.close``, ``issue.close``)
|
||||
# is silently dropped by the production loader and must never appear here.
|
||||
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):
|
||||
@@ -90,9 +192,11 @@ def migrate_v1_to_v2(v1_data):
|
||||
ident_name = "reviewer"
|
||||
elif role == "author":
|
||||
ident_name = "author"
|
||||
elif role == "reconciler":
|
||||
ident_name = "reconciler"
|
||||
else:
|
||||
role = prof.get("role")
|
||||
if role not in (None, "author", "reviewer"):
|
||||
if role not in (None, "author", "reviewer", "reconciler"):
|
||||
raise ValueError(
|
||||
f"Profile '{name}' has unsupported role {role!r}"
|
||||
)
|
||||
@@ -124,20 +228,35 @@ def migrate_v1_to_v2(v1_data):
|
||||
raise ValueError(
|
||||
f"Profile '{name}' operation fields must be lists"
|
||||
)
|
||||
identity_data["allowed_operations"] = list(allowed)
|
||||
identity_data["forbidden_operations"] = list(forbidden)
|
||||
try:
|
||||
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":
|
||||
identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED)
|
||||
identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN)
|
||||
elif role == "reviewer":
|
||||
identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED)
|
||||
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:
|
||||
raise ValueError(
|
||||
f"Profile '{name}' has no explicit operation lists and no "
|
||||
"unambiguous author/reviewer role marker (fail closed)"
|
||||
)
|
||||
|
||||
if role == "reconciler":
|
||||
_assert_reconciler_required_survive(
|
||||
identity_data["allowed_operations"], name
|
||||
)
|
||||
|
||||
# Nest inside environments/services structure
|
||||
env = environments.setdefault(env_name, {})
|
||||
services = env.setdefault("services", {})
|
||||
|
||||
@@ -56,23 +56,46 @@ def resolve_namespace_workspace(
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
demotions: list[str] | None = None,
|
||||
verify_paths: bool = False,
|
||||
) -> 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): a binding to a deleted worktree can never name a
|
||||
valid task workspace, so resolution falls through to the next candidate.
|
||||
Explicit arguments are never demoted — a caller-declared path must fail
|
||||
loudly downstream rather than silently rebind. Demotion notes are
|
||||
appended to *demotions* when provided. Runtime-context and mutation
|
||||
guards resolve through :func:`resolve_namespace_mutation_context`, which
|
||||
always verifies.
|
||||
"""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||
|
||||
for candidate, source in (
|
||||
(worktree_path, "worktree_path argument"),
|
||||
(worktree, "worktree argument"),
|
||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
|
||||
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
|
||||
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,
|
||||
"reviewer PR lease worktree"),
|
||||
"reviewer PR lease worktree", False),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text)), source
|
||||
if not text:
|
||||
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)"
|
||||
|
||||
@@ -88,6 +111,7 @@ def resolve_namespace_mutation_context(
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||
demotions: list[str] = []
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
@@ -96,6 +120,8 @@ def resolve_namespace_mutation_context(
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
@@ -111,7 +137,7 @@ def resolve_namespace_mutation_context(
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
|
||||
+161
-1
@@ -8,6 +8,9 @@ available unless explicit recovery-mode proof is supplied.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES
|
||||
@@ -366,4 +369,161 @@ def assess_gitea_operation_path(
|
||||
else "proceed with native MCP"
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def default_terminal_probe_command() -> list[str]:
|
||||
return ["git", "--version"] if shutil.which("git") else ["echo", "ok"]
|
||||
|
||||
|
||||
def _resolve_executable(cwd: str | None, executable: str | None) -> str | None:
|
||||
if not executable:
|
||||
return None
|
||||
if os.path.isabs(executable):
|
||||
if os.path.exists(executable) and os.access(executable, os.X_OK):
|
||||
return executable
|
||||
return None
|
||||
if "/" in executable or "\\" in executable:
|
||||
target_cwd = cwd or os.getcwd()
|
||||
full_path = os.path.abspath(os.path.join(target_cwd, executable))
|
||||
if os.path.exists(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
return None
|
||||
return shutil.which(executable)
|
||||
|
||||
|
||||
def diagnose_terminal_failure(cwd: str | None, command: list[str]) -> dict[str, Any]:
|
||||
"""Inspect and categorize command spawn failures (#556)."""
|
||||
diagnostics = {
|
||||
"cwd": cwd,
|
||||
"command": command,
|
||||
"cwd_exists": True,
|
||||
"cwd_is_dir": True,
|
||||
"shell_exists": True,
|
||||
"executable_exists": True,
|
||||
"resolved_executable": None,
|
||||
"error_type": "session launcher failure",
|
||||
"error_msg": (
|
||||
"Subprocess spawn failed despite valid CWD and executable. This may be due "
|
||||
"to sandboxing, permission restrictions, or system resource limits."
|
||||
),
|
||||
}
|
||||
|
||||
if cwd:
|
||||
if not os.path.exists(cwd):
|
||||
diagnostics["cwd_exists"] = False
|
||||
diagnostics["error_type"] = "missing cwd"
|
||||
diagnostics["error_msg"] = f"Current working directory does not exist: {cwd}"
|
||||
return diagnostics
|
||||
if not os.path.isdir(cwd):
|
||||
diagnostics["cwd_is_dir"] = False
|
||||
diagnostics["error_type"] = "cwd is not a directory"
|
||||
diagnostics["error_msg"] = f"Current working directory is not a directory: {cwd}"
|
||||
return diagnostics
|
||||
|
||||
exe = command[0] if command else None
|
||||
if exe:
|
||||
resolved_exe = _resolve_executable(cwd, exe)
|
||||
diagnostics["resolved_executable"] = resolved_exe
|
||||
if not resolved_exe:
|
||||
diagnostics["executable_exists"] = False
|
||||
if "venv" in exe or "pytest" in exe:
|
||||
diagnostics["error_type"] = "missing runtime wrapper"
|
||||
diagnostics["error_msg"] = (
|
||||
"Virtual environment runtime wrapper/executable not found or not "
|
||||
f"executable: {exe}"
|
||||
)
|
||||
else:
|
||||
diagnostics["error_type"] = "missing executable"
|
||||
diagnostics["error_msg"] = f"Executable not found in PATH or CWD: {exe}"
|
||||
return diagnostics
|
||||
|
||||
# Check common shell availability
|
||||
has_any_shell = False
|
||||
for shell_path in ("/bin/sh", "/bin/zsh", "/bin/bash"):
|
||||
if os.path.exists(shell_path) and os.access(shell_path, os.X_OK):
|
||||
has_any_shell = True
|
||||
break
|
||||
if not has_any_shell:
|
||||
diagnostics["shell_exists"] = False
|
||||
diagnostics["error_type"] = "missing shell"
|
||||
diagnostics["error_msg"] = (
|
||||
"No standard system shell (/bin/sh, /bin/zsh, /bin/bash) is "
|
||||
"available or executable."
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
return diagnostics
|
||||
|
||||
|
||||
def probe_terminal_spawn(
|
||||
cwd: str | None = None,
|
||||
command: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Proactively probe command spawning to detect launcher health (#556)."""
|
||||
probe_cmd = command or default_terminal_probe_command()
|
||||
diag = diagnose_terminal_failure(cwd, probe_cmd)
|
||||
if diag["error_type"] != "session launcher failure":
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": diag["error_type"],
|
||||
"error_msg": diag["error_msg"],
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
probe_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
timeout=5,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return {
|
||||
"healthy": True,
|
||||
"error_type": None,
|
||||
"error_msg": "",
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
# Non-zero status still proves the launcher spawned the process.
|
||||
return {
|
||||
"healthy": True,
|
||||
"error_type": None,
|
||||
"error_msg": "",
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"exit_code": proc.returncode,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": diag["error_type"],
|
||||
"error_msg": diag["error_msg"],
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": "probe timeout",
|
||||
"error_msg": f"Subprocess probe timed out after {exc.timeout} seconds.",
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": diag["error_type"],
|
||||
"error_msg": f"Subprocess spawn failed with exception: {exc}",
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
|
||||
@@ -86,6 +86,22 @@ _WRONG_BRANCH_RE = re.compile(
|
||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||
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:
|
||||
@@ -204,16 +220,19 @@ def assess_post_merge_cleanup_proof(
|
||||
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:
|
||||
cleanup_mutations = re.search(
|
||||
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
value_match = _CLEANUP_MUTATIONS_VALUE_RE.search(text)
|
||||
value = (value_match.group(1).strip() if value_match else "")
|
||||
value_lower = value.lower()
|
||||
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(
|
||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||
)
|
||||
|
||||
@@ -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,648 @@
|
||||
"""PR synchronization and conflict-remediation lifecycle (#PR-SYNC).
|
||||
|
||||
Pure assessment helpers for:
|
||||
|
||||
* ``gitea_assess_pr_sync_status`` — recommend the next sanctioned action for an
|
||||
open PR when the base branch advances, approvals go stale, or conflicts appear.
|
||||
* ``gitea_update_pr_branch_by_merge`` preflight — author-only, fail-closed pin
|
||||
of both PR head and live base head; never rebase/force-push.
|
||||
|
||||
All recommendation logic is hermetic (no network) so unit tests cover every
|
||||
acceptance path without Gitea credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
# Recommended next actions (controller / skill vocabulary).
|
||||
ACTION_MERGE_NOW = "merge_now"
|
||||
ACTION_UPDATE_BRANCH_BY_MERGE = "update_branch_by_merge"
|
||||
ACTION_AUTHOR_CONFLICT_REMEDIATION = "author_conflict_remediation"
|
||||
ACTION_FRESH_REVIEW_REQUIRED = "fresh_review_required"
|
||||
ACTION_BLOCKED = "blocked"
|
||||
|
||||
_VALID_ACTIONS = frozenset({
|
||||
ACTION_MERGE_NOW,
|
||||
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||
ACTION_AUTHOR_CONFLICT_REMEDIATION,
|
||||
ACTION_FRESH_REVIEW_REQUIRED,
|
||||
ACTION_BLOCKED,
|
||||
})
|
||||
|
||||
# Author-only mutation; reviewer/merger must never update author branches.
|
||||
_AUTHOR_UPDATE_ROLES = frozenset({"author"})
|
||||
_DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "limited"})
|
||||
|
||||
# Allowed Gitea update style — merge only (never rebase).
|
||||
UPDATE_STYLE_MERGE = "merge"
|
||||
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
||||
|
||||
|
||||
def _normalize_sha(value: str | None) -> str | None:
|
||||
text = (value or "").strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
return text if _FULL_SHA.match(text) else None
|
||||
|
||||
|
||||
def _normalize_role(role: str | None) -> str:
|
||||
return (role or "").strip().lower() or "unknown"
|
||||
|
||||
|
||||
def assess_pr_sync_status(
|
||||
*,
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
pr_number: int,
|
||||
pr_state: str | None = None,
|
||||
source_branch: str | None = None,
|
||||
pr_head_sha: str | None = None,
|
||||
base_head_sha: str | None = None,
|
||||
commits_behind: int | None = None,
|
||||
mergeable: bool | None = None,
|
||||
has_conflicts: bool | None = None,
|
||||
branch_protection_requires_current_base: bool | None = None,
|
||||
approval_at_current_head: bool | None = None,
|
||||
checks_status: str | None = None,
|
||||
active_author_lock: bool | None = None,
|
||||
active_reviewer_lease: bool | None = None,
|
||||
active_merger_lease: bool | None = None,
|
||||
prepared_verdict_head_sha: str | None = None,
|
||||
checks_required: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Recommend the next sanctioned PR lifecycle action.
|
||||
|
||||
Decision order (fail closed):
|
||||
|
||||
1. Incomplete identity / open state / head SHAs → ``blocked``
|
||||
2. Conflicts (or mergeable false with conflict signal) →
|
||||
``author_conflict_remediation``
|
||||
3. Head changed after approval / prepared verdict for former head →
|
||||
``fresh_review_required`` (unless conflicts already routed author work)
|
||||
4. Behind live base + branch protection requires current base +
|
||||
auto-mergeable → ``update_branch_by_merge``
|
||||
5. Approval at current head + mergeable + checks ok (+ update not
|
||||
required) → ``merge_now``
|
||||
6. Otherwise ``blocked`` with structured reasons
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
pr_head = _normalize_sha(pr_head_sha)
|
||||
base_head = _normalize_sha(base_head_sha)
|
||||
prepared_head = _normalize_sha(prepared_verdict_head_sha)
|
||||
state = (pr_state or "").strip().lower()
|
||||
checks = (checks_status or "unknown").strip().lower()
|
||||
behind = commits_behind if isinstance(commits_behind, int) and commits_behind >= 0 else None
|
||||
requires_current = bool(branch_protection_requires_current_base)
|
||||
approval_ok = bool(approval_at_current_head)
|
||||
|
||||
# Derive conflict when caller only supplies mergeable=False.
|
||||
if has_conflicts is None and mergeable is False:
|
||||
has_conflicts = True
|
||||
conflicts = bool(has_conflicts) if has_conflicts is not None else None
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"host": (host or "").strip() or None,
|
||||
"org": (org or "").strip() or None,
|
||||
"repo": (repo or "").strip() or None,
|
||||
"pr_number": pr_number,
|
||||
"pr_state": state or None,
|
||||
"source_branch": (source_branch or "").strip() or None,
|
||||
"pr_head_sha": pr_head,
|
||||
"base_head_sha": base_head,
|
||||
"commits_behind": behind,
|
||||
"mergeable": mergeable,
|
||||
"has_conflicts": conflicts,
|
||||
"branch_protection_requires_current_base": requires_current,
|
||||
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
||||
"checks_status": checks,
|
||||
"active_locks_and_leases": {
|
||||
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
|
||||
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
|
||||
"merger_lease": bool(active_merger_lease) if active_merger_lease is not None else None,
|
||||
},
|
||||
"prepared_verdict_head_sha": prepared_head,
|
||||
"recommended_next_action": ACTION_BLOCKED,
|
||||
"approval_valid_for_merge": False,
|
||||
"stale_approval": False,
|
||||
"stale_prepared_verdict": False,
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
"performed": False,
|
||||
}
|
||||
|
||||
if not isinstance(pr_number, int) or pr_number <= 0:
|
||||
reasons.append("pr_number must be a positive integer (fail closed)")
|
||||
return result
|
||||
|
||||
if state and state not in ("open",):
|
||||
reasons.append(f"PR is not open (state={state}); cannot synchronize or merge")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
if not state:
|
||||
reasons.append("PR state missing (fail closed)")
|
||||
return result
|
||||
|
||||
if pr_head is None:
|
||||
reasons.append(
|
||||
"exact PR head SHA missing or not a full 40-char hex SHA (fail closed)"
|
||||
)
|
||||
if base_head is None:
|
||||
reasons.append(
|
||||
"exact live base head SHA missing or not a full 40-char hex SHA (fail closed)"
|
||||
)
|
||||
if behind is None:
|
||||
reasons.append("commits_behind missing or invalid (fail closed)")
|
||||
if mergeable is None:
|
||||
reasons.append("mergeable signal missing (fail closed)")
|
||||
if approval_at_current_head is None:
|
||||
reasons.append("approval_at_current_head missing (fail closed)")
|
||||
if branch_protection_requires_current_base is None:
|
||||
reasons.append(
|
||||
"branch_protection_requires_current_base missing (fail closed)"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
# Stale prepared verdict / approval across heads.
|
||||
stale_prepared = bool(prepared_head and prepared_head != pr_head)
|
||||
result["stale_prepared_verdict"] = stale_prepared
|
||||
stale_approval = not approval_ok
|
||||
result["stale_approval"] = stale_approval
|
||||
result["approval_valid_for_merge"] = bool(approval_ok and not stale_prepared)
|
||||
|
||||
# ── Conflicts: author remediation in dedicated worktree ──────────────
|
||||
if conflicts is True or mergeable is False:
|
||||
if conflicts is True or mergeable is False:
|
||||
# Distinguish pure non-mergeable without explicit conflict flag
|
||||
# still routes author remediation (Gitea cannot auto-update).
|
||||
reasons.append(
|
||||
f"PR #{pr_number} has conflicts or is not mergeable at head "
|
||||
f"{pr_head}; route author conflict remediation in the existing "
|
||||
"issue/PR worktree under branches/ (never force-push or rebase)"
|
||||
)
|
||||
if stale_approval:
|
||||
reasons.append(
|
||||
"any approval at a former head is invalid; after remediation "
|
||||
"require a fresh independent review at the new exact head"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_AUTHOR_CONFLICT_REMEDIATION
|
||||
result["approval_valid_for_merge"] = False
|
||||
return result
|
||||
|
||||
# ── Stale approval / prepared verdict at former head ─────────────────
|
||||
if not approval_ok or stale_prepared:
|
||||
if behind and behind > 0 and requires_current and mergeable is True:
|
||||
# Must update first; update will also invalidate approval.
|
||||
reasons.append(
|
||||
f"PR is {behind} commit(s) behind live base and branch protection "
|
||||
"requires current base; automatic merge-from-base is available"
|
||||
)
|
||||
reasons.append(
|
||||
"approval is not valid at the current head (or prepared verdict "
|
||||
"is head-scoped to a former SHA); after update require fresh review"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
||||
result["approval_valid_for_merge"] = False
|
||||
return result
|
||||
reasons.append(
|
||||
"approval is not valid at the exact current PR head "
|
||||
f"({pr_head}); never reuse a former-head approval for merge"
|
||||
)
|
||||
if stale_prepared:
|
||||
reasons.append(
|
||||
f"prepared verdict pinned to former head {prepared_head} cannot "
|
||||
f"authorize review/merge at current head {pr_head}"
|
||||
)
|
||||
if active_reviewer_lease:
|
||||
reasons.append(
|
||||
"active reviewer lease must be released or superseded for the "
|
||||
"new head before a fresh independent review"
|
||||
)
|
||||
if active_merger_lease:
|
||||
reasons.append(
|
||||
"active merger lease cannot cross heads; release or re-acquire "
|
||||
"at the new exact head"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
||||
result["approval_valid_for_merge"] = False
|
||||
return result
|
||||
|
||||
# ── Outdated + protection requires current base, auto-updatable ──────
|
||||
if behind and behind > 0 and requires_current:
|
||||
if mergeable is True and conflicts is not True:
|
||||
reasons.append(
|
||||
f"PR is {behind} commit(s) behind live base {base_head}; "
|
||||
"branch protection requires the PR branch to contain current base; "
|
||||
"Gitea can merge base into the PR branch without conflicts"
|
||||
)
|
||||
reasons.append(
|
||||
"route author-only update_branch_by_merge; never rebase or force-push; "
|
||||
"new head invalidates prior approval and requires fresh independent review"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
||||
# Approval today is at old head that will change — not mergeable yet
|
||||
# for final merge until re-reviewed, but assess marks update path.
|
||||
result["approval_valid_for_merge"] = False
|
||||
result["stale_approval"] = True # will become stale after update
|
||||
return result
|
||||
reasons.append(
|
||||
"update required by branch protection but automatic merge is not available"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
# ── Checks gate for merge_now ────────────────────────────────────────
|
||||
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
|
||||
if checks in ("pending", "running", "queued"):
|
||||
reasons.append(f"required checks are not finished (status={checks})")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
if checks in ("failure", "failed", "error", "cancelled"):
|
||||
reasons.append(f"required checks failed (status={checks})")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
# unknown — fail closed when checks_required
|
||||
if checks == "unknown":
|
||||
reasons.append("checks status unknown (fail closed)")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
# ── Ready to merge without update ────────────────────────────────────
|
||||
# Includes: current with approval; outdated when update is NOT required.
|
||||
if approval_ok and mergeable is True and conflicts is not True:
|
||||
if behind and behind > 0 and not requires_current:
|
||||
reasons.append(
|
||||
f"PR is {behind} commit(s) behind live base but branch protection "
|
||||
"does not require the latest base; preserve the approved head"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
"PR has a valid approval at the exact current head, is conflict-free "
|
||||
"and mergeable; do not update the branch unnecessarily"
|
||||
)
|
||||
reasons.append("route directly to the sanctioned merger workflow (merge_now)")
|
||||
result["recommended_next_action"] = ACTION_MERGE_NOW
|
||||
result["approval_valid_for_merge"] = True
|
||||
return result
|
||||
|
||||
reasons.append("no sanctioned next action matched the observed PR state (fail closed)")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
|
||||
def assess_update_pr_branch_preflight(
|
||||
*,
|
||||
role_kind: str | None,
|
||||
expected_pr_head_sha: str | None,
|
||||
live_pr_head_sha: str | None,
|
||||
expected_base_head_sha: str | None,
|
||||
live_base_head_sha: str | None,
|
||||
has_conflicts: bool | None = None,
|
||||
mergeable: bool | None = None,
|
||||
style: str | None = UPDATE_STYLE_MERGE,
|
||||
has_author_lock: bool | None = None,
|
||||
worktree_path: str | None = None,
|
||||
worktree_owns_source_branch: bool | None = None,
|
||||
control_checkout_clean: bool | None = None,
|
||||
master_parity_ok: bool | None = None,
|
||||
force_push: bool = False,
|
||||
rebase: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Author-only preflight for update-by-merge; fail closed on any race.
|
||||
|
||||
When conflicts exist, returns a structured author-remediation handoff and
|
||||
sets ``mutation_allowed=False`` so no partial remote update is performed.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
role = _normalize_role(role_kind)
|
||||
exp_pr = _normalize_sha(expected_pr_head_sha)
|
||||
live_pr = _normalize_sha(live_pr_head_sha)
|
||||
exp_base = _normalize_sha(expected_base_head_sha)
|
||||
live_base = _normalize_sha(live_base_head_sha)
|
||||
style_norm = (style or "").strip().lower() or UPDATE_STYLE_MERGE
|
||||
|
||||
conflicts = has_conflicts
|
||||
if conflicts is None and mergeable is False:
|
||||
conflicts = True
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"mutation_allowed": False,
|
||||
"performed": False,
|
||||
"style": style_norm,
|
||||
"role_kind": role,
|
||||
"expected_pr_head_sha": exp_pr,
|
||||
"live_pr_head_sha": live_pr,
|
||||
"expected_base_head_sha": exp_base,
|
||||
"live_base_head_sha": live_base,
|
||||
"head_race": False,
|
||||
"base_race": False,
|
||||
"has_conflicts": conflicts,
|
||||
"author_remediation_handoff": False,
|
||||
"approval_invalidated_for_former_head": False,
|
||||
"force_push": bool(force_push),
|
||||
"rebase": bool(rebase),
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
# Role gate — author only.
|
||||
if role not in _AUTHOR_UPDATE_ROLES:
|
||||
reasons.append(
|
||||
f"role '{role}' cannot update an author PR branch "
|
||||
"(author-only; reviewer/merger denial)"
|
||||
)
|
||||
return result
|
||||
|
||||
if force_push:
|
||||
reasons.append("force-push is forbidden for PR branch update (fail closed)")
|
||||
return result
|
||||
if rebase or style_norm in _FORBIDDEN_UPDATE_STYLES:
|
||||
reasons.append(
|
||||
f"update style '{style_norm}' forbidden; only style=merge is allowed "
|
||||
"(never rebase)"
|
||||
)
|
||||
return result
|
||||
if style_norm != UPDATE_STYLE_MERGE:
|
||||
reasons.append(
|
||||
f"unknown update style '{style_norm}'; expected '{UPDATE_STYLE_MERGE}'"
|
||||
)
|
||||
return result
|
||||
|
||||
if not exp_pr or not live_pr:
|
||||
reasons.append(
|
||||
"expected and live PR head SHAs must be full 40-char hex (fail closed)"
|
||||
)
|
||||
if not exp_base or not live_base:
|
||||
reasons.append(
|
||||
"expected and live base head SHAs must be full 40-char hex (fail closed)"
|
||||
)
|
||||
if reasons:
|
||||
return result
|
||||
|
||||
if exp_pr != live_pr:
|
||||
result["head_race"] = True
|
||||
reasons.append(
|
||||
f"PR head race: expected {exp_pr} but live is {live_pr} "
|
||||
"(fail closed; no partial mutation)"
|
||||
)
|
||||
return result
|
||||
if exp_base != live_base:
|
||||
result["base_race"] = True
|
||||
reasons.append(
|
||||
f"base head race: expected {exp_base} but live is {live_base} "
|
||||
"(fail closed; no partial mutation)"
|
||||
)
|
||||
return result
|
||||
|
||||
if has_author_lock is not True:
|
||||
reasons.append(
|
||||
"existing issue/PR lock required before update_branch_by_merge (fail closed)"
|
||||
)
|
||||
wt = (worktree_path or "").strip()
|
||||
if not wt:
|
||||
reasons.append("existing PR worktree path required under branches/ (fail closed)")
|
||||
else:
|
||||
norm = wt.replace("\\", "/")
|
||||
if "/branches/" not in norm and not norm.startswith("branches/"):
|
||||
reasons.append(
|
||||
f"worktree_path '{wt}' must be under branches/ (fail closed)"
|
||||
)
|
||||
if worktree_owns_source_branch is False:
|
||||
reasons.append(
|
||||
"worktree does not own the PR source branch (fail closed)"
|
||||
)
|
||||
if control_checkout_clean is False:
|
||||
reasons.append("control checkout is not clean (fail closed)")
|
||||
if master_parity_ok is False:
|
||||
reasons.append("runtime/master parity failed (fail closed)")
|
||||
|
||||
if conflicts is True:
|
||||
result["author_remediation_handoff"] = True
|
||||
reasons.append(
|
||||
"conflicts present: return structured author-remediation handoff "
|
||||
"without creating a partial remote update"
|
||||
)
|
||||
reasons.append(
|
||||
"resolve conflicts explicitly in the existing dedicated worktree, "
|
||||
"run focused and regression tests, commit/push via sanctioned author "
|
||||
"workflow, then route the new exact head to independent review"
|
||||
)
|
||||
return result
|
||||
|
||||
if mergeable is False and conflicts is not False:
|
||||
result["author_remediation_handoff"] = True
|
||||
reasons.append(
|
||||
"PR is not mergeable; refuse automatic update and hand off to "
|
||||
"author conflict remediation (fail closed)"
|
||||
)
|
||||
return result
|
||||
|
||||
if reasons:
|
||||
return result
|
||||
|
||||
result["mutation_allowed"] = True
|
||||
reasons.append(
|
||||
"preflight passed: author may merge live base into the PR branch via "
|
||||
"native MCP update (style=merge only)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def assess_post_update_head_transition(
|
||||
*,
|
||||
former_pr_head_sha: str | None,
|
||||
new_pr_head_sha: str | None,
|
||||
former_approval_head_sha: str | None = None,
|
||||
former_reviewer_lease_head_sha: str | None = None,
|
||||
former_merger_lease_head_sha: str | None = None,
|
||||
prepared_verdict_head_sha: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""After a successful update-by-merge, invalidate cross-head artifacts.
|
||||
|
||||
The new head never inherits approval, reviewer/merger leases, or prepared
|
||||
verdicts from the former head.
|
||||
"""
|
||||
former = _normalize_sha(former_pr_head_sha)
|
||||
new = _normalize_sha(new_pr_head_sha)
|
||||
reasons: list[str] = []
|
||||
result: dict[str, Any] = {
|
||||
"former_pr_head_sha": former,
|
||||
"new_pr_head_sha": new,
|
||||
"head_changed": bool(former and new and former != new),
|
||||
"approval_invalidated": False,
|
||||
"reviewer_lease_superseded": False,
|
||||
"merger_lease_superseded": False,
|
||||
"prepared_verdict_invalidated": False,
|
||||
"recommended_next_action": ACTION_BLOCKED,
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
if not former or not new:
|
||||
reasons.append("former and new PR head SHAs required (fail closed)")
|
||||
return result
|
||||
if former == new:
|
||||
reasons.append(
|
||||
"PR head unchanged after update; unexpected for merge-from-base"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
result["head_changed"] = True
|
||||
# Approval at former head is always void at new head.
|
||||
former_approval = _normalize_sha(former_approval_head_sha) or former
|
||||
if former_approval != new:
|
||||
result["approval_invalidated"] = True
|
||||
reasons.append(
|
||||
f"approval for former head {former_approval} is invalid at new head "
|
||||
f"{new}; never preserve approval across a head change"
|
||||
)
|
||||
|
||||
rev_lease = _normalize_sha(former_reviewer_lease_head_sha)
|
||||
if rev_lease and rev_lease != new:
|
||||
result["reviewer_lease_superseded"] = True
|
||||
reasons.append(
|
||||
f"reviewer lease for head {rev_lease} cannot cross to {new}; "
|
||||
"release or supersede before fresh review"
|
||||
)
|
||||
elif rev_lease is None:
|
||||
# Unknown lease head still must not authorize at new head.
|
||||
result["reviewer_lease_superseded"] = True
|
||||
reasons.append(
|
||||
"any reviewer lease scoped to the former head is superseded at the new head"
|
||||
)
|
||||
|
||||
mer_lease = _normalize_sha(former_merger_lease_head_sha)
|
||||
if mer_lease and mer_lease != new:
|
||||
result["merger_lease_superseded"] = True
|
||||
reasons.append(
|
||||
f"merger lease for head {mer_lease} cannot cross to {new}"
|
||||
)
|
||||
else:
|
||||
result["merger_lease_superseded"] = True
|
||||
reasons.append(
|
||||
"any merger lease scoped to the former head is superseded at the new head"
|
||||
)
|
||||
|
||||
prepared = _normalize_sha(prepared_verdict_head_sha)
|
||||
if prepared and prepared != new:
|
||||
result["prepared_verdict_invalidated"] = True
|
||||
reasons.append(
|
||||
f"prepared verdict for head {prepared} cannot authorize the new head {new}"
|
||||
)
|
||||
elif prepared is None:
|
||||
result["prepared_verdict_invalidated"] = True
|
||||
reasons.append(
|
||||
"prepared verdicts associated with the former head are invalidated"
|
||||
)
|
||||
|
||||
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
||||
reasons.append(
|
||||
"route the new exact head to independent review "
|
||||
f"({ACTION_FRESH_REVIEW_REQUIRED})"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def assess_sequential_queue_step(
|
||||
*,
|
||||
just_merged: bool,
|
||||
master_refreshed: bool,
|
||||
next_pr_number: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Controller sequential processing: reassess only after merge + master refresh."""
|
||||
reasons: list[str] = []
|
||||
if just_merged and not master_refreshed:
|
||||
reasons.append(
|
||||
"master must be refreshed after each merge before reassessing the next PR "
|
||||
"(do not update every PR simultaneously)"
|
||||
)
|
||||
return {
|
||||
"reassess_allowed": False,
|
||||
"process_next": False,
|
||||
"next_pr_number": next_pr_number,
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
}
|
||||
if not just_merged:
|
||||
reasons.append("no merge completed; sequential step does not advance")
|
||||
return {
|
||||
"reassess_allowed": False,
|
||||
"process_next": False,
|
||||
"next_pr_number": next_pr_number,
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
}
|
||||
if next_pr_number:
|
||||
reasons.append(
|
||||
"merge completed and live master refreshed; reassess the next PR "
|
||||
f"#{next_pr_number}"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
"merge completed and live master refreshed; reassess the next PR"
|
||||
)
|
||||
return {
|
||||
"reassess_allowed": True,
|
||||
"process_next": True,
|
||||
"next_pr_number": next_pr_number,
|
||||
"post_merge_cleanup_handoff": True,
|
||||
"reasons": reasons,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
|
||||
def merge_approval_usable_at_head(
|
||||
*,
|
||||
current_head_sha: str | None,
|
||||
approved_head_sha: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Stale approval cannot authorize merge (head-scoped only)."""
|
||||
current = _normalize_sha(current_head_sha)
|
||||
approved = _normalize_sha(approved_head_sha)
|
||||
ok = bool(current and approved and current == approved)
|
||||
reasons: list[str] = []
|
||||
if not ok:
|
||||
reasons.append(
|
||||
f"stale approval: approved head '{approved or '(none)'}' does not "
|
||||
f"match current head '{current or '(none)'}'; cannot authorize merge"
|
||||
)
|
||||
return {
|
||||
"approval_usable": ok,
|
||||
"current_head_sha": current,
|
||||
"approved_head_sha": approved,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def lease_usable_at_head(
|
||||
*,
|
||||
lease_kind: str,
|
||||
lease_head_sha: str | None,
|
||||
current_head_sha: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reviewer/merger leases cannot cross heads."""
|
||||
current = _normalize_sha(current_head_sha)
|
||||
lease_head = _normalize_sha(lease_head_sha)
|
||||
kind = (lease_kind or "").strip().lower() or "unknown"
|
||||
ok = bool(current and lease_head and current == lease_head)
|
||||
reasons: list[str] = []
|
||||
if not ok:
|
||||
reasons.append(
|
||||
f"stale {kind} lease: lease head '{lease_head or '(none)'}' cannot "
|
||||
f"authorize work at current head '{current or '(none)'}'"
|
||||
)
|
||||
return {
|
||||
"lease_usable": ok,
|
||||
"lease_kind": kind,
|
||||
"lease_head_sha": lease_head,
|
||||
"current_head_sha": current,
|
||||
"reasons": reasons,
|
||||
}
|
||||
+67
-6
@@ -403,10 +403,37 @@ _REVIEWER_ACTIVE_RE = re.compile(
|
||||
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
|
||||
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]:
|
||||
"""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 ""
|
||||
reasons: list[str] = []
|
||||
reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None)
|
||||
@@ -422,15 +449,49 @@ def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
|
||||
)
|
||||
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")
|
||||
if not live_approval:
|
||||
if verdict_recorded and not live_approval:
|
||||
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")
|
||||
if not push_during:
|
||||
if validation_started and not push_during:
|
||||
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")
|
||||
elif reviewed and live_merge and reviewed != live_merge:
|
||||
reasons.append("live head before merge differs from reviewed head SHA")
|
||||
|
||||
@@ -18,6 +18,11 @@ RECONCILER_RECOMMENDED_OPERATIONS = (
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"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 = (
|
||||
|
||||
+26
-2
@@ -16,11 +16,35 @@ or the local remote URL is unavailable (best-effort corroboration only).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
REMEDIATION = (
|
||||
"Pass explicit org= and repo= matching the local git remote, "
|
||||
"e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools."
|
||||
"Pass explicit org= and repo= matching the local git remote on tools that "
|
||||
"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(
|
||||
*,
|
||||
|
||||
@@ -31,6 +31,7 @@ python-multipart==0.0.32
|
||||
referencing==0.37.0
|
||||
rich==15.0.0
|
||||
rpds-py==2026.5.1
|
||||
sentry-sdk==2.20.0
|
||||
shellingham==1.5.4
|
||||
sse-starlette==3.4.5
|
||||
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})",
|
||||
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(
|
||||
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,
|
||||
)
|
||||
_MERGED_CLAIM_RE = re.compile(
|
||||
|
||||
@@ -93,8 +93,16 @@ def assess_workflow_blockers(
|
||||
capability_blocked: bool = False,
|
||||
mcp_reconnect_failed: bool = False,
|
||||
stale_capability_state: bool = False,
|
||||
live_namespace_broken: bool = False,
|
||||
) -> 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] = []
|
||||
if infra_stop:
|
||||
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")
|
||||
if stale_capability_state:
|
||||
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 {
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
@@ -118,16 +132,19 @@ def assess_state_advancement(
|
||||
state_completion: dict[str, bool] | None,
|
||||
*,
|
||||
target_state: str,
|
||||
infra_stop: bool = False,
|
||||
capability_blocked: bool = False,
|
||||
**blocker_kwargs,
|
||||
) -> 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 {})
|
||||
target = _clean(target_state).upper()
|
||||
blockers = assess_workflow_blockers(
|
||||
infra_stop=infra_stop,
|
||||
capability_blocked=capability_blocked,
|
||||
)
|
||||
blockers = assess_workflow_blockers(**blocker_kwargs)
|
||||
reasons = list(blockers["reasons"])
|
||||
|
||||
try:
|
||||
|
||||
+226
-11
@@ -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]:
|
||||
"""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] = []
|
||||
for entry in action_log or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("gated_rejected") or entry.get("performed") is False:
|
||||
continue
|
||||
action = (entry.get("action") or "").strip().lower()
|
||||
@@ -2228,20 +2235,39 @@ HANDOFF_REVIEW_MUTATION_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": (
|
||||
("Selected PR", ("selected pr",)),
|
||||
("Reviewer eligibility", ("reviewer eligibility", "eligibility")),
|
||||
("Pinned reviewed head", ("pinned reviewed head", "pinned head")),
|
||||
("Worktree path", ("worktree path", "starting worktree path")),
|
||||
("Worktree dirty", ("worktree dirty", "whether worktree was dirty")),
|
||||
("Scratch worktree used", ("scratch worktree used", "scratch clone used",
|
||||
"scratch worktree")),
|
||||
("Reviewed head SHA", ("reviewed head sha", "candidate head sha")),
|
||||
("Review worktree path", ("review worktree path", "worktree path",
|
||||
"starting worktree path")),
|
||||
("Review worktree dirty", ("review worktree dirty", "worktree dirty",
|
||||
"whether worktree was dirty")),
|
||||
("Unrelated local mutations", ("unrelated local mutations",
|
||||
"unrelated files modified")),
|
||||
"unrelated files modified",
|
||||
"file edits by reviewer")),
|
||||
("Review decision", ("review decision", "decision")),
|
||||
("Merge result", ("merge result",)),
|
||||
("Linked issue status", ("linked issue status", "linked issue")),
|
||||
("Cleanup status", ("cleanup status", "cleanup")),
|
||||
("Safe next action", ("safe next action", "next")),
|
||||
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
||||
"merger": (
|
||||
("Selected PR", ("selected pr",)),
|
||||
("Reviewed head SHA", ("reviewed head sha", "candidate head sha")),
|
||||
("Active profile", ("active profile",)),
|
||||
("Role kind", ("role kind",)),
|
||||
("Merge capability source", ("merge capability source",)),
|
||||
("Explicit operator authorization", ("explicit operator authorization", "operator authorization", "authorization")),
|
||||
("Expected head SHA", ("expected head sha", "expected head")),
|
||||
("Approval at current head", ("approval at current head", "approval")),
|
||||
("Merge result", ("merge result",)),
|
||||
("Cleanup status", ("cleanup status", "cleanup")),
|
||||
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
||||
"author": (
|
||||
("Selected issue", ("selected issue",)),
|
||||
@@ -2417,13 +2443,26 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
|
||||
field for field in required
|
||||
if field[0] not in ("Workspace mutations", "Mutations")
|
||||
]
|
||||
if role == "review":
|
||||
# Issue #320: reviewer handoffs use the precise mutation categories
|
||||
if role in ("review", "merger"):
|
||||
# Issue #320: reviewer and merger handoffs use the precise mutation categories
|
||||
# in HANDOFF_REVIEW_MUTATION_FIELDS instead of the legacy ambiguous
|
||||
# "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 = [
|
||||
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):
|
||||
return {
|
||||
@@ -2431,7 +2470,7 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
|
||||
"downgraded": True,
|
||||
"missing_fields": [],
|
||||
"reasons": [
|
||||
"review handoff must not include legacy "
|
||||
"review or merger handoff must not include legacy "
|
||||
"'Workspace mutations' field; report the precise "
|
||||
"mutation categories instead (issue #320)"
|
||||
],
|
||||
@@ -4830,6 +4869,22 @@ RECONCILER_CLOSE_REPORT_FIELDS = (
|
||||
"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(
|
||||
*,
|
||||
@@ -4976,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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -5540,6 +5746,15 @@ def assess_already_landed_classification_report(report_text, **kwargs):
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
|
||||
def assess_conflict_fix_classification_final_report(report_text, **kwargs):
|
||||
"""#522: require live PR head re-pin before conflict-fix classification."""
|
||||
from conflict_fix_classification import (
|
||||
assess_conflict_fix_classification_final_report as _assess,
|
||||
)
|
||||
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
|
||||
def assess_prior_blocker_skip_proof(report_text, **kwargs):
|
||||
"""#318: require live blocker proof before skipping earlier open PRs."""
|
||||
from reviewer_blocker_skip import assess_prior_blocker_skip_proof as _assess
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Reviewer handoff consistency validation (#501).
|
||||
|
||||
Detects contradictions between narrative claims and mutation ledger fields in
|
||||
reviewer/final-review controller handoffs. Pure validation only — does not post
|
||||
comments or mutate Gitea state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_HANDOFF_FIELD_RE = re.compile(
|
||||
r"^\s*[-*]\s*([^:]+):\s*(.*)$",
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
|
||||
_MERGE_NARRATIVE_RE = re.compile(
|
||||
r"\b(?:merged\s+pr\s*#|pr\s+#\d+\s+(?:was\s+)?merged|"
|
||||
r"merge\s+result\s*:\s*merged|successfully\s+merged|"
|
||||
r"terminal\s+mutation\s+budget.{0,80}\bmerge)\b",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_REVIEW_SUBMIT_BLOCKED_RE = re.compile(
|
||||
r"\b(?:review\s+submission\s+blocked|submit_pr_review\s+(?:failed|blocked)|"
|
||||
r"gitea_submit_pr_review\s+(?:failed|blocked|not\s+(?:attempted|performed))|"
|
||||
r"review\s+not\s+submitted|could\s+not\s+submit\s+review)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FINAL_DECISION_MARKED_RE = re.compile(
|
||||
r"\b(?:gitea_mark_final_review_decision|final\s+review\s+decision\s+marked|"
|
||||
r"server-side\s+final\s+decision\s+marked|marked\s+final\s+review\s+decision)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TERMINAL_BUDGET_CONSUMED_RE = re.compile(
|
||||
r"\b(?:terminal\s+mutation\s+budget\s+(?:already\s+)?consumed|"
|
||||
r"single[- ]terminal\s+mutation\s+(?:already\s+)?consumed|"
|
||||
r"mutation\s+budget\s+exhausted)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LEASE_NARRATIVE_RE = re.compile(
|
||||
r"\b(?:reviewer\s+lease\s+acquired|acquired\s+reviewer\s+pr\s+lease|"
|
||||
r"gitea_acquire_reviewer_pr_lease)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REJECTED_MUTATION_RE = re.compile(
|
||||
r"\b(?:mutation\s+rejected|tool\s+failed\s+closed|blocked\s+before\s+mutation|"
|
||||
r"no\s+server-side\s+state\s+changed)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_IN_LEDGER_RE = re.compile(r"\bmerge\b", re.IGNORECASE)
|
||||
_REVIEW_SUBMITTED_IN_LEDGER_RE = re.compile(
|
||||
r"\b(?:submitted|approve|request_changes|review\s+submitted)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NONE_VALUE_RE = re.compile(r"^\s*(?:none|not\s+attempted|n/a)\s*$", re.IGNORECASE)
|
||||
|
||||
_BLOCKED_REVIEW_PROOF_FIELDS = (
|
||||
"tool called",
|
||||
"mutation attempted",
|
||||
"mutation rejected",
|
||||
"no server-side state changed",
|
||||
)
|
||||
|
||||
|
||||
def render_blocked_review_handoff_template() -> str:
|
||||
"""Return a corrected handoff template for blocked review submissions."""
|
||||
return """## Blocked Review Submission Handoff
|
||||
|
||||
- Tool called: gitea_submit_pr_review
|
||||
- Mutation attempted: yes
|
||||
- Mutation rejected: yes
|
||||
- No server-side state changed: confirmed
|
||||
- Proof/source: <paste exact tool error or gate reason>
|
||||
- Prior terminal mutation that consumed budget: <name exact prior mutation or none>
|
||||
- Review decision (local intent): <approve | request_changes | comment only>
|
||||
- Server-side final decision marked: <yes with tool proof | no>
|
||||
- Gitea review submitted: no
|
||||
- Gitea review blocked because: <exact gate/error>
|
||||
- Review mutations: none (submission blocked)
|
||||
- MCP/Gitea mutations: <list only mutations that actually occurred>
|
||||
- Safe next action: rewrite handoff with consistent ledger before posting
|
||||
"""
|
||||
|
||||
|
||||
def _handoff_fields(text: str | None) -> dict[str, str]:
|
||||
fields: dict[str, str] = {}
|
||||
for match in _HANDOFF_FIELD_RE.finditer(text or ""):
|
||||
key = match.group(1).strip().lower()
|
||||
value = match.group(2).strip()
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
def _is_none_value(value: str | None) -> bool:
|
||||
return bool(_NONE_VALUE_RE.match(value or ""))
|
||||
|
||||
|
||||
def _ledger_mentions_merge(*values: str | None) -> bool:
|
||||
blob = " ".join(v for v in values if v)
|
||||
return bool(blob) and bool(_MERGE_IN_LEDGER_RE.search(blob))
|
||||
|
||||
|
||||
def _ledger_mentions_review_submission(*values: str | None) -> bool:
|
||||
blob = " ".join(v for v in values if v)
|
||||
return bool(blob) and bool(_REVIEW_SUBMITTED_IN_LEDGER_RE.search(blob))
|
||||
|
||||
|
||||
def assess_reviewer_handoff_consistency(report_text: str | None) -> dict:
|
||||
"""Validate reviewer handoff narrative against mutation ledger fields."""
|
||||
text = report_text or ""
|
||||
fields = _handoff_fields(text)
|
||||
reasons: list[str] = []
|
||||
|
||||
review_mutations = fields.get("review mutations", "")
|
||||
merge_mutations = fields.get("merge mutations", "")
|
||||
mcp_mutations = fields.get("mcp/gitea mutations", "")
|
||||
review_decision = fields.get("review decision", "")
|
||||
|
||||
if _MERGE_NARRATIVE_RE.search(text) and not _ledger_mentions_merge(
|
||||
merge_mutations, mcp_mutations, review_mutations
|
||||
):
|
||||
reasons.append(
|
||||
"narrative claims a merge occurred but mutation ledger omits merge"
|
||||
)
|
||||
|
||||
if _TERMINAL_BUDGET_CONSUMED_RE.search(text):
|
||||
if _ledger_mentions_merge(merge_mutations, mcp_mutations):
|
||||
pass
|
||||
elif _LEASE_NARRATIVE_RE.search(text) and not _ledger_mentions_merge(
|
||||
merge_mutations, mcp_mutations
|
||||
):
|
||||
reasons.append(
|
||||
"terminal mutation budget attributed to merge but ledger only "
|
||||
"shows lease or non-merge activity"
|
||||
)
|
||||
elif not _ledger_mentions_merge(merge_mutations, mcp_mutations):
|
||||
reasons.append(
|
||||
"terminal mutation budget consumed claim must identify the "
|
||||
"exact prior mutation in the ledger"
|
||||
)
|
||||
|
||||
if _REVIEW_SUBMIT_BLOCKED_RE.search(text) and _FINAL_DECISION_MARKED_RE.search(text):
|
||||
reasons.append(
|
||||
"handoff claims review submission blocked but also claims "
|
||||
"server-side final decision was marked"
|
||||
)
|
||||
|
||||
lease_claimed = _LEASE_NARRATIVE_RE.search(text) or (
|
||||
not _is_none_value(mcp_mutations)
|
||||
and "lease" in mcp_mutations.lower()
|
||||
)
|
||||
if lease_claimed and _is_none_value(review_decision):
|
||||
reasons.append(
|
||||
"reviewer lease acquired but Review decision field is missing or none"
|
||||
)
|
||||
|
||||
if _REVIEW_SUBMIT_BLOCKED_RE.search(text) or _REJECTED_MUTATION_RE.search(text):
|
||||
lower = text.lower()
|
||||
missing_proof = [
|
||||
field
|
||||
for field in _BLOCKED_REVIEW_PROOF_FIELDS
|
||||
if field not in lower
|
||||
]
|
||||
if missing_proof:
|
||||
reasons.append(
|
||||
"blocked/rejected mutation claim missing proof fields: "
|
||||
+ ", ".join(missing_proof)
|
||||
)
|
||||
|
||||
if (
|
||||
_FINAL_DECISION_MARKED_RE.search(text)
|
||||
and _is_none_value(review_mutations)
|
||||
and not _ledger_mentions_review_submission(mcp_mutations)
|
||||
and not _REVIEW_SUBMIT_BLOCKED_RE.search(text)
|
||||
):
|
||||
reasons.append(
|
||||
"final review decision marked but Review mutations ledger is empty"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"fields": fields,
|
||||
}
|
||||
+1108
-12
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)
|
||||
|
||||
|
||||
# #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]:
|
||||
"""Return tracked paths with local modifications from ``git status --porcelain``.
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_SESSION_OWNED_RE = re.compile(
|
||||
r"branches/(?:review-pr\d+[\w/-]*|review-[\w-]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
from reviewer_worktree import REVIEW_WORKTREE_RE
|
||||
|
||||
_SESSION_OWNED_RE = REVIEW_WORKTREE_RE
|
||||
_WORKTREE_PATH_RE = re.compile(
|
||||
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
|
||||
+40
-5
@@ -55,7 +55,6 @@ def skip_python_scan_walk_root(project_root: str, walk_root: str) -> bool:
|
||||
|
||||
REVIEWER_TASKS = frozenset({
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
@@ -63,6 +62,10 @@ REVIEWER_TASKS = frozenset({
|
||||
"approve_pr",
|
||||
})
|
||||
|
||||
MERGER_TASKS = frozenset({
|
||||
"merge_pr",
|
||||
})
|
||||
|
||||
AUTHOR_TASKS = frozenset({
|
||||
"create_issue",
|
||||
"comment_issue",
|
||||
@@ -73,13 +76,17 @@ AUTHOR_TASKS = frozenset({
|
||||
"create_pr",
|
||||
"comment_pr",
|
||||
"address_pr_change_requests",
|
||||
"delete_branch",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
"reconcile_landed_pr",
|
||||
})
|
||||
|
||||
RECONCILER_TASKS = frozenset({
|
||||
"cleanup_merged_pr_branch",
|
||||
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
|
||||
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
|
||||
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
|
||||
"delete_branch",
|
||||
"reconcile_already_landed_pr",
|
||||
"reconcile_already_landed",
|
||||
"reconcile-landed-pr",
|
||||
@@ -95,9 +102,10 @@ TASK_REQUIRED_ROLE = {
|
||||
"create_pr": "author",
|
||||
"comment_pr": "author",
|
||||
"address_pr_change_requests": "author",
|
||||
"delete_branch": "author",
|
||||
# #729: reconciler-owned; see RECONCILER_TASKS and task_capability_map.
|
||||
"delete_branch": "reconciler",
|
||||
"review_pr": "reviewer",
|
||||
"merge_pr": "reviewer",
|
||||
"merge_pr": "merger",
|
||||
"blind_pr_queue_review": "reviewer",
|
||||
"pr_queue_cleanup": "reviewer",
|
||||
"pr-queue-cleanup": "reviewer",
|
||||
@@ -109,9 +117,13 @@ TASK_REQUIRED_ROLE = {
|
||||
"reconcile_already_landed_pr": "reconciler",
|
||||
"reconcile_already_landed": "reconciler",
|
||||
"reconcile-landed-pr": "reconciler",
|
||||
"cleanup_merged_pr_branch": "reconciler",
|
||||
# #309: reconciler tasks close already-landed PRs/issues only.
|
||||
"reconcile_close_landed_pr": "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 = (
|
||||
@@ -123,6 +135,10 @@ WRONG_ROLE_RECONCILER_MSG = (
|
||||
"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
|
||||
|
||||
|
||||
@@ -238,6 +254,25 @@ def route_task_session(
|
||||
_record_route(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":
|
||||
route = ROUTE_TO_AUTHOR
|
||||
message = (
|
||||
@@ -406,4 +441,4 @@ def assess_infra_stop(project_root: str | None = None) -> dict:
|
||||
|
||||
def check_mid_merge(project_root: str | None = None) -> bool:
|
||||
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
|
||||
return assess_infra_stop(project_root)["infra_stop"]
|
||||
return assess_infra_stop(project_root)["infra_stop"]
|
||||
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Path-filtered CI gate for the internal web UI (#436).
|
||||
# Runs the hermetic web UI unittest suite when a change touches UI surfaces.
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
if [[ "${WEBUI_CI_FORCE:-}" == "1" ]]; then
|
||||
exec "$script_dir/test-webui"
|
||||
fi
|
||||
|
||||
base_ref="${WEBUI_CI_BASE_REF:-}"
|
||||
if [[ -z "$base_ref" ]]; then
|
||||
if git rev-parse --verify prgs/master >/dev/null 2>&1; then
|
||||
base_ref="$(git merge-base HEAD prgs/master 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "$base_ref" ]]; then
|
||||
base_ref="${GITHUB_BASE_REF:-${CHANGE_TARGET:-master}}"
|
||||
if git rev-parse --verify "origin/$base_ref" >/dev/null 2>&1; then
|
||||
base_ref="$(git merge-base HEAD "origin/$base_ref")"
|
||||
elif git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
|
||||
base_ref="$(git merge-base HEAD "$base_ref")"
|
||||
else
|
||||
base_ref="HEAD~1"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
changed="$(git diff --name-only "$base_ref" HEAD 2>/dev/null || true)"
|
||||
if [[ -z "$changed" ]]; then
|
||||
echo "ci-webui-check: no changed files vs $base_ref; skipping web UI suite"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python_bin="${WEBUI_TEST_PYTHON:-python3}"
|
||||
if [[ -x "$repo_root/venv/bin/python" ]]; then
|
||||
python_bin="$repo_root/venv/bin/python"
|
||||
fi
|
||||
|
||||
if printf '%s\n' "$changed" | "$python_bin" -c "
|
||||
import sys
|
||||
from webui.ci_paths import should_run_webui_ci
|
||||
paths = [line.strip() for line in sys.stdin if line.strip()]
|
||||
raise SystemExit(0 if should_run_webui_ci(paths) else 1)
|
||||
"; then
|
||||
echo "ci-webui-check: web UI surface changed; running suite"
|
||||
exec "$script_dir/test-webui"
|
||||
fi
|
||||
|
||||
echo "ci-webui-check: no web UI paths in diff; skipping suite"
|
||||
exit 0
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install canonical Gitea workflow skill into Codex skills dir (#551).
|
||||
# Symlinks the in-repo skills/llm-project-workflow package under the names
|
||||
# gitea-workflow, llm-project-workflow, and git-pr-workflows.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/install-codex-workflow-skill.sh [--dry-run] [--skills-dir DIR]
|
||||
|
||||
Symlink the portable skills/llm-project-workflow package into the Codex
|
||||
skills directory under the canonical names:
|
||||
gitea-workflow
|
||||
llm-project-workflow
|
||||
git-pr-workflows
|
||||
|
||||
Defaults:
|
||||
skills dir: $CODEX_HOME/skills or ~/.codex/skills
|
||||
source: <repo>/skills/llm-project-workflow
|
||||
EOF
|
||||
}
|
||||
|
||||
dry_run=0
|
||||
skills_dir=""
|
||||
while [[ "${1:-}" == --* ]]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=1 ;;
|
||||
--skills-dir)
|
||||
shift
|
||||
skills_dir="${1:-}"
|
||||
;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) usage >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
source_skill="$repo_root/skills/llm-project-workflow"
|
||||
|
||||
if [[ ! -f "$source_skill/SKILL.md" ]]; then
|
||||
echo "Error: missing $source_skill/SKILL.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$skills_dir" ]]; then
|
||||
if [[ -n "${CODEX_HOME:-}" ]]; then
|
||||
skills_dir="$CODEX_HOME/skills"
|
||||
else
|
||||
skills_dir="${HOME}/.codex/skills"
|
||||
fi
|
||||
fi
|
||||
|
||||
names=(gitea-workflow llm-project-workflow git-pr-workflows)
|
||||
|
||||
echo "source: $source_skill"
|
||||
echo "codex skills dir: $skills_dir"
|
||||
|
||||
if [[ "$dry_run" -eq 0 ]]; then
|
||||
mkdir -p "$skills_dir"
|
||||
fi
|
||||
|
||||
for name in "${names[@]}"; do
|
||||
target="$skills_dir/$name"
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
echo "dry-run: ln -sfn $source_skill $target"
|
||||
continue
|
||||
fi
|
||||
if [[ -e "$target" || -L "$target" ]]; then
|
||||
if [[ -L "$target" ]]; then
|
||||
rm -f "$target"
|
||||
else
|
||||
echo "Error: $target exists and is not a symlink (refuse to overwrite)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
ln -sfn "$source_skill" "$target"
|
||||
echo "linked $target -> $source_skill"
|
||||
done
|
||||
|
||||
echo "done. Restart Codex so skills reload."
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
python_bin="${WEBUI_TEST_PYTHON:-python3}"
|
||||
if [[ -x "$repo_root/venv/bin/python" ]]; then
|
||||
python_bin="$repo_root/venv/bin/python"
|
||||
fi
|
||||
|
||||
pattern="${WEBUI_TEST_PATTERN:-test_webui_*.py}"
|
||||
export WEBUI_TEST_OFFLINE="${WEBUI_TEST_OFFLINE:-1}"
|
||||
exec "$python_bin" -m unittest discover -s tests -p "$pattern" "$@"
|
||||
@@ -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,595 @@
|
||||
"""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
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# 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",
|
||||
) -> 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,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
) -> 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(),
|
||||
)
|
||||
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",
|
||||
) -> 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,
|
||||
)
|
||||
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,
|
||||
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
|
||||
|
||||
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)"
|
||||
)
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
|
||||
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),
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: gitea-workflow
|
||||
description: >-
|
||||
Canonical alias for the Gitea/LLM project workflow router. Same skill as
|
||||
llm-project-workflow. Use at the start of any Gitea-Tools author, review,
|
||||
merge, reconcile, or issue-filing task. Trigger terms: gitea-workflow,
|
||||
llm-project-workflow, git-pr-workflows, gitea, PR review, work-issue.
|
||||
---
|
||||
|
||||
# gitea-workflow (canonical alias)
|
||||
|
||||
This directory is the **stable name** for controller prompts and Codex mounts
|
||||
(`gitea-workflow`). The portable implementation lives at:
|
||||
|
||||
**[`../llm-project-workflow/SKILL.md`](../llm-project-workflow/SKILL.md)**
|
||||
|
||||
## Required action
|
||||
|
||||
1. Open and follow `skills/llm-project-workflow/SKILL.md` (router).
|
||||
2. Load the matching workflow under `skills/llm-project-workflow/workflows/`.
|
||||
3. Do not mutate git/Gitea until the workflow is loaded.
|
||||
|
||||
## Multi-runtime names (must resolve identically)
|
||||
|
||||
| Name | Role |
|
||||
|------|------|
|
||||
| `gitea-workflow` | Canonical controller / Codex skill name |
|
||||
| `llm-project-workflow` | Portable in-repo skill package |
|
||||
| `git-pr-workflows` | Legacy alias |
|
||||
|
||||
Install for Codex:
|
||||
|
||||
```bash
|
||||
./scripts/install-codex-workflow-skill.sh
|
||||
```
|
||||
|
||||
Preflight via MCP: `mcp_check_workflow_skill_preflight`.
|
||||
@@ -6,6 +6,8 @@ description: >-
|
||||
report schema. Use at the start of any implementation, review, merge,
|
||||
reconciliation, or issue-filing task.
|
||||
---
|
||||
> **Also known as:** `gitea-workflow`, `git-pr-workflows` (canonical multi-runtime names — see docs/workflow-skill-mount.md / #551).
|
||||
|
||||
|
||||
# LLM Project Workflow Skill
|
||||
|
||||
@@ -30,13 +32,44 @@ workflow file.
|
||||
mutation.
|
||||
- A nearby capability does not count.
|
||||
- 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.
|
||||
- **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
|
||||
only.
|
||||
only (see BLOCKED + DIAGNOSE rule above).
|
||||
- Final report must use the schema for the loaded workflow.
|
||||
- If a task requires a different mode, stop and produce a handoff for the
|
||||
correct workflow.
|
||||
|
||||
## Covered blocker classes (BLOCKED + DIAGNOSE must trigger for these)
|
||||
|
||||
- missing required skill or workflow guide (e.g. gitea-workflow, llm-project-workflow)
|
||||
- broken terminal/tool runner or shell spawn failure
|
||||
- MCP capability failure, reset, or deadlock (e.g. preflight state cleared)
|
||||
- wrong profile or role for the requested operation
|
||||
- dirty or misbound worktree (root checkout or non-branches/ path)
|
||||
- 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)
|
||||
- missing required MCP tool/schema or operation
|
||||
- stale or inconsistent runtime state (e.g. lease vs actual, dirty state disagreement)
|
||||
- unavailable project instructions or checked-in guides
|
||||
- any other failure of a step the current workflow or controller prompt declares "required"
|
||||
|
||||
**Prohibited unless controller authorizes in writing for this instance:** temp scripts, direct API fallback, MCP internals, direct imports, in-memory state restoration, manual bypasses, or any continuation that hides the blocker. All such cases must be reported as process/tooling defects.
|
||||
|
||||
## Mode isolation
|
||||
|
||||
A run that starts in `review-merge-pr` mode may not create process issues,
|
||||
@@ -99,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,
|
||||
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
|
||||
|
||||
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
|
||||
@@ -167,6 +236,7 @@ Ready-to-copy task prompts live in [`templates/`](templates/):
|
||||
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md)
|
||||
- [`worktree-cleanup.md`](templates/worktree-cleanup.md)
|
||||
- [`release-tag.md`](templates/release-tag.md)
|
||||
- [`canonical-state-comments.md`](templates/canonical-state-comments.md)
|
||||
|
||||
## Adapting to a project
|
||||
|
||||
@@ -184,6 +254,18 @@ Releases follow SemVer from remote `master` only, after full test suite passes.
|
||||
See [`templates/release-tag.md`](templates/release-tag.md) and
|
||||
`scripts/release-tag`.
|
||||
|
||||
## Proof: missing required workflow steps stop before mutation
|
||||
|
||||
- The llm-project-workflow router (this file) and every loaded workflow (work-issue.md, review-merge-pr.md, create-issue.md, etc.) now declare at the top: if required step/skill/tool/capability/instruction/profile/worktree binding/preflight fails, STOP, state BLOCKED, use blocked-diagnose-report.md template, only non-mutating recovery.
|
||||
- Controller prompts (start-issue.md, review-pr.md, merge-pr.md, recover-bad-state.md, etc.) and the runbooks (docs/llm-workflow-runbooks.md) explicitly require the same and prohibit unsafe fallbacks.
|
||||
- MCP guards (branches-only mutation guard #274, worktree binding #510, preflight purity, role checks, lease gates, gitea_lock_issue, etc.) plus the "prove before mutation" rules ensure that a BLOCKED state prevents git/Gitea mutations.
|
||||
- When a skill/guide/tool is missing (e.g. gitea-workflow not mounted for a runtime), the load step in the router/prompt fails the "required" check → BLOCKED + report before any gitea_* call or git command that mutates.
|
||||
- Terminal/shell failures, capability deadlocks, wrong profile, dirty/misbound worktree, root risk, guard failures, missing schema, stale state, unavailable instructions all map to the covered blocker classes and trigger the same stop + report.
|
||||
- No code path in the canonical workflows allows continuation past a declared required step without the BLOCKED report.
|
||||
- See also: Global LLM Worktree Rule, Shell Spawn Hard-Stop Rule, Identity and profile safety, Subagent Tool-Budget Guardrails, and the explicit prohibition list in Universal rules.
|
||||
|
||||
Tests / proof docs updated in this change + runbooks. Full relevant test runs (see PR handoff) pass; `git diff --check` clean. Missing-step cases are now documented to fail closed before mutation.
|
||||
|
||||
## Bootstrap Review Path (#557)
|
||||
|
||||
Self-hosted MCP workflow fixes can deadlock live review daemons. Do not bypass
|
||||
@@ -195,4 +277,3 @@ If and only if a controller posts a durable `BOOTSTRAP REVIEW AUTHORIZATION
|
||||
`docs/bootstrap-review-path.md`
|
||||
|
||||
Otherwise stop with BLOCKED + DIAGNOSE. Bootstrap never weakens normal PR gates.
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Blocker Report Template (BLOCKED + DIAGNOSE)
|
||||
|
||||
Use this exact structure whenever a required workflow step cannot be performed. Emit this report and stop. Do not proceed to mutation or fallback unless a controller explicitly authorizes an exception in writing.
|
||||
|
||||
## Required step
|
||||
<Describe the exact step, skill, tool, capability, instruction, profile, or preflight that was required. Include the canonical name and where it is defined (e.g. gitea-workflow skill, specific workflow file, gitea_xxx tool).>
|
||||
|
||||
## Observed failure
|
||||
<Exact symptom, error message, missing output, guard error, 404, schema error, dirty state, wrong profile, etc. Quote relevant output or tool response.>
|
||||
|
||||
## Expected behavior
|
||||
<What the workflow/docs/prompts say should happen. Reference the specific rule, template, or preflight that requires this step.>
|
||||
|
||||
## Checks performed
|
||||
- List every verification attempted (e.g. gitea_whoami, resolve_task_capability, ls skills/, git status, mcp_list_*, worktree list, etc.)
|
||||
- Note any discrepancies found (e.g. skill not mounted for this runtime, capability not in profile, cwd not under branches/, etc.)
|
||||
|
||||
## Safe recovery attempted
|
||||
- Only non-mutating actions (reads, lists, views, status, whoami, resolve, fetch --dry, etc.)
|
||||
- List what was tried and the result.
|
||||
- If no safe recovery possible, state that explicitly.
|
||||
|
||||
## Likely classification
|
||||
Choose one or more:
|
||||
- missing required skill or workflow guide
|
||||
- broken terminal/tool runner
|
||||
- MCP capability failure or deadlock
|
||||
- wrong profile or role
|
||||
- dirty or misbound worktree
|
||||
- root checkout mutation risk
|
||||
- mutation guard failure
|
||||
- missing required MCP tool/schema
|
||||
- stale or inconsistent runtime state
|
||||
- unavailable project instructions
|
||||
- other: <describe>
|
||||
|
||||
## Durable fix recommendation
|
||||
<Specific, actionable recommendation that fixes the root process/tooling issue (e.g. "Mount gitea-workflow skill for Codex under canonical name in ~/.codex/skills/", "Add preflight in llm-project-workflow/SKILL.md that hard-stops before any gitea_ call if X is unavailable", "Update controller prompt to require BLOCKED + this report before any fallback", "Grant capability in profile config", etc.). Do not suggest temp workarounds.>
|
||||
|
||||
## Mutation occurred?
|
||||
- No (preferred and required unless explicitly authorized)
|
||||
- Yes — describe exactly what was mutated and why it was unavoidable after diagnosis. (This should be rare and will trigger additional review.)
|
||||
|
||||
## Single next action
|
||||
<One concrete next step for the current actor (e.g. "Controller to approve or reject recovery", "File follow-up issue #XXX for skill mounting", "Re-launch session from clean branches/ worktree after skill installed", "Stop and wait for profile update").>
|
||||
|
||||
---
|
||||
|
||||
**Rule reminder (do not bypass):**
|
||||
If the required step is unavailable, you are BLOCKED. Diagnose using this template. Report. Stop. Unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) are prohibited unless a controller has authorized them for this specific instance in a prior handoff.
|
||||
|
||||
This report must appear in the final output / handoff before any further action.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Blocked review submission handoff
|
||||
|
||||
Use when `gitea_submit_pr_review` or the terminal mutation gate blocks review
|
||||
submission.
|
||||
|
||||
```text
|
||||
## Blocked Review Submission Handoff
|
||||
|
||||
- Tool called: gitea_submit_pr_review
|
||||
- Mutation attempted: yes
|
||||
- Mutation rejected: yes
|
||||
- No server-side state changed: confirmed
|
||||
- Proof/source: <paste exact tool error or gate reason>
|
||||
- Prior terminal mutation that consumed budget: <exact prior mutation or none>
|
||||
- Review decision (local intent): <approve | request_changes | comment only>
|
||||
- Server-side final decision marked: <yes with tool proof | no>
|
||||
- Gitea review submitted: no
|
||||
- Gitea review blocked because: <exact gate/error>
|
||||
- Review mutations: none (submission blocked)
|
||||
- MCP/Gitea mutations: <only mutations that actually occurred>
|
||||
- Safe next action: rewrite handoff with consistent ledger before posting
|
||||
```
|
||||
@@ -0,0 +1,148 @@
|
||||
# Template: canonical state comments
|
||||
|
||||
Use these only for workflow-changing comments. Casual discussion does not need
|
||||
the full template.
|
||||
|
||||
## Issue
|
||||
|
||||
```text
|
||||
## Canonical Issue State
|
||||
|
||||
STATE:
|
||||
<ready-for-author | in-progress | blocked | PR-open | needs-review | ready-to-merge | merged | closed | superseded>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<controller | author | reviewer | merger | reconciler | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<specific one-sentence action>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next role>
|
||||
|
||||
WHAT_HAPPENED:
|
||||
<latest meaningful event>
|
||||
|
||||
WHY:
|
||||
<decision rationale>
|
||||
|
||||
RELATED_DISCUSSION:
|
||||
<link/reference or none>
|
||||
|
||||
RELATED_PRS:
|
||||
- #...
|
||||
|
||||
BRANCH:
|
||||
<branch or none>
|
||||
|
||||
HEAD_SHA:
|
||||
<40-character SHA or none>
|
||||
|
||||
VALIDATION:
|
||||
<tests/proofs or none>
|
||||
|
||||
BLOCKERS:
|
||||
<blocker and unblock condition, or none>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
|
||||
## PR
|
||||
|
||||
```text
|
||||
## Canonical PR State
|
||||
|
||||
STATE:
|
||||
<needs-review | changes-requested | approved | stale-approval | ready-to-merge | merged | blocked | superseded>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<controller | author | reviewer | merger | reconciler | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<specific one-sentence action>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next role>
|
||||
|
||||
WHAT_HAPPENED:
|
||||
<latest meaningful event>
|
||||
|
||||
WHY:
|
||||
<decision rationale>
|
||||
|
||||
ISSUE:
|
||||
#...
|
||||
|
||||
BASE:
|
||||
<branch>
|
||||
|
||||
HEAD:
|
||||
<branch>
|
||||
|
||||
HEAD_SHA:
|
||||
<40-character SHA>
|
||||
|
||||
REVIEW_STATUS:
|
||||
<none | approved | changes-requested | stale | contaminated>
|
||||
|
||||
VALIDATION:
|
||||
<tests/proofs>
|
||||
|
||||
BLOCKERS:
|
||||
<blockers or none>
|
||||
|
||||
SUPERSEDES:
|
||||
<PRs or none>
|
||||
|
||||
SUPERSEDED_BY:
|
||||
<PR or none>
|
||||
|
||||
MERGE_READY:
|
||||
<yes/no and why>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
|
||||
## Discussion
|
||||
|
||||
```text
|
||||
## Canonical Discussion Summary
|
||||
|
||||
STATE:
|
||||
<needs-more-discussion | ready-for-issues | issues-created | closed>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<controller | author | reviewer | user>
|
||||
|
||||
DECISION:
|
||||
<what was decided>
|
||||
|
||||
WHY:
|
||||
<reasoning and tradeoffs>
|
||||
|
||||
SUBSTANTIVE_COMMENTS:
|
||||
<count and summary>
|
||||
|
||||
ISSUES_TO_CREATE_OR_CREATED:
|
||||
- #...
|
||||
|
||||
DEPENDENCY_ORDER:
|
||||
<order or none>
|
||||
|
||||
NON_GOALS:
|
||||
<non-goals>
|
||||
|
||||
OPEN_QUESTIONS:
|
||||
<questions or none>
|
||||
|
||||
NEXT_ACTION:
|
||||
<specific one-sentence action>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next role>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# Template: Canonical Thread Handoff (CTH)
|
||||
|
||||
Copy, fill the fields, and post as an issue or PR comment.
|
||||
|
||||
```text
|
||||
## CTH: <Type>
|
||||
|
||||
Status: <current workflow state>
|
||||
Next owner: <author|reviewer|merger|controller|operator>
|
||||
Current blocker: <none or exact blocker>
|
||||
Decision: <what was decided this session>
|
||||
Proof: <commands, SHAs, gate outputs, or explicit pending proof>
|
||||
Next action: <one concrete step for the next actor>
|
||||
Ready-to-paste prompt: <full prompt the next session should paste>
|
||||
```
|
||||
|
||||
Allowed `<Type>` values:
|
||||
|
||||
- State Handoff
|
||||
- Controller Decision
|
||||
- Author Handoff
|
||||
- Reviewer Handoff
|
||||
- Merger Handoff
|
||||
- Supersession Notice
|
||||
- Blocker
|
||||
|
||||
Rules:
|
||||
|
||||
- Find the latest CTH comment in the thread before starting work.
|
||||
- Treat the latest valid CTH as the current source of truth.
|
||||
- Post a new CTH when you finish, block, skip, supersede, approve, request
|
||||
changes, or hand off.
|
||||
- A CTH summarizes workflow state; formal Gitea review verdicts remain
|
||||
authoritative for merge gates.
|
||||
|
||||
Full protocol: `docs/canonical-thread-handoff.md`
|
||||
@@ -1,4 +1,4 @@
|
||||
# Template: merge a PR (eligible reviewer only)
|
||||
# Template: merge a PR (eligible merger only)
|
||||
|
||||
Copy, fill the `<...>` fields, and paste as the task prompt.
|
||||
|
||||
@@ -10,12 +10,17 @@ Load the canonical workflow first:
|
||||
Final report schema: `schemas/review-merge-final-report.md`.
|
||||
|
||||
Rules (llm-project-workflow):
|
||||
- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, lease, profile/role, capability, worktree under branches/, preflight, tool, instruction, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback.
|
||||
- Find the latest CTH comment on the PR/issue thread before starting work.
|
||||
Post a new CTH: Merger Handoff (or CTH: Blocker) at session end.
|
||||
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
|
||||
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
|
||||
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
|
||||
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
|
||||
and is blocked when it disagrees with the local git remote URL.
|
||||
- Only an eligible, NON-author reviewer merges. If authenticated user == PR
|
||||
- Only an eligible, NON-author merger merges. If authenticated user == PR
|
||||
author → STOP.
|
||||
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
|
||||
- No force-merge, no bypassing branch protections.
|
||||
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
|
||||
@@ -28,22 +33,34 @@ Steps:
|
||||
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
|
||||
2. Verify authenticated identity + active profile.
|
||||
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
|
||||
4. Capability evidence (#179): cite the exact gitea_resolve_task_capability
|
||||
4. **PR sync assess (required):** call `gitea_assess_pr_sync_status` with
|
||||
explicit `remote`/`org`/`repo`. Route on `recommended_next_action`:
|
||||
- `merge_now` → continue merger path (do **not** update the branch)
|
||||
- `update_branch_by_merge` → STOP; hand off to **author** for
|
||||
`gitea_update_pr_branch_by_merge` (pins expected PR head + base head)
|
||||
- `author_conflict_remediation` → STOP; author worktree conflict fix
|
||||
- `fresh_review_required` → STOP; independent re-review at current head
|
||||
- `blocked` → STOP and diagnose
|
||||
Never merge on a former-head approval after update/remediation.
|
||||
5. Capability evidence (#179): cite the exact gitea_resolve_task_capability
|
||||
output (or runtime context) proving merge_pr is allowed — a bare
|
||||
"capability checks passed" claim is downgraded.
|
||||
5. Final live-state recheck (#179), immediately before the merge mutation —
|
||||
6. Final live-state recheck (#179), immediately before the merge mutation —
|
||||
re-read the live PR and prove:
|
||||
- PR still open
|
||||
- live head SHA still equals the pinned/reviewed head SHA
|
||||
- base branch unchanged
|
||||
- no undismissed REQUEST_CHANGES / blocking review state remains
|
||||
If any recheck fails → STOP, re-pin, re-validate.
|
||||
6. If any gate fails → STOP and report.
|
||||
7. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
||||
7. If any gate fails → STOP and report.
|
||||
8. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
||||
pinning the reviewed head SHA (expected_head_sha) and, where supported,
|
||||
the changed-file set.
|
||||
8. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
|
||||
9. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
|
||||
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
|
||||
10. **Sequential queue:** after merge, refresh live `master`, run post-merge
|
||||
cleanup handoff, then reassess the **next** PR (do not batch-update all
|
||||
open PRs).
|
||||
|
||||
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
|
||||
- Record merge mutations separately from cleanup mutations in the controller handoff.
|
||||
@@ -63,10 +80,12 @@ Then run the cleanup template (worktree-cleanup.md) in a reconciler session:
|
||||
- fetch/prune; confirm main checkout is clean and current (0 0).
|
||||
|
||||
Handoff: end with a section titled exactly `Controller Handoff` per SKILL.md
|
||||
§K (long form — a merge is always high-risk), including the review/merge role
|
||||
fields (Selected PR, Reviewer eligibility, Pinned reviewed head, Review
|
||||
§K (long form — a merge is always high-risk), including the merger role
|
||||
fields (Selected PR, Merger eligibility, Pinned reviewed head, Review
|
||||
decision, Merge result, Linked issue status, Cleanup status) plus: merge
|
||||
commit, PR metadata state/merged flag/hash, remote master hash, and the
|
||||
post-merge verification method used & verification results. Reports missing
|
||||
the handoff are downgraded (review_proofs.assess_controller_handoff).
|
||||
|
||||
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
```
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
Copy, fill the `<...>` fields, paste as the task prompt. Recovery is read-then-
|
||||
act: gather facts first, never discard unmerged work.
|
||||
|
||||
**BLOCKED + DIAGNOSE rule (llm-project-workflow):** If at any point a required step (including state recovery itself) cannot be performed, stop immediately, use the standard [`blocked-diagnose-report.md`](blocked-diagnose-report.md) template, attempt only safe non-mutating recovery, and report. Do not continue or fallback.
|
||||
|
||||
```text
|
||||
Task: recover repo state for <situation>. Do not lose unmerged work.
|
||||
|
||||
Rules (llm-project-workflow):
|
||||
- Fail closed: if state is unclear or a step would delete unmerged work, STOP.
|
||||
- BLOCKED + DIAGNOSE default: if state is unclear, a required check fails, or a step would delete unmerged work or bypass a guard, STOP and emit a full blocked-diagnose-report.md using the template. Clearly state BLOCKED. Diagnose. Only non-mutating recovery.
|
||||
- Never push master. Never discard commits not safely pushed to <remote>.
|
||||
- Prove you are in a branches/ worktree before any recovery mutation.
|
||||
|
||||
Diagnose first:
|
||||
1. git fetch <remote> --prune
|
||||
@@ -16,8 +19,19 @@ Diagnose first:
|
||||
3. git rev-list --left-right --count <remote>/master...master # ahead/behind
|
||||
4. For any PR involved: confirm state (open/closed/merged) AND whether
|
||||
<remote>/master actually contains its commits ("closed" != "merged").
|
||||
5. Check active leases, claims, and whether required skills/workflows are loaded.
|
||||
|
||||
Act per case:
|
||||
If a required diagnostic or recovery step itself is unavailable (e.g. terminal broken, skill missing, guard blocks, wrong profile), emit:
|
||||
|
||||
## Required step
|
||||
<the step>
|
||||
|
||||
## Observed failure
|
||||
<...>
|
||||
|
||||
(complete the full blocked-diagnose-report.md template)
|
||||
|
||||
Act per case (only after clean diagnosis; if blocked, use the template and stop):
|
||||
- Dirty worktree from another issue: leave it; start yours in a new worktree.
|
||||
- Local master ahead of remote: confirm the extra commits live on a branch
|
||||
pushed to <remote>, THEN git reset --hard <remote>/master. Verify with
|
||||
@@ -26,6 +40,7 @@ Act per case:
|
||||
- Branch deleted before merge: recover commits from a local branch/reflog (or
|
||||
git fsck --lost-found), re-push, reopen the PR.
|
||||
- Unauthorized untracked file: do not commit it; leave pre-existing artifacts.
|
||||
- Any blocker: use blocked-diagnose-report.md template and stop.
|
||||
|
||||
Handoff: what was wrong, evidence, action taken, current state, what remains.
|
||||
Handoff: what was wrong, evidence, action taken, current state, what remains. If BLOCKED, include the full blocker report.
|
||||
```
|
||||
|
||||
@@ -36,6 +36,10 @@ Load the canonical workflow first:
|
||||
Final report schema: `schemas/review-merge-final-report.md`.
|
||||
|
||||
Rules (llm-project-workflow):
|
||||
- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, lease, profile/role, capability, worktree under branches/, preflight, tool, instruction, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback.
|
||||
- Find the latest CTH comment on the PR/issue thread before starting work.
|
||||
Post a new CTH: Reviewer Handoff (or CTH: Blocker) at session end.
|
||||
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
|
||||
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
|
||||
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
|
||||
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
|
||||
@@ -110,8 +114,9 @@ Steps:
|
||||
- base branch unchanged
|
||||
- no undismissed REQUEST_CHANGES / blocking review state left unaccounted
|
||||
If anything moved → STOP, re-pin, re-validate before any verdict.
|
||||
10. Post the review verdict: approve only if scope is clean and checks pass;
|
||||
otherwise request changes with specifics. Never merge from this review step.
|
||||
10. Post the review verdict: approve only if scope is clean and checks pass;
|
||||
otherwise request changes with specifics. Never merge from this review step.
|
||||
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md):
|
||||
|
||||
Review Metadata:
|
||||
@@ -128,6 +133,8 @@ Pinned reviewed head, Review decision, Merge result, Linked issue status,
|
||||
Cleanup status. If you could not merge, name the exact gate. Reports missing
|
||||
the handoff are downgraded (review_proofs.assess_controller_handoff).
|
||||
|
||||
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
|
||||
Baseline failure proof (#533): if a validation command exits non-zero, do NOT
|
||||
call it a clean pass. Only label a failure "baseline"/"pre-existing" with
|
||||
pre-merge proof — the failure reproduced on the PR's pre-merge base commit, or a
|
||||
|
||||
@@ -10,6 +10,10 @@ Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report
|
||||
Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue)
|
||||
|
||||
Rules (llm-project-workflow):
|
||||
- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, acquire lease, prove worktree under branches/, capability, profile, tool, instruction, preflight, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback.
|
||||
- Find the latest CTH comment on the issue/PR thread before starting work.
|
||||
Post a new CTH: Author Handoff (or CTH: Blocker) at session end.
|
||||
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
|
||||
- No repo changes without a tracking issue. If none exists, create one first;
|
||||
if it can't be created, stop.
|
||||
- Work only in an isolated branch worktree under branches/. The main checkout
|
||||
|
||||
@@ -12,6 +12,8 @@ This file is the canonical issue-creation workflow for Gitea-Tools. Load it
|
||||
before any issue mutation. Final report schema:
|
||||
[`schemas/create-issue-final-report.md`](../schemas/create-issue-final-report.md).
|
||||
|
||||
**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.
|
||||
|
||||
**Default task prompt:**
|
||||
|
||||
> Create or update Gitea issues in this project only if every identity,
|
||||
@@ -342,6 +344,41 @@ If edits are needed, make the smallest correction necessary and report the corre
|
||||
|
||||
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
|
||||
|
||||
Apply labels, assignees, milestones, or project fields only if:
|
||||
|
||||
@@ -12,6 +12,10 @@ This file is the canonical PR review/merge workflow for Gitea-Tools. Load it
|
||||
before any PR mutation. Final report schema:
|
||||
[`schemas/review-merge-final-report.md`](../schemas/review-merge-final-report.md).
|
||||
|
||||
**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:**
|
||||
|
||||
> Review the next eligible open PR in this project. Merge it only if every
|
||||
@@ -26,6 +30,12 @@ workflow rules exactly.
|
||||
|
||||
## 0. Load the canonical workflow first
|
||||
|
||||
Before starting PR work, **find the latest CTH comment** on the PR or linked
|
||||
issue thread. Treat that CTH as the current handoff state. Post a new
|
||||
**CTH: Reviewer Handoff**, **CTH: Merger Handoff**, **CTH: Blocker**, or
|
||||
**CTH: Supersession Notice** when you finish, block, skip, supersede,
|
||||
approve, request changes, or hand off. See `docs/canonical-thread-handoff.md`.
|
||||
|
||||
Before starting PR work, check whether the project provides a canonical PR review/merge workflow through a project skill, runbook, or MCP helper.
|
||||
|
||||
If available, load it first and report:
|
||||
@@ -818,6 +828,75 @@ The final report must identify:
|
||||
* whether same-PR merge continuation was allowed
|
||||
* 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)
|
||||
|
||||
Parallel reviewer sessions are allowed only when each session holds a distinct,
|
||||
@@ -871,9 +950,53 @@ Final reports must state:
|
||||
* final live head SHA before merge
|
||||
* whether any push occurred during validation
|
||||
|
||||
## 26D. PR synchronization and conflict-remediation lifecycle
|
||||
|
||||
**Do not treat “approved” as the final readiness state.** For every approved
|
||||
open PR, call `gitea_assess_pr_sync_status` (native MCP only) and route by
|
||||
`recommended_next_action`:
|
||||
|
||||
| Action | Meaning | Next role / tools |
|
||||
|--------|---------|-------------------|
|
||||
| `merge_now` | Valid approval at exact current head; conflict-free; update not required; checks ok | Merger: sanctioned merge workflow only — **do not** update the branch |
|
||||
| `update_branch_by_merge` | Behind live base; protection requires current base; Gitea can merge base without conflicts | **Author only:** `gitea_update_pr_branch_by_merge` with pinned `expected_pr_head_sha` + `expected_base_head_sha` |
|
||||
| `author_conflict_remediation` | Conflicts / not auto-updatable | **Author only:** existing `branches/` worktree, issue lock, merge master, resolve, test, push; never force-push/rebase |
|
||||
| `fresh_review_required` | Head changed after approval, or update/remediation produced a new head | Independent reviewer at the **new exact head**; old approvals/leases/verdicts are void |
|
||||
| `blocked` | Other gate (checks, incomplete facts, closed PR, …) | Diagnose; do not merge or update |
|
||||
|
||||
### Hard rules
|
||||
|
||||
* Pin both expected PR head and expected base head for every update. Either
|
||||
race → fail closed with no partial mutation.
|
||||
* Never rebase or force-push author branches.
|
||||
* Never update an author branch from a reviewer or merger profile.
|
||||
* Never preserve approval, reviewer lease, merger lease, or prepared verdict
|
||||
across a head change.
|
||||
* Process PRs **sequentially**: synchronize/remediate one → review new head →
|
||||
merge → refresh live `master` → reassess the next PR. Do not update every
|
||||
open PR at once (each merge restales the rest).
|
||||
* Conflict remediation only in the existing dedicated issue/PR worktree under
|
||||
`branches/`. Do not delete that worktree until the updated PR is merged and
|
||||
cleanup eligibility is proven.
|
||||
* Post-merge: canonical cleanup handoff to reconciler (section 28).
|
||||
|
||||
### After `gitea_update_pr_branch_by_merge` succeeds
|
||||
|
||||
1. Treat former-head approval as invalidated.
|
||||
2. Release/supersede obsolete reviewer and merger leases.
|
||||
3. Route `recommended_next_action=fresh_review_required` at the new head.
|
||||
4. Independent re-review, then merger lease/adopt + merge at the new head only.
|
||||
|
||||
All Gitea reads/mutations use native MCP. Never substitute direct API, curl,
|
||||
tea/gh, Web UI mutation by an LLM, database changes, raw Git push, or token
|
||||
access.
|
||||
|
||||
## 27. Merge rules
|
||||
|
||||
Before merge, rerun fresh live checks:
|
||||
Before merge, call `gitea_assess_pr_sync_status` when the PR is approved/open
|
||||
and may be behind base. Only proceed with merge when
|
||||
`recommended_next_action` is `merge_now` and approval remains at the current
|
||||
head. Then rerun fresh live checks:
|
||||
|
||||
* whoami
|
||||
* active profile/runtime
|
||||
@@ -928,6 +1051,14 @@ Confirm:
|
||||
|
||||
Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup.
|
||||
|
||||
Do not delete source branches from reviewer mode with raw git commands. Commands
|
||||
such as `git branch -d <branch>` and `git push <remote> --delete <branch>` are
|
||||
not proof of authorized cleanup and bypass MCP role/capability gates. Merged PR
|
||||
source branch cleanup must use an explicit MCP cleanup tool such as
|
||||
`gitea_cleanup_merged_pr_branch` or another approved cleanup helper with exact
|
||||
`gitea.branch.delete` authority, merged-PR proof, no open PR using the branch,
|
||||
target-branch ancestry proof, a `branches/` worktree path, and cleanup mutations
|
||||
reported separately from review mutations.
|
||||
Review, baseline, and merge-simulation worktrees created during this run are
|
||||
transient and are removed automatically at successful completion once they are
|
||||
clean, carry no open PR, and hold no active lease (#401). Use
|
||||
@@ -1027,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 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
|
||||
|
||||
Include:
|
||||
|
||||
@@ -12,6 +12,10 @@ This file is the canonical author/coder workflow for Gitea-Tools. Load it
|
||||
before any issue implementation mutation. Final report schema:
|
||||
[`schemas/work-issue-final-report.md`](../schemas/work-issue-final-report.md).
|
||||
|
||||
**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:**
|
||||
|
||||
> Find the next eligible issue in this project, work on it only if all gates
|
||||
@@ -26,6 +30,12 @@ This is an author/coder workflow. It is not a reviewer workflow.
|
||||
|
||||
## 0. Load the canonical workflow first
|
||||
|
||||
Before starting issue work, **find the latest CTH comment** on the target
|
||||
issue or linked PR thread. Treat that CTH as the current handoff state unless
|
||||
a newer authoritative gate supersedes it. Post a new **CTH: Author Handoff**
|
||||
(or `CTH: Blocker`) when you finish, block, or hand off.
|
||||
See `docs/canonical-thread-handoff.md`.
|
||||
|
||||
Before starting issue work, check whether the project provides a canonical work-on-issue workflow through a project skill, runbook, or MCP helper.
|
||||
|
||||
If available, load it first and report:
|
||||
@@ -615,10 +625,39 @@ After push, report:
|
||||
|
||||
If push fails, stop and produce a recovery handoff.
|
||||
|
||||
## 20A. Conflict-fix lease and push gate (#399)
|
||||
## 20A. Live head re-pin before conflict-fix classification (#522)
|
||||
|
||||
Open PR inventory `mergeable` / `head_sha` fields are **advisory and may be
|
||||
stale**. Before classifying a PR as conflicted or creating a conflict-fix
|
||||
worktree:
|
||||
|
||||
1. Re-fetch the live PR (`gitea_view_pr` or equivalent) and record:
|
||||
* inventory head SHA (if any)
|
||||
* inventory mergeable (if any)
|
||||
* **live** head SHA (full 40-char)
|
||||
* **live** mergeable
|
||||
2. Call `gitea_assess_conflict_fix_classification` with those values.
|
||||
3. Act only on the returned classification and **pinned live head**:
|
||||
* `stale_inventory_skip` / `live_mergeable_skip` → **do not** create a
|
||||
conflict-fix worktree; do not mutate the PR branch for conflicts.
|
||||
* `conflict_fix_needed` → conflict-fix worktree allowed for the pinned
|
||||
live head only.
|
||||
* `incomplete_live_repin` → stop; re-fetch live state.
|
||||
4. If inventory head ≠ live head, report both SHAs and use the live head only.
|
||||
5. If inventory says `mergeable:false` but live says `mergeable:true`, classify
|
||||
as stale inventory and skip author conflict-fix mutation.
|
||||
|
||||
Conflict-fix final reports that claim conflict-fix work must include:
|
||||
|
||||
* citation of `gitea_assess_conflict_fix_classification`
|
||||
* `Live head SHA: <40-char hex>` (or Pinned head SHA / Live PR head SHA)
|
||||
* `Conflict-fix classification: <stale_inventory_skip|conflict_fix_needed|live_mergeable_skip|incomplete_live_repin>`
|
||||
|
||||
## 20B. Conflict-fix lease and push gate (#399)
|
||||
|
||||
When pushing to an existing PR branch to resolve merge conflicts:
|
||||
|
||||
0. Complete §20A live-head re-pin / classification first.
|
||||
1. Call `gitea_acquire_conflict_fix_lease` before any push.
|
||||
2. Call `gitea_assess_conflict_fix_push` immediately before `git push` with:
|
||||
* branch head before push
|
||||
|
||||
@@ -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,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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user