Compare commits
265
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0fffae576 | ||
|
|
ae6a3ae00c | ||
|
|
2fde92ad25 | ||
|
|
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 | ||
|
|
ffa1ff95cc | ||
|
|
95f77bb648 | ||
|
|
08957aeb2a | ||
|
|
4d865666c7 | ||
|
|
d9cf26ddbf | ||
|
|
95ec891bd2 | ||
|
|
72ae320400 | ||
|
|
f04f61058f | ||
|
|
b71dfe9696 | ||
|
|
a82d35f8bd | ||
|
|
066eec8477 | ||
|
|
f29df56a99 | ||
|
|
8e9e4670c6 | ||
|
|
e4f1a52a87 | ||
|
|
a2b7327447 | ||
|
|
0a202970fd | ||
|
|
631620d264 | ||
|
|
7fb813446f | ||
|
|
f6b2adac4d | ||
|
|
719de6fb7a | ||
|
|
0ee89fe347 | ||
|
|
7a0de4a8e9 | ||
|
|
4e019f1a59 | ||
|
|
4bcdd350f6 | ||
|
|
e0c583db75 | ||
|
|
32f1f3486f | ||
|
|
32fc1719aa | ||
|
|
8ff1924047 | ||
|
|
84d921a52f | ||
|
|
44cf2a4ce8 | ||
|
|
22d4735170 | ||
|
|
87d6532cb3 | ||
|
|
d663114976 | ||
|
|
565363d5f4 | ||
|
|
7ba469f292 | ||
|
|
d57ca94ec5 | ||
|
|
9a2e585a9e | ||
|
|
add87b5708 | ||
|
|
81822da04d | ||
|
|
05ea99cd32 | ||
|
|
7c3699ce2d | ||
|
|
c3452ce866 | ||
|
|
97b3c6573b | ||
|
|
51e226e8ba | ||
|
|
09d8db5b9e | ||
|
|
e97b1d3642 | ||
|
|
b12212493c | ||
|
|
b7e0c537f3 | ||
|
|
2a3d30fb3a | ||
|
|
1ffa911dbc | ||
|
|
5ad764b359 | ||
|
|
0e791325f8 | ||
|
|
826d632173 | ||
|
|
9d86fc44e4 | ||
|
|
8db26c9a15 | ||
|
|
ae9ed8a908 | ||
|
|
f1449ff5c8 | ||
|
|
08bcc03f17 | ||
|
|
6e589e73ea | ||
|
|
8c171fb47b | ||
|
|
18e7d7b6e1 | ||
|
|
bd22135906 | ||
|
|
78fce0dbb6 | ||
|
|
fd2bc018ff | ||
|
|
daf9910e83 | ||
|
|
535da1bc5a | ||
|
|
dfbee45309 | ||
|
|
a4e014e62b | ||
|
|
eeadb1bbea | ||
|
|
c64809134f | ||
|
|
749ca04388 | ||
|
|
ea51b0196a | ||
|
|
cc63ab74e6 | ||
|
|
9438855fdd | ||
|
|
cfce823dd7 | ||
|
|
7231f27c1c | ||
|
|
e27b6ddefb | ||
|
|
cefebd7643 | ||
|
|
3d540f6fba | ||
|
|
2bb45c0f80 | ||
|
|
0b62d4e06a | ||
|
|
3550262613 | ||
|
|
59f2de9711 | ||
|
|
c0b1f562a9 | ||
|
|
560cfa8f47 | ||
|
|
3b4f222a7d | ||
|
|
a2aea5357d | ||
|
|
7d5c7df82f | ||
|
|
08ea214526 | ||
|
|
6b45212659 | ||
|
|
ccdff1df27 | ||
|
|
af938064cd | ||
|
|
2faf18a802 | ||
|
|
236c8b779f | ||
|
|
564fee9639 | ||
|
|
d56baa635e | ||
|
|
c54ac0d4de | ||
|
|
b7755f26e3 | ||
|
|
3cb05284b3 | ||
|
|
4d24c34548 | ||
|
|
4561d7ad12 | ||
|
|
9e29d00292 | ||
|
|
3d11e1f12b | ||
|
|
182fcbf598 | ||
|
|
ce61e424f6 | ||
|
|
96fe077292 | ||
|
|
f89949cea9 | ||
|
|
3b499cec2a | ||
|
|
eff4572a91 | ||
|
|
b3edd53185 | ||
|
|
25cf323d14 | ||
|
|
429faccd08 | ||
|
|
9ada4762c5 | ||
|
|
33d01ba65e | ||
|
|
3bce0a55fb | ||
|
|
afb4ba563c | ||
|
|
9235f3a23c | ||
|
|
d50328aea4 | ||
|
|
48cf3a334b | ||
|
|
974463163d | ||
|
|
cedf451a2b | ||
|
|
be592d6d4d | ||
|
|
0cbd1fc801 | ||
|
|
673cb77004 | ||
|
|
d6b1e4be3c | ||
|
|
28f0f84de8 | ||
|
|
76d1417c28 | ||
|
|
685e627984 | ||
|
|
08202f7eaa | ||
|
|
a863928661 | ||
|
|
7af73e1539 | ||
|
|
8f866ae753 | ||
|
|
ae0478bf54 | ||
|
|
cc5686b896 | ||
|
|
d4dc9aa854 | ||
|
|
3496fc3952 | ||
|
|
89582a0f2a | ||
|
|
831183308d | ||
|
|
a1de4ab8f9 | ||
|
|
3c41d2af5c | ||
|
|
d306c0a5ec | ||
|
|
85532059e2 | ||
|
|
54a75153a9 | ||
|
|
5dfc00319f | ||
|
|
f51dc95153 | ||
|
|
b15494deb9 | ||
|
|
55b3658d93 | ||
|
|
c7b462a034 | ||
|
|
2c47208b0d | ||
|
|
e956040b6b | ||
|
|
43a17a7e8e | ||
|
|
d042a9ca24 | ||
|
|
f061fee18d | ||
|
|
551ee7d70c | ||
|
|
ddaf380db2 | ||
|
|
8b551c565f | ||
|
|
66d37dd3cb | ||
|
|
44a7c62dc3 | ||
|
|
d5dc9f2708 | ||
|
|
3e4b721d60 | ||
|
|
1830360850 | ||
|
|
06c2069234 | ||
|
|
b33844d74a | ||
|
|
84b841c727 | ||
|
|
09e83fd034 | ||
|
|
276a71bd1a | ||
|
|
0e701d578a | ||
|
|
a9265efa82 | ||
|
|
5512ba7710 | ||
|
|
934688a71d | ||
|
|
1320a55a7e | ||
|
|
87beb44394 | ||
|
|
0d41760ea3 | ||
|
|
b9e5cd0f57 | ||
|
|
db032ef93c | ||
|
|
8a8cffbdf5 |
@@ -46,3 +46,12 @@ GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
||||
# profile's values. Leave unset for pure env-based configuration.
|
||||
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
|
||||
GITEA_MCP_PROFILE=prgs
|
||||
|
||||
# Namespace-scoped active task workspaces (#510). Each MCP namespace uses only
|
||||
# its own role env var; foreign bindings (e.g. GITEA_AUTHOR_WORKTREE in a
|
||||
# merger process) are ignored.
|
||||
# GITEA_AUTHOR_WORKTREE=/path/to/repo/branches/issue-123-work
|
||||
# GITEA_REVIEWER_WORKTREE=/path/to/repo/branches/review-pr456
|
||||
# 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
|
||||
|
||||
@@ -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,344 @@
|
||||
"""Audit vs cleanup phase gates for reconciliation workflows (#419).
|
||||
|
||||
Audit/reconciliation tasks are read-only unless a separate cleanup phase is
|
||||
explicitly authorized with exact capability proof, safety proof, and
|
||||
before/after snapshots. Cleanup mutations must be classified in final reports;
|
||||
audit reports must not claim ``no mutations`` when cleanup occurred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
RECONCILE_WORKFLOW_PATH = "workflows/reconcile-landed-pr.md"
|
||||
|
||||
PHASE_AUDIT = "audit"
|
||||
PHASE_CLEANUP = "cleanup"
|
||||
|
||||
# Tasks that enter audit phase on capability resolution (read-only default).
|
||||
AUDIT_PHASE_TASKS = frozenset({
|
||||
"reconcile-landed-pr",
|
||||
"reconcile_landed_pr",
|
||||
"reconcile_issue_claims",
|
||||
"reconcile_merged_cleanups",
|
||||
})
|
||||
|
||||
# Mutation tasks forbidden during audit phase (fail closed).
|
||||
AUDIT_FORBIDDEN_TASKS = frozenset({
|
||||
"delete_branch",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"claim_issue",
|
||||
"close_pr",
|
||||
"close_issue",
|
||||
"create_issue",
|
||||
"merge_pr",
|
||||
"review_pr",
|
||||
"submit_pr_review",
|
||||
"comment_pr",
|
||||
"comment_issue",
|
||||
"set_issue_labels",
|
||||
})
|
||||
|
||||
# Shell/git commands audit phase must not run.
|
||||
AUDIT_FORBIDDEN_COMMAND_RE = re.compile(
|
||||
r"(?:^|\s)(?:git\s+(?:push|branch\s+-D|worktree\s+remove)|"
|
||||
r"gitea_delete_branch|delete_remote_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_NO_MUTATIONS_RE = re.compile(
|
||||
r"(?:no\s+mutations|mutations\s*:\s*none|no\s+unsafe\s+mutation)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_OCCURRED_RE = re.compile(
|
||||
r"(?:delete_remote_branch|remove_local_worktree|git\s+branch\s+-D|"
|
||||
r"git\s+worktree\s+remove|remote branch.*deleted|worktree.*removed|"
|
||||
r"cleanup\s+phase\s*:\s*(?!none\b)\S)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXTERNAL_STATE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*external[- ]state mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_GIT_REF_RE = re.compile(
|
||||
r"^\s*[-*]?\s*git ref mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_PHASE_AUTH_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup phase (?:authorized|authorization)\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"^\s*[-*]?\s*delete.?branch capability(?: proven)?\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BEFORE_AFTER_RE = re.compile(
|
||||
r"^\s*[-*]?\s*before/after (?:state )?snapshot\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_SAFETY_PROOF_RE = re.compile(
|
||||
r"^\s*[-*]?\s*(?:branch|worktree) safe to remove\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_session: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _blank_session() -> dict[str, Any]:
|
||||
return {
|
||||
"phase": PHASE_AUDIT,
|
||||
"entered_from_task": None,
|
||||
"cleanup_authorized": False,
|
||||
"cleanup_authorization": {},
|
||||
}
|
||||
|
||||
|
||||
def current_phase() -> str | None:
|
||||
"""Return active reconciliation phase or None when unset."""
|
||||
if not _session:
|
||||
return None
|
||||
return _session.get("phase")
|
||||
|
||||
|
||||
def active_record() -> dict[str, Any] | None:
|
||||
"""Return a copy of the session record, if any."""
|
||||
return dict(_session) if _session else None
|
||||
|
||||
|
||||
def clear_phase() -> None:
|
||||
"""Clear reconciliation phase state."""
|
||||
global _session
|
||||
_session = None
|
||||
|
||||
|
||||
def enter_audit_phase(task: str) -> dict[str, Any]:
|
||||
"""Enter read-only audit phase for a reconciliation task."""
|
||||
global _session
|
||||
normalized = (task or "").strip().lower()
|
||||
_session = _blank_session()
|
||||
_session["entered_from_task"] = normalized
|
||||
return dict(_session)
|
||||
|
||||
|
||||
def authorize_cleanup_phase(
|
||||
*,
|
||||
operator_approved: bool = False,
|
||||
workflow_authorized: bool = False,
|
||||
delete_capability_proven: bool = False,
|
||||
safety_proof: dict[str, Any] | None = None,
|
||||
before_after_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Authorize cleanup phase after explicit approval and safety proofs."""
|
||||
reasons: list[str] = []
|
||||
if not (operator_approved or workflow_authorized):
|
||||
reasons.append(
|
||||
"cleanup phase requires operator approval or explicit workflow "
|
||||
"authorization"
|
||||
)
|
||||
if not delete_capability_proven:
|
||||
reasons.append(
|
||||
"cleanup phase requires exact delete_branch capability proof "
|
||||
"(gitea.branch.delete)"
|
||||
)
|
||||
safety = dict(safety_proof or {})
|
||||
if not safety.get("safe_to_delete_remote") and not safety.get(
|
||||
"safe_to_remove_worktree"
|
||||
):
|
||||
reasons.append(
|
||||
"cleanup phase requires proof that branch/worktree is safe to remove"
|
||||
)
|
||||
snapshot = dict(before_after_snapshot or {})
|
||||
if not snapshot.get("before") or not snapshot.get("after"):
|
||||
reasons.append(
|
||||
"cleanup phase requires before/after state snapshot"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"authorized": False,
|
||||
"phase": current_phase() or PHASE_AUDIT,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"remain in audit-only mode or supply operator approval, "
|
||||
"delete_branch capability proof, safety proof, and "
|
||||
"before/after snapshot before cleanup"
|
||||
),
|
||||
}
|
||||
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = _blank_session()
|
||||
_session["phase"] = PHASE_CLEANUP
|
||||
_session["cleanup_authorized"] = True
|
||||
_session["cleanup_authorization"] = {
|
||||
"operator_approved": operator_approved,
|
||||
"workflow_authorized": workflow_authorized,
|
||||
"delete_capability_proven": delete_capability_proven,
|
||||
"safety_proof": safety,
|
||||
"before_after_snapshot": snapshot,
|
||||
}
|
||||
return {
|
||||
"authorized": True,
|
||||
"phase": PHASE_CLEANUP,
|
||||
"reasons": [],
|
||||
"cleanup_authorization": dict(_session["cleanup_authorization"]),
|
||||
"safe_next_action": "proceed with authorized cleanup mutations only",
|
||||
}
|
||||
|
||||
|
||||
def check_audit_task_enters_phase(task: str) -> bool:
|
||||
"""Return whether resolving *task* should enter audit phase."""
|
||||
return (task or "").strip().lower() in AUDIT_PHASE_TASKS
|
||||
|
||||
|
||||
def check_audit_mutation_allowed(task: str) -> tuple[bool, list[str]]:
|
||||
"""Fail closed when a mutation task runs during audit phase."""
|
||||
normalized = (task or "").strip().lower()
|
||||
phase = current_phase()
|
||||
if phase != PHASE_AUDIT:
|
||||
return True, []
|
||||
if normalized in AUDIT_FORBIDDEN_TASKS:
|
||||
return False, [
|
||||
f"task '{normalized}' is forbidden in audit-only reconciliation "
|
||||
"mode: switch to an explicit cleanup phase with operator approval "
|
||||
"and exact delete_branch capability proof before cleanup mutations"
|
||||
]
|
||||
return True, []
|
||||
|
||||
|
||||
def check_cleanup_execution_allowed() -> tuple[bool, list[str]]:
|
||||
"""Fail closed when cleanup execution is attempted without authorization."""
|
||||
phase = current_phase()
|
||||
if phase == PHASE_CLEANUP and (_session or {}).get("cleanup_authorized"):
|
||||
return True, []
|
||||
if phase is None:
|
||||
return False, [
|
||||
"cleanup execution requires an active reconciliation session; "
|
||||
"resolve a reconciliation audit task first"
|
||||
]
|
||||
return False, [
|
||||
"cleanup execution forbidden in audit-only reconciliation mode; "
|
||||
"call gitea_authorize_reconciliation_cleanup_phase with operator "
|
||||
"approval, delete_branch capability proof, safety proof, and "
|
||||
"before/after snapshot"
|
||||
]
|
||||
|
||||
|
||||
def classify_cleanup_mutation(action: str) -> str:
|
||||
"""Map a cleanup action to the required mutation ledger category (#419)."""
|
||||
normalized = (action or "").strip().lower()
|
||||
if "delete_remote" in normalized or normalized in {
|
||||
"delete_branch",
|
||||
"gitea_delete_branch",
|
||||
}:
|
||||
return "external-state"
|
||||
if "branch" in normalized and "delete" in normalized:
|
||||
return "git-ref"
|
||||
if "worktree" in normalized or "remove_local" in normalized:
|
||||
return "cleanup"
|
||||
return "cleanup"
|
||||
|
||||
|
||||
def assess_audit_reconciliation_report(report_text: str) -> dict[str, Any]:
|
||||
"""Validate audit/cleanup reconciliation reports (fail closed)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
cleanup_occurred = bool(_CLEANUP_OCCURRED_RE.search(text))
|
||||
claims_no_mutations = bool(_NO_MUTATIONS_RE.search(text))
|
||||
|
||||
if cleanup_occurred and claims_no_mutations:
|
||||
reasons.append(
|
||||
"report claims no mutations but documents cleanup mutations; "
|
||||
"audit-only reports must not perform cleanup and cleanup reports "
|
||||
"must not claim no mutations"
|
||||
)
|
||||
|
||||
if cleanup_occurred:
|
||||
if not _CLEANUP_PHASE_AUTH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without "
|
||||
"'Cleanup phase authorized: true'"
|
||||
)
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without delete_branch capability "
|
||||
"proof"
|
||||
)
|
||||
if not _BEFORE_AFTER_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without before/after state snapshot"
|
||||
)
|
||||
if not _SAFETY_PROOF_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without branch/worktree safety proof"
|
||||
)
|
||||
|
||||
if re.search(r"delete_remote|remote branch.*delet", text, re.I):
|
||||
if not _EXTERNAL_STATE_RE.search(text):
|
||||
reasons.append(
|
||||
"remote branch deletion must be classified under "
|
||||
"External-state mutations"
|
||||
)
|
||||
if re.search(r"git\s+branch\s+-D|local branch.*delet", text, re.I):
|
||||
if not _GIT_REF_RE.search(text):
|
||||
reasons.append(
|
||||
"local branch deletion must be classified under "
|
||||
"Git ref mutations"
|
||||
)
|
||||
if re.search(r"worktree.*remov|remove_local_worktree", text, re.I):
|
||||
if not _CLEANUP_MUTATIONS_RE.search(text):
|
||||
reasons.append(
|
||||
"worktree removal must be classified under Cleanup mutations"
|
||||
)
|
||||
|
||||
if (
|
||||
RECONCILE_WORKFLOW_PATH.replace("workflows/", "") in text
|
||||
or "reconcile-landed-pr" in text.lower()
|
||||
):
|
||||
if cleanup_occurred and "audit phase" in text.lower():
|
||||
if "cleanup phase" not in text.lower():
|
||||
reasons.append(
|
||||
"report mixes audit phase with cleanup mutations without "
|
||||
"documenting cleanup phase transition"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"cleanup_occurred": cleanup_occurred,
|
||||
"claims_no_mutations": claims_no_mutations,
|
||||
"safe_next_action": (
|
||||
"proceed"
|
||||
if proven
|
||||
else "fix audit/cleanup report: separate audit from cleanup phase, "
|
||||
"classify mutations, and do not claim no mutations after cleanup"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_audit_command_allowed(command: str) -> tuple[bool, list[str]]:
|
||||
"""Block shell commands that perform cleanup during audit phase."""
|
||||
phase = current_phase()
|
||||
if phase != PHASE_AUDIT:
|
||||
return True, []
|
||||
cmd = (command or "").strip()
|
||||
if AUDIT_FORBIDDEN_COMMAND_RE.search(cmd):
|
||||
return False, [
|
||||
f"command forbidden in audit-only reconciliation mode: {cmd!r}; "
|
||||
"authorize cleanup phase before branch/worktree deletion or push"
|
||||
]
|
||||
return True, []
|
||||
@@ -7,8 +7,13 @@ project's ``branches/`` directory, never from the stable control checkout.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
||||
# via namespace_workspace_binding (#510).
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
@@ -48,6 +53,130 @@ def resolve_mutation_workspace(
|
||||
return os.path.realpath(project_root)
|
||||
|
||||
|
||||
def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
|
||||
"""Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*."""
|
||||
raw = (common_dir or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
if os.path.isabs(raw):
|
||||
return os.path.realpath(raw)
|
||||
return os.path.realpath(os.path.join(workspace_path, raw))
|
||||
|
||||
|
||||
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
|
||||
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
|
||||
path = (workspace_path or "").strip()
|
||||
fallback = os.path.realpath(fallback_project_root)
|
||||
if not path:
|
||||
return fallback
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common = _realpath_git_common_dir(path, res.stdout)
|
||||
except Exception:
|
||||
return fallback
|
||||
if common.endswith(f"{os.sep}.git"):
|
||||
return os.path.dirname(common)
|
||||
if os.path.basename(common) == ".git":
|
||||
return os.path.dirname(common)
|
||||
return fallback
|
||||
|
||||
|
||||
def resolve_author_mutation_context(
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
*,
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards (#460)."""
|
||||
workspace = resolve_mutation_workspace(
|
||||
worktree_path,
|
||||
process_project_root,
|
||||
active_worktree_env=active_worktree_env,
|
||||
author_worktree_env=author_worktree_env,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
# Canonical repository identity comes from the MCP process checkout (#460),
|
||||
# not from the declared task workspace being validated.
|
||||
canonical_root = resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
"workspace_path": workspace,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
|
||||
|
||||
def assess_workspace_repo_membership(
|
||||
*,
|
||||
workspace_path: str,
|
||||
canonical_repo_root: str,
|
||||
) -> dict:
|
||||
"""Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*."""
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
reasons: list[str] = []
|
||||
|
||||
if not os.path.exists(workspace):
|
||||
reasons.append(f"worktree path '{workspace}' does not exist")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
if not os.path.isdir(workspace):
|
||||
reasons.append(f"worktree path '{workspace}' is not a directory")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", workspace, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common_dir = _realpath_git_common_dir(workspace, res.stdout)
|
||||
except Exception:
|
||||
reasons.append(f"worktree '{workspace}' is not a valid git repository")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
expected_dir = os.path.realpath(os.path.join(root, ".git"))
|
||||
if common_dir != expected_dir:
|
||||
reasons.append(
|
||||
f"worktree '{workspace}' does not belong to the target repository '{root}'"
|
||||
)
|
||||
return _membership_assessment(not reasons, reasons, workspace, root, common_dir)
|
||||
|
||||
|
||||
def _membership_assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
workspace: str,
|
||||
root: str,
|
||||
common_dir: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"workspace_path": workspace,
|
||||
"canonical_repo_root": root,
|
||||
"git_common_dir": common_dir,
|
||||
}
|
||||
|
||||
|
||||
def format_workspace_repo_membership_error(assessment: dict) -> str:
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"])
|
||||
return (
|
||||
f"Branches-only mutation guard (#274): {reasons} (fail closed). "
|
||||
f"canonical repository root: {root}; workspace: {workspace}."
|
||||
)
|
||||
|
||||
|
||||
def assess_author_mutation_worktree(
|
||||
*,
|
||||
workspace_path: str,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""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"})
|
||||
|
||||
_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 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",
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"""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")
|
||||
|
||||
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
|
||||
),
|
||||
}
|
||||
@@ -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,156 @@
|
||||
# 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** sessions **must not**:
|
||||
|
||||
| Forbidden | Why |
|
||||
|-----------|-----|
|
||||
| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state |
|
||||
| Restart 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 |
|
||||
|
||||
EOF / transport recovery: **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.
|
||||
|
||||
### 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** (client-namespace probes fail, wrong identity, wrong root, missing mutation capability, persistent EOF after reconnect):
|
||||
|
||||
1. **Normal PR / review / merge / issue-mutation work must stop.**
|
||||
2. Do **not** improvise by switching to a dev worktree MCP for production mutations.
|
||||
3. Resume only after:
|
||||
- runtime is restored, **or**
|
||||
- a **controlled** promotion/rollback completes with the promotion record above, **or**
|
||||
- a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented.
|
||||
|
||||
## 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.
|
||||
4. Operator Guide wiki cross-link to this ADR.
|
||||
|
||||
## 6. Acceptance for this ADR
|
||||
|
||||
1. Document merged under `docs/architecture/`.
|
||||
2. Issue #615 references this path.
|
||||
3. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **violations**.
|
||||
4. Unhealthy runtime stops normal mutation work until restore/promotion/rollback/bootstrap.
|
||||
|
||||
## 7. Document history
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule |
|
||||
@@ -0,0 +1,190 @@
|
||||
# Bootstrap Review Path for Self-Hosted MCP Workflow Fixes (#557)
|
||||
|
||||
## Purpose
|
||||
|
||||
Gitea-Tools MCP workflow fixes can create a **bootstrap deadlock**: the live
|
||||
MCP daemons still run the broken code from `master`, so canonical reviewer /
|
||||
merger tools cannot complete the review that would land the fix.
|
||||
|
||||
This document defines a **narrow, controller-authorized bootstrap path** so
|
||||
such fixes can land without weakening normal review/merge gates.
|
||||
|
||||
It is **not** a general bypass. Normal PRs must still use the full
|
||||
reviewer → merger MCP workflow.
|
||||
|
||||
## When this path applies
|
||||
|
||||
All of the following must be true:
|
||||
|
||||
1. The PR changes **Gitea-Tools MCP workflow / preflight / lease / gate** code
|
||||
that the live daemon must load to review itself (self-hosted control plane).
|
||||
2. Canonical review or merge tools **fail closed** because of that defect
|
||||
(documented tool errors, not “inconvenience”).
|
||||
3. A **controller** records an explicit bootstrap authorization on the PR or
|
||||
linked issue (see below).
|
||||
4. The PR source is clean, testable, and free of unrelated scope.
|
||||
|
||||
Example (historical): PR #553 / Issue #546 (reviewer preflight capability/lease
|
||||
deadlock). Live daemons on unpatched `master` could not complete canonical
|
||||
review of the fix PR.
|
||||
|
||||
## Durable authorization (required)
|
||||
|
||||
**No manual remote merge, force-merge, or API merge of a bootstrap PR is
|
||||
allowed** unless a controller has posted a durable authorization record on
|
||||
Gitea.
|
||||
|
||||
### Authorization record (issue or PR comment)
|
||||
|
||||
The controller comment **must** include a machine-readable marker and the
|
||||
fields below:
|
||||
|
||||
```text
|
||||
## BOOTSTRAP REVIEW AUTHORIZATION (#557)
|
||||
|
||||
Status: APPROVED
|
||||
PR: <number>
|
||||
Issue: <number>
|
||||
Controller: <gitea username>
|
||||
Reason: live MCP runtime cannot canonically review this self-hosted workflow fix
|
||||
Scope: narrow bootstrap only — does not weaken normal PR gates
|
||||
|
||||
Validation evidence:
|
||||
- git diff --check: clean
|
||||
- targeted pytest: <command> → pass
|
||||
- full suite attribution (if non-zero): baseline compared to prgs/master
|
||||
|
||||
Duplicate-PR audit: <none | list and disposition>
|
||||
Mutation ledger audit: <summary or path to proof>
|
||||
Contamination audit: <clean | list contaminated comment/lease IDs and disposition>
|
||||
|
||||
Allowed verification actions used: <list>
|
||||
Forbidden actions NOT used: root checkout edits; raw curl mutation; direct
|
||||
module import of gitea_mcp_server for mutations; in-memory gate restoration
|
||||
|
||||
Post-land plan:
|
||||
- restart MCP daemons for author/reviewer/merger/reconciler profiles
|
||||
- re-verify with canonical tools (see Post-bootstrap steps)
|
||||
```
|
||||
|
||||
Without this record, bootstrap merge is **forbidden**. Agents must stop with
|
||||
`BLOCKED + DIAGNOSE` rather than improvise a merge.
|
||||
|
||||
## Review gates and proof (still required)
|
||||
|
||||
Bootstrap does **not** skip technical review. It only allows verification and
|
||||
landing when the **live MCP mutation path** cannot complete the review.
|
||||
|
||||
Before authorization, the controller (or a delegated read-only verifier in an
|
||||
isolated worktree) must have:
|
||||
|
||||
| Gate | Requirement |
|
||||
|------|-------------|
|
||||
| Diff hygiene | `git diff --check` clean on the PR tip vs `prgs/master` |
|
||||
| Tests | Targeted tests for the fix pass; full suite either green or failures attributed to baseline `prgs/master` |
|
||||
| Duplicate PR | Open-PR inventory shows no competing open PR for the same issue (or supersession is documented) |
|
||||
| Mutation ledger | Any claimed mutations are ledger-consistent; no silent root edits |
|
||||
| Contamination audit | PR/issue comments and leases classified as clean vs workflow-contaminated |
|
||||
| Scope | Diff limited to the bootstrap issue; no drive-by refactors |
|
||||
|
||||
### Contamination audit (comments / leases)
|
||||
|
||||
Comments or leases created via **raw curl**, **direct Python import of MCP
|
||||
modules**, or **token extraction** are **workflow-contaminated**. They must be
|
||||
listed and must **not** be treated as canonical review proof.
|
||||
|
||||
Only comments posted via **sanctioned MCP reviewer tools** (after the fix is
|
||||
landed and daemons restarted, or during a clean path that did not bypass gates)
|
||||
count as canonical review state.
|
||||
|
||||
Historical example on PR #553:
|
||||
|
||||
| Comment | Disposition |
|
||||
|---------|-------------|
|
||||
| #7247 test / raw API | contaminated |
|
||||
| #7251 lease metadata / raw API | contaminated |
|
||||
| #7252 lease metadata / direct import | contaminated |
|
||||
| #7265 lease metadata / MCP reviewer tools | workflow-clean |
|
||||
|
||||
## Allowed verification actions
|
||||
|
||||
These may run **outside** the broken live mutation path to establish facts:
|
||||
|
||||
- Read-only MCP tools (`gitea_view_pr`, `gitea_list_prs`, `gitea_whoami`,
|
||||
`gitea_get_profile`, inventory, eligibility **reads**).
|
||||
- Isolated `branches/` worktree checkout of the PR head (never root).
|
||||
- `git fetch`, `git log`, `git diff`, `git merge-tree` (read-only analysis).
|
||||
- Targeted `pytest` / `py_compile` inside that worktree.
|
||||
- Controller posting of the bootstrap authorization comment via a healthy
|
||||
MCP profile **if available**; if comment mutation is also deadlocked, a
|
||||
**human operator** posts the authorization in the Gitea UI (still durable on
|
||||
the thread).
|
||||
|
||||
## Forbidden actions (always)
|
||||
|
||||
Even under bootstrap:
|
||||
|
||||
- Editing or committing in the **project root / control checkout**.
|
||||
- Treating root as a worktree for implementation or review mutations.
|
||||
- Raw `curl` / REST mutation with extracted tokens as a normal workflow.
|
||||
- `import gitea_mcp_server` (or sibling modules) from a shell to call mutation
|
||||
helpers and bypass preflight.
|
||||
- In-memory restoration of capability / lease / decision state to “finish”
|
||||
a blocked chain.
|
||||
- Force-push, history rewrite, or deleting evidence comments.
|
||||
- Weakening normal gates in code “temporarily” without a tracked issue/PR.
|
||||
- Self-review or self-merge by the PR author identity.
|
||||
|
||||
## Landing procedure (after APPROVED authorization)
|
||||
|
||||
1. Confirm the authorization record is present and complete on the PR or issue.
|
||||
2. Merge **only** with an independent merger identity when possible.
|
||||
- Preferred: restart/replace the MCP merger daemon with a build that includes
|
||||
the fix (or a one-shot process started from the PR worktree binary) so
|
||||
`gitea_merge_pr` can run under normal gates.
|
||||
- If the live merger daemon still cannot load the fix, a **human operator**
|
||||
may merge via the Gitea UI **only** after the authorization record exists.
|
||||
3. Never merge from contaminated proof alone.
|
||||
|
||||
## Post-bootstrap steps
|
||||
|
||||
### 1. Restart MCP daemons
|
||||
|
||||
After the fix lands on `prgs/master`:
|
||||
|
||||
1. Stop author / reviewer / merger / reconciler MCP server processes for this
|
||||
repo (IDE MCP pool + any long-lived terminals).
|
||||
2. Confirm no stale PID still serves the old code.
|
||||
3. Restart each profile so it loads the updated `master` tree (or installed
|
||||
package path).
|
||||
4. Call `gitea_whoami` / `gitea_get_profile` on each namespace and record
|
||||
profile name + identity.
|
||||
|
||||
### 2. Re-verify with canonical tools
|
||||
|
||||
Example pattern after PR #553-class fixes (lease release / #550-style follow-up):
|
||||
|
||||
1. From a **reviewer** MCP session: acquire/release or inspect the relevant
|
||||
lease with canonical tools only.
|
||||
2. Confirm no direct-import or raw-API path is required.
|
||||
3. Post a short controller or reconciler note: bootstrap complete; normal gates
|
||||
restored.
|
||||
|
||||
If verification still requires a bypass, open a new issue — do **not** extend
|
||||
this bootstrap authorization silently.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- This path does **not** authorize skipping tests, duplicate audits, or
|
||||
contamination audits.
|
||||
- This path does **not** authorize root checkout implementation work.
|
||||
- This path does **not** replace BLOCKED + DIAGNOSE when a non-bootstrap
|
||||
failure occurs (#552).
|
||||
- This path does **not** grant permanent “operator exception” culture.
|
||||
|
||||
## Related
|
||||
|
||||
- Root checkout policy: `docs/llm-workflow-runbooks.md` (Global LLM Worktree Rule, #475)
|
||||
- BLOCKED + DIAGNOSE: issue #552 / skill workflows
|
||||
- Reviewer lease / preflight deadlock class: issues #546, #548, #550
|
||||
- Durable session proofs across daemon pools: issue #559
|
||||
@@ -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
|
||||
@@ -13,6 +13,25 @@ credentials.** Every test mocks the HTTP client and the keychain/auth lookup.
|
||||
|
||||
## 1. Standard test commands
|
||||
|
||||
### Canonical runner: `./run-tests.sh`
|
||||
|
||||
The canonical full-validation command is the root-level runner. It invokes the
|
||||
project virtualenv interpreter and passes any extra arguments straight through
|
||||
to `pytest`:
|
||||
|
||||
```bash
|
||||
# Full validation
|
||||
./run-tests.sh
|
||||
|
||||
# Focused validation (extra args forward to pytest)
|
||||
./run-tests.sh tests/test_mcp_server.py -q
|
||||
```
|
||||
|
||||
`run-tests.sh` runs `venv/bin/python -m pytest "$@"` and fails with a clear
|
||||
setup message if the virtualenv Python is missing (so a session never silently
|
||||
falls back to the wrong interpreter). The explicit `venv/bin/python -m pytest`
|
||||
forms below remain valid and equivalent.
|
||||
|
||||
The test suite needs the project virtualenv (it provides the MCP SDK):
|
||||
|
||||
```bash
|
||||
@@ -35,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>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Controller Issue-Acceptance Gate
|
||||
|
||||
A merged PR does not automatically prove an issue is fully satisfied. After
|
||||
merge, a controller must audit the linked issue against its acceptance criteria
|
||||
and post a durable handoff before the issue is treated as complete.
|
||||
|
||||
## Workflow position
|
||||
|
||||
1. Author implements the issue and opens a PR.
|
||||
2. Reviewer reviews the PR.
|
||||
3. Merger merges the approved PR.
|
||||
4. **Controller performs issue-acceptance audit.**
|
||||
5. Controller posts a `## Controller Issue Acceptance` comment with either:
|
||||
- `STATE: accepted` and checked criteria, or
|
||||
- a rejection path (`more-work-required`, `needs-tests`, `needs-docs`, etc.)
|
||||
with `MISSING_WORK` and a paste-ready `NEXT_PROMPT`.
|
||||
|
||||
Gitea may auto-close an issue via `Closes #N` in the PR body. That closure is
|
||||
merge mechanics only. Controller acceptance is still required before any final
|
||||
report or queue controller treats the issue as complete.
|
||||
|
||||
## Template
|
||||
|
||||
Use `issue_acceptance_gate.render_controller_acceptance_template()` or the
|
||||
copy in
|
||||
[`skills/llm-project-workflow/templates/controller-issue-acceptance.md`](../skills/llm-project-workflow/templates/controller-issue-acceptance.md).
|
||||
|
||||
## Final-report rules
|
||||
|
||||
Final reports must not claim `issue complete` solely because a PR merged.
|
||||
Either:
|
||||
|
||||
- include a valid `## Controller Issue Acceptance` block with
|
||||
`STATE: accepted`, or
|
||||
- explicitly state `controller acceptance pending` and identify the controller
|
||||
as the next actor.
|
||||
|
||||
`final_report_validator` enforces this through
|
||||
`issue_acceptance_gate.validate_final_report_issue_acceptance()`.
|
||||
|
||||
## Role boundaries
|
||||
|
||||
- Authors must not mark their own issues accepted.
|
||||
- Reviewers must not mark issue acceptance unless acting under controller
|
||||
capability.
|
||||
- Mergers merge PRs; they do not substitute for controller acceptance.
|
||||
|
||||
## Related
|
||||
|
||||
- #495 — canonical next-action comment templates
|
||||
- #496 — fail-closed canonical comment validation before posting
|
||||
- #303 — controller handoff schema for reconciliation workflows
|
||||
+88
-24
@@ -1,34 +1,98 @@
|
||||
# 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: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 |
|
||||
|
||||
## 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.
|
||||
|
||||
+472
-16
@@ -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
|
||||
@@ -274,12 +281,46 @@ is proven abandoned and the takeover is recorded.
|
||||
|
||||
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
|
||||
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
|
||||
records an `author_issue_work` lease in the issue-lock payload with issue
|
||||
number, optional PR number, branch, worktree path, claimant identity/profile,
|
||||
created timestamp, expiry timestamp, and last heartbeat timestamp. An active
|
||||
same-issue/same-operation lease blocks duplicate work. An expired lease still
|
||||
blocks takeover until a recovery review records why the prior work is abandoned,
|
||||
completed, or unsafe to continue.
|
||||
records an `author_issue_work` lease in a keyed lock file under
|
||||
`GITEA_ISSUE_LOCK_DIR` (default `~/.cache/gitea-tools/issue-locks`), one file
|
||||
per `remote` + `org` + `repo` + `issue_number`. The current MCP session binds
|
||||
its active lock through a per-process pointer so concurrent repos/issues never
|
||||
share one overwrite-prone slot (#443).
|
||||
|
||||
Each lock payload includes issue number, optional PR number, branch, worktree
|
||||
path, claimant identity/profile, created timestamp, expiry timestamp, and last
|
||||
heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
|
||||
work. An expired lease still blocks takeover until a recovery review records why
|
||||
the prior work is abandoned, completed, or unsafe to continue.
|
||||
|
||||
**Stacked PRs (#484).** By default the lock worktree must be base-equivalent to
|
||||
`master`/`main`/`dev` — ordinary work is unchanged. A *stacked* PR (deliberately
|
||||
based on another unmerged PR's branch) is an explicit, opt-in path: pass
|
||||
`stacked_base_branch` **and** `stacked_base_pr` to `gitea_lock_issue`. The lock
|
||||
fails closed unless that branch is owned by a live **open** PR whose number
|
||||
matches `stacked_base_pr`, so arbitrary or stale branches cannot be used as
|
||||
bases. When approved, the lock payload records
|
||||
`approved_stacked_base = {branch, pr_number, verified_open}` and the worktree may
|
||||
be base-equivalent to that branch instead of master. `gitea_create_pr` then
|
||||
allows `base = <that branch>` only when it matches the recorded approval, the
|
||||
dependency PR is **still open**, and the PR body documents the stack:
|
||||
|
||||
- `Stacked on PR #<X> / issue #<Y>`
|
||||
- `Base branch: <feature-branch>`
|
||||
- `Head branch: <this-issue-branch>`
|
||||
- `Do not merge before PR #<X>` (merge ordering)
|
||||
- retarget/rebase to `master` after the dependency lands, if required
|
||||
|
||||
Stacked support never bypasses the issue lock — the base is recorded *on* the
|
||||
lock and re-verified at PR time. A merged/closed dependency base fails closed;
|
||||
retarget onto `master` or re-lock against a live base.
|
||||
|
||||
**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal
|
||||
recovery path.** That global slot is deprecated and can clobber unrelated live
|
||||
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
|
||||
adoption rebinds the session when the issue's exact branch already exists (#442).
|
||||
`gitea_create_pr` resolves the durable keyed lock by session pointer or by
|
||||
matching `head` branch without unsafe manual seeding.
|
||||
|
||||
**Issue-lock recovery (#447):** Do not manually seed, restore, or delete
|
||||
`/tmp/gitea_issue_lock.json` as a normal recovery path. That file is global
|
||||
@@ -291,6 +332,17 @@ shared state and manual writes can clobber another session's live lease. Use
|
||||
3. Operator override only when explicitly authorized — record
|
||||
`External-state mutations` and `operator override proof` in the final report.
|
||||
|
||||
**Adoption proof in the live lock response (#477):** when `gitea_lock_issue`
|
||||
adopts an existing own branch, the response carries an `adoption` block with
|
||||
citable fields — `adoption_decision` (`ADOPT`), `adopted` (`true`),
|
||||
`adopted_branch`, `adopted_branch_head`, `matcher_summary` (boundary-safe reason
|
||||
the branch qualified), `competing_branch_check`, and `safe_next_action`. A normal
|
||||
lock instead returns an `adoption_check` block with `adoption_decision`
|
||||
(`NO_MATCH`) and `adopted: false`, so a non-adoption response can never be misread
|
||||
as claiming adoption. Recovery reports should quote the live lock response
|
||||
`adoption`/`adoption_check` block directly instead of inferring adoption from
|
||||
separate offline checks.
|
||||
|
||||
`gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
|
||||
metadata. Final-report validation blocks handoffs that hide lock read/write/delete
|
||||
under `External-state mutations: none` or mix author PR creation with reviewer
|
||||
@@ -332,6 +384,84 @@ explicit control-checkout repair.
|
||||
|
||||
Portable wording: [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||
|
||||
### Root checkout guard (#475)
|
||||
|
||||
The MCP server enforces a fail-closed **root checkout guard** before author,
|
||||
reviewer, and merger mutations when the active workspace is not an isolated
|
||||
`branches/...` worktree (reconciler close paths remain exempt per #468).
|
||||
|
||||
The guard blocks when the **control checkout** (repository root) is:
|
||||
|
||||
- on a non-stable branch (`master` / `main` / `dev` expected),
|
||||
- detached HEAD,
|
||||
- dirty (tracked edits),
|
||||
- or its `HEAD` does not match `prgs/master` when that ref is available.
|
||||
|
||||
**Remediation (never auto-reset or stash):**
|
||||
|
||||
> Root checkout is not on master. Preserve state, switch root back to master,
|
||||
> and use `scripts/worktree-review` or the sanctioned issue worktree flow.
|
||||
|
||||
**Recovery after root hijack:**
|
||||
|
||||
1. Preserve any in-progress edits (copy paths, note branch name, or commit on a
|
||||
rescue branch from a `branches/...` worktree).
|
||||
2. From the repository root: `git checkout master` (or `main` / `dev` per repo
|
||||
policy) and `git fetch prgs && git merge --ff-only prgs/master` when safe.
|
||||
3. Confirm `git status` is clean and `git branch --show-current` is `master`.
|
||||
4. Resume work only inside `branches/issue-<n>-<slug>` via `gitea_lock_issue` /
|
||||
`git worktree add`.
|
||||
|
||||
`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
|
||||
runs from broken `master`, canonical review can deadlock on itself.
|
||||
|
||||
Do **not** improvise raw API, direct imports, root edits, or gate bypasses.
|
||||
|
||||
Use the narrow controller-authorized path documented in:
|
||||
|
||||
- [`docs/bootstrap-review-path.md`](bootstrap-review-path.md)
|
||||
|
||||
Hard rules:
|
||||
|
||||
- No merge without a durable `BOOTSTRAP REVIEW AUTHORIZATION (#557)` record on
|
||||
the PR or linked issue.
|
||||
- Validation, duplicate-PR, mutation-ledger, and contamination audits remain
|
||||
mandatory.
|
||||
- Root checkout stays read-only orchestration only; verification runs in
|
||||
`branches/` worktrees.
|
||||
- After land: restart MCP daemons and re-verify with canonical tools only.
|
||||
- This never weakens normal reviewer/merger gates for ordinary PRs.
|
||||
|
||||
## Shell Spawn Hard-Stop Rule
|
||||
|
||||
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
|
||||
@@ -531,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.`
|
||||
|
||||
@@ -589,13 +739,40 @@ 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.`
|
||||
|
||||
#### Merger lease adoption (#536)
|
||||
|
||||
When review and merge run in **separate sessions**, the merger must **not**
|
||||
manually seed `reviewer_pr_lease._SESSION_LEASE` or equivalent in-process state.
|
||||
That ad hoc pattern was used incidentally for PR #493 and PR #421; it is not
|
||||
canonical proof and is rejected by mutation gates.
|
||||
|
||||
**Canonical merger handoff:**
|
||||
|
||||
1. Confirm a reviewer session holds an active PR lease and posted **APPROVED**
|
||||
at the current live head (`gitea_get_pr_review_feedback`).
|
||||
2. In a clean merger worktree under `branches/`, call
|
||||
`gitea_adopt_merger_pr_lease` with `worktree`, `expected_head_sha`, and
|
||||
optional `issue_number`.
|
||||
3. The tool posts durable adoption proof on the PR thread (`<!-- mcp-review-lease-adoption:v1 -->`)
|
||||
recording actor, profiles, adopted-from session/comment, adoption reason, and
|
||||
timestamp, then records sanctioned in-session provenance.
|
||||
4. Call `gitea_merge_pr` with the same pinned `expected_head_sha`.
|
||||
|
||||
**Forbidden:** Python one-liners or scripts that call `record_session_lease()`
|
||||
without provenance from `gitea_acquire_reviewer_pr_lease`,
|
||||
`gitea_adopt_merger_pr_lease`, or `gitea_heartbeat_reviewer_pr_lease`.
|
||||
|
||||
**Same-session review+merge:** the reviewer session may use
|
||||
`gitea_acquire_reviewer_pr_lease` directly; adoption is only for cross-session
|
||||
merger handoff.
|
||||
|
||||
### Close the issue after merge / Reconciliation
|
||||
|
||||
- **Profile:** issue-manager or merger.
|
||||
@@ -613,6 +790,53 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
|
||||
- **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
|
||||
@@ -633,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 |
|
||||
@@ -683,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,
|
||||
@@ -715,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,
|
||||
@@ -778,6 +1178,45 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
```
|
||||
|
||||
## Namespace workspace binding (#510)
|
||||
|
||||
Each MCP namespace resolves its **own** active task workspace. Foreign role
|
||||
worktree environment variables must not poison another namespace's purity
|
||||
checks.
|
||||
|
||||
| Namespace | Workspace env vars (in priority under `GITEA_ACTIVE_WORKTREE`) | Allowed roots |
|
||||
|-----------|------------------------------------------------------------------|---------------|
|
||||
| author | `GITEA_AUTHOR_WORKTREE` | `branches/<task>` worktree only (#274) |
|
||||
| reviewer | `GITEA_REVIEWER_WORKTREE` | clean `branches/<review>` worktree |
|
||||
| merger | `GITEA_MERGER_WORKTREE` | clean `branches/<merge>` worktree **or** clean control checkout |
|
||||
| reconciler | `GITEA_RECONCILER_WORKTREE` | clean `branches/<reconcile>` worktree **or** clean control checkout |
|
||||
|
||||
`GITEA_AUTHOR_WORKTREE` is **author-only**. Reviewer, merger, and reconciler
|
||||
MCP processes ignore it even when it points at a dirty author WIP tree.
|
||||
|
||||
### Safe reconnect / rebind procedure
|
||||
|
||||
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.
|
||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||
own namespace — that destroys another agent's WIP.
|
||||
|
||||
### CTH guidance for workspace binding blockers
|
||||
|
||||
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.
|
||||
|
||||
## Safety notes
|
||||
|
||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||
@@ -787,6 +1226,8 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
|
||||
## Related documents
|
||||
|
||||
- [`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.
|
||||
- [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md) — static author/reviewer namespace deployment (#139 decision).
|
||||
@@ -796,6 +1237,21 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
- [`credential-isolation.md`](credential-isolation.md) — credential handling.
|
||||
- [`release-workflows.md`](release-workflows.md) — release/merge workflow.
|
||||
- [`../README.md`](../README.md) — canonical config, thin launchers, the menu.
|
||||
- [`state-handoff-ledger.md`](state-handoff-ledger.md) — canonical state comments and next-action handoff (#494).
|
||||
|
||||
## Canonical state handoff ledger (#494)
|
||||
|
||||
Gitea comments and final reports must make continuation obvious without chat
|
||||
history. See [`state-handoff-ledger.md`](state-handoff-ledger.md) for:
|
||||
|
||||
- discussion → issue → PR → review → merge → reconcile lifecycle
|
||||
- templates for discussion, issue, PR, and queue-controller state comments
|
||||
- final-report requirements: Current status, Next actor, Next action, Next prompt
|
||||
- queue-controller priority order and discussion ≥5-comment rule (urgent/trivial
|
||||
exceptions)
|
||||
|
||||
Helpers live in `state_handoff_ledger.py`; final-report enforcement is wired
|
||||
through `assess_final_report_validator`.
|
||||
|
||||
## PR-only queue cleanup mode (#390)
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# MCP daemon import and keychain guard (#558)
|
||||
|
||||
## Problem
|
||||
|
||||
During deadlock debugging, agents imported `gitea_mcp_server` / ran credential
|
||||
helpers from a raw shell, bypassing preflight purity and role gates.
|
||||
|
||||
## Rule
|
||||
|
||||
Mutation auth and keychain fill require a **sanctioned MCP daemon** process.
|
||||
|
||||
| Context | Allowed |
|
||||
|---------|---------|
|
||||
| Official MCP entrypoint (`mcp_server.py` / `gitea_mcp_server` `__main__`) sets `GITEA_MCP_SANCTIONED_DAEMON=1` | yes |
|
||||
| pytest | yes |
|
||||
| `GITEA_ALLOW_DIRECT_MCP_IMPORT=1` (operator/tests only) | yes |
|
||||
| bare `python -c 'import gitea_auth; get_auth_header(...)'` | **no** |
|
||||
| keychain fill without daemon | **no** unless `GITEA_ALLOW_KEYCHAIN_CLI=1` |
|
||||
|
||||
## Operator note
|
||||
|
||||
LLM sessions must never set the allow-direct-import or allow-keychain-cli
|
||||
overrides. Those are human-only escape hatches.
|
||||
@@ -0,0 +1,67 @@
|
||||
# MCP operator shell menu
|
||||
|
||||
## Purpose
|
||||
|
||||
`./mcp-menu.sh` is a repository-root terminal menu for onboarding and operating
|
||||
the Gitea-Tools MCP/Gitea workflow without memorizing every prompt, script path,
|
||||
or runbook section.
|
||||
|
||||
It is intentionally **safe by default**: status checks and copy-paste workflow
|
||||
prompts. It does not delete branches, force-push, edit lock files, or bypass
|
||||
sanctioned MCP tools.
|
||||
|
||||
## How to run
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
./mcp-menu.sh
|
||||
```
|
||||
|
||||
The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
||||
`set -euo pipefail`.
|
||||
|
||||
## Safety rules
|
||||
|
||||
- **Read-only by default** — root checkout health is inspection only.
|
||||
- **No destructive git** — no `git push --force`, branch deletion, or
|
||||
`--delete` refspecs.
|
||||
- **No lock-file editing** — issue locks are acquired only through
|
||||
`gitea_lock_issue`.
|
||||
- **No raw API bypass** — prompts direct operators to sanctioned MCP tools.
|
||||
- **Remote mutations require confirmation** — any future menu action that would
|
||||
mutate remote or server state must be clearly labeled and require explicit
|
||||
operator confirmation before running.
|
||||
- **Author work stays under `branches/`** — the root checkout is a stable
|
||||
control checkout on `master` / `prgs/master`.
|
||||
|
||||
## Menu options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
|
||||
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
||||
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
|
||||
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
|
||||
| Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. |
|
||||
| Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. |
|
||||
| Proxmox deployment placeholder | **Not implemented** — informational message only. |
|
||||
| Create Proxmox LXC placeholder | **Not implemented** — informational message only. |
|
||||
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
|
||||
| Exit | Quit the menu. |
|
||||
|
||||
## Placeholder-only entries
|
||||
|
||||
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
|
||||
dedicated issues implement sanctioned automation. The menu prints a clear
|
||||
message and does not invoke deploy scripts.
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [`docs/llm-workflow-runbooks.md`](llm-workflow-runbooks.md) — Gitea-specific workflow runbooks
|
||||
- [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable workflow skill
|
||||
- [`skills/llm-project-workflow/workflows/`](../skills/llm-project-workflow/workflows/) — canonical task workflows
|
||||
|
||||
## Tests
|
||||
|
||||
Hermetic coverage lives in `tests/test_mcp_menu_script.py`.
|
||||
@@ -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,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,86 @@
|
||||
# Canonical state handoff ledger (#494)
|
||||
|
||||
Gitea is the system of record for continuation. Every discussion, issue, and PR
|
||||
should answer: current state, last proof, who acts next, and the exact prompt for
|
||||
the next role.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. Controller opens a discussion.
|
||||
2. Discussion accumulates substantive comments (default minimum: five).
|
||||
3. Controller posts a discussion summary when ready.
|
||||
4. Controller creates linked issues from the summary.
|
||||
5. Author locks issue, implements, opens PR.
|
||||
6. Reviewer reviews at current head.
|
||||
7. Merger merges after formal approval.
|
||||
8. Reconciler closes superseded or already-landed PRs.
|
||||
9. Issue/PR/discussion state comments make the next action obvious at every step.
|
||||
|
||||
## Discussion rules
|
||||
|
||||
- Do **not** convert a discussion into issues until it has at least **five
|
||||
substantive comments**, unless the discussion state comment marks
|
||||
`URGENCY: urgent` or `URGENCY: trivial`.
|
||||
- Substantive comment types: proposal, risk/concern, acceptance criteria,
|
||||
implementation approach, dependency/sequence, summary.
|
||||
- Before issue creation, post a **discussion summary** with decision, issues to
|
||||
create, non-goals, unresolved questions, and next prompt.
|
||||
- Created issues must link back to the discussion; the discussion must link
|
||||
forward to created issues.
|
||||
|
||||
## State comment templates
|
||||
|
||||
Use `state_handoff_ledger.py` helpers or copy the canonical blocks:
|
||||
|
||||
- `render_discussion_state_comment(...)`
|
||||
- `render_discussion_summary_comment(...)`
|
||||
- `render_issue_state_comment(...)`
|
||||
- `render_pr_state_comment(...)`
|
||||
- `render_queue_controller_report(...)`
|
||||
|
||||
Post state comments as the **latest canonical update** on the object. Do not
|
||||
bury state inside PR bodies only.
|
||||
|
||||
## Final report requirements
|
||||
|
||||
Every final report must include a `Controller Handoff` section with:
|
||||
|
||||
- **Current status** — live state after this session
|
||||
- **Next actor** — `author`, `reviewer`, `merger`, `reconciler`, or `controller`
|
||||
- **Next action** — one imperative step
|
||||
- **Next prompt** — ready-to-paste prompt for the next role
|
||||
|
||||
`assess_final_report_next_action_handoff` and
|
||||
`assess_contradictory_state_handoff` enforce these fields and reject
|
||||
contradictory claims (for example, ready-to-merge without approval, issue done
|
||||
without PR proof, discussion complete without summary).
|
||||
|
||||
## Queue controller selection (priority order)
|
||||
|
||||
1. Merge clean approved PRs at current head.
|
||||
2. Review PRs needing review.
|
||||
3. Reconcile superseded or already-landed duplicates.
|
||||
4. Continue blocked-but-now-unblocked issues.
|
||||
5. Create issues from completed discussions.
|
||||
6. Start new author work only when higher-priority queue items are clear.
|
||||
|
||||
For each candidate object, read the **latest canonical state comment** before
|
||||
choosing an action. Output the exact next-role prompt in the controller report.
|
||||
|
||||
## Example workflow states
|
||||
|
||||
| State | Next actor | Typical next action |
|
||||
|-------|------------|---------------------|
|
||||
| Discussion needs more comments | controller | Facilitate discussion until five substantive comments or urgent/trivial exception |
|
||||
| Issue ready for author | author | Lock issue and implement in `branches/` worktree |
|
||||
| PR needs review | reviewer | Review at pinned head in reviewer worktree |
|
||||
| PR approved at head | merger | Merge with merger profile after eligibility proof |
|
||||
| PR superseded | reconciler | Close duplicate/already-landed PR with proof |
|
||||
| Issue blocked | controller | Post issue state comment with blockers and next prompt |
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Chat history is not the source of truth.
|
||||
- Do not weaken author/reviewer/merger/reconciler separation.
|
||||
- Do not replace Gitea issues, PRs, or canonical workflow files under
|
||||
`skills/llm-project-workflow/`.
|
||||
@@ -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
|
||||
+127
-8
@@ -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)
|
||||
|
||||
@@ -43,13 +50,29 @@ Optional environment variables:
|
||||
| `/api/projects` | JSON registry export |
|
||||
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
|
||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
||||
| `/runtime` | Stub — MCP runtime health (#430) |
|
||||
| `/audit` | Stub — report audit paste (#431) |
|
||||
| `/worktrees` | Stub — hygiene dashboard (#432) |
|
||||
| `/leases` | Stub — lease visibility (#433) |
|
||||
| `/runtime` | MCP runtime health and stale detection (#430) |
|
||||
| `/api/runtime` | JSON runtime health export |
|
||||
| `/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)
|
||||
|
||||
@@ -82,8 +105,104 @@ 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 -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_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,
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
- `gitea_dry_run_pr_review` — validation-phase review mechanics.
|
||||
- `gitea_mark_final_review_decision` — mark validation complete.
|
||||
- `gitea_submit_pr_review` / `gitea_review_pr` — gated live review.
|
||||
- `gitea_acquire_reviewer_pr_lease` / `gitea_heartbeat_reviewer_pr_lease` — per-PR reviewer lease (#407).
|
||||
- `gitea_adopt_merger_pr_lease` — guarded cross-session merger lease adoption (#536).
|
||||
- `gitea_merge_pr` — gated merge (only merge path).
|
||||
|
||||
## Read tools
|
||||
|
||||
@@ -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.
|
||||
@@ -11,7 +11,14 @@ 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 (
|
||||
HANDOFF_HEADING,
|
||||
assess_controller_handoff,
|
||||
@@ -21,34 +28,45 @@ from review_proofs import (
|
||||
assess_review_mutation_final_report,
|
||||
assess_validation_report,
|
||||
)
|
||||
from state_handoff_ledger import assess_state_handoff_ledger_report
|
||||
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(
|
||||
@@ -116,6 +134,22 @@ _TARGET_BRANCH_SHA_RE = re.compile(
|
||||
r"target branch sha\s*:\s*[0-9a-f]{40}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKFLOW_LOAD_HELPER_RE = re.compile(
|
||||
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}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKFLOW_LOAD_BOUNDARY_RE = re.compile(
|
||||
r"workflow[- ]load helper result[\s\S]{0,400}?boundary[_ ]status\s*:\s*(?:clean|violation)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKFLOW_FILE_VIEW_NARRATIVE_RE = re.compile(
|
||||
r"(?:read|viewed|loaded)\s+(?:the\s+)?(?:canonical\s+)?(?:workflow|review-merge-pr\.md)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||
_RECONCILE_STALE_FIELDS = (
|
||||
"pr number opened",
|
||||
@@ -174,6 +208,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,
|
||||
@@ -315,6 +366,38 @@ def _rule_shared_controller_handoff(
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_state_handoff_next_action(report_text: str) -> list[dict[str, str]]:
|
||||
result = assess_state_handoff_ledger_report(report_text)
|
||||
if result.get("complete"):
|
||||
return []
|
||||
findings: list[dict[str, str]] = []
|
||||
next_action = result.get("next_action") or {}
|
||||
contradictions = result.get("contradictions") or {}
|
||||
if next_action.get("block"):
|
||||
findings.extend(
|
||||
_findings_from_reasons(
|
||||
"shared.state_handoff_next_action",
|
||||
next_action.get("reasons") or [],
|
||||
field="Next action handoff",
|
||||
severity="block",
|
||||
safe_next_action=next_action.get("safe_next_action")
|
||||
or "add next-action handoff fields to Controller Handoff",
|
||||
)
|
||||
)
|
||||
if contradictions.get("block"):
|
||||
findings.extend(
|
||||
_findings_from_reasons(
|
||||
"shared.state_handoff_contradiction",
|
||||
contradictions.get("reasons") or [],
|
||||
field="State handoff",
|
||||
severity="block",
|
||||
safe_next_action=contradictions.get("safe_next_action")
|
||||
or "resolve contradictory state claims before submission",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]:
|
||||
result = assess_email_disclosure(report_text)
|
||||
if result.get("proven"):
|
||||
@@ -328,6 +411,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 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,
|
||||
*,
|
||||
@@ -491,6 +611,123 @@ def _rule_reviewer_validation_failure_history(
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_validation_cwd_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report
|
||||
|
||||
session = validation_session or {}
|
||||
claims = (
|
||||
session.get("validation_ran")
|
||||
or session.get("command")
|
||||
or session.get("baseline_validation_ran")
|
||||
)
|
||||
if not claims and "validation command:" not in (report_text or "").lower():
|
||||
return []
|
||||
|
||||
result = assess_validation_cwd_proof_report(
|
||||
report_text,
|
||||
validation_session=session,
|
||||
)
|
||||
if result.get("proven") or not result.get("claims_validation"):
|
||||
return []
|
||||
severity = "block" if result.get("violations") else "downgrade"
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.validation_cwd_proof",
|
||||
severity,
|
||||
"Validation cwd/HEAD proof",
|
||||
reason,
|
||||
result.get("safe_next_action")
|
||||
or "document pwd, HEAD SHA, and explicit cwd before validation",
|
||||
)
|
||||
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from pr_work_lease import assess_reviewer_stale_head_final_report
|
||||
|
||||
result = assess_reviewer_stale_head_final_report(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.stale_head_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Stale-head proof",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"state reviewed head SHA, live head before approval/merge, and "
|
||||
"whether any push occurred during validation"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
text = report_text or ""
|
||||
if "conflict-fix" not in text.lower() and "conflict fix" not in text.lower():
|
||||
return []
|
||||
result = assess_conflict_fix_final_report(text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"author.conflict_fix_push_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Conflict-fix push proof",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"state branch head before/after push, reviewer lease status, "
|
||||
"fast-forward status, and whether any reviewer was active"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_worktree_cleanup_audit_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from worktree_cleanup_audit import assess_cleanup_audit_final_report
|
||||
|
||||
text = report_text or ""
|
||||
if "cleanup audit" not in text.lower() and "reconciliation table" not in text.lower():
|
||||
return []
|
||||
result = assess_cleanup_audit_final_report(text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"author.worktree_cleanup_audit_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Worktree cleanup audit",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"include reconciliation table counts, disposition rows, and "
|
||||
"final git worktree list proof"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _BARE_PYTEST_RE.search(text):
|
||||
@@ -599,6 +836,74 @@ def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, st
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_premerge_baseline_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from premerge_baseline_proof import assess_premerge_baseline_proof
|
||||
|
||||
result = assess_premerge_baseline_proof(report_text)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.premerge_baseline_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Pre-merge baseline proof",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or (
|
||||
"prove the failure on the PR pre-merge base commit or a known-failure "
|
||||
"record predating the PR; current-master reproduction is not baseline proof"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
*,
|
||||
action_log: list[dict] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not re.search(
|
||||
r"validation status|pr-head validation status|official validation status",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return []
|
||||
result = assess_validation_status_vocabulary(
|
||||
text,
|
||||
command_log=action_log,
|
||||
)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.validation_status_vocabulary",
|
||||
result.get("reasons") or [],
|
||||
field="Validation status",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "use a validation status that matches the proof path executed",
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if "baseline worktree path" not in text.lower():
|
||||
@@ -817,6 +1122,69 @@ def _rule_reconcile_linked_issue_live(
|
||||
return []
|
||||
|
||||
|
||||
_PR_CLOSE_NEGATIVE = frozenset({"", "none", "n/a", "na", "0", "no", "not closed", "not performed"})
|
||||
_ANCESTOR_AFFIRMATIVE_RE = re.compile(
|
||||
r"ancestor|passed|true|verified|confirmed", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _reconciler_pr_close_performed(fields: dict[str, str], lock: dict) -> bool:
|
||||
"""True when the report/session indicates a reconciler PR close happened."""
|
||||
if lock.get("pr_closed") is True:
|
||||
return True
|
||||
value = (fields.get("prs closed", "") or "").strip().lower()
|
||||
if value in _PR_CLOSE_NEGATIVE:
|
||||
return False
|
||||
# A closed PR is reported by number (e.g. "#99") or an affirmative result.
|
||||
return bool(re.search(r"#\s*\d+|\b(?:closed|success|done)\b", value))
|
||||
|
||||
|
||||
def _rule_reconcile_close_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
reconciler_close_lock: dict | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""#306: a reconciler PR close must carry exact proof fields.
|
||||
|
||||
Read-only/comment-only reconciliations are untouched. Once a PR close is
|
||||
reported (via the ``PRs closed`` field or a session close lock), the
|
||||
handoff must prove the close capability, ancestor landing, PR close
|
||||
result, and the linked-issue result — narrative alone fails closed.
|
||||
"""
|
||||
fields = _handoff_fields(report_text)
|
||||
lock = reconciler_close_lock or {}
|
||||
if not _reconciler_pr_close_performed(fields, lock):
|
||||
return []
|
||||
|
||||
missing: list[str] = []
|
||||
capabilities = fields.get("capabilities proven", "")
|
||||
if "gitea.pr.close" not in capabilities.lower():
|
||||
missing.append("close capability proof (gitea.pr.close)")
|
||||
ancestor = fields.get("ancestor proof", "")
|
||||
if not _ANCESTOR_AFFIRMATIVE_RE.search(ancestor):
|
||||
missing.append("ancestor proof")
|
||||
prs_closed = (fields.get("prs closed", "") or "").strip().lower()
|
||||
if prs_closed in _PR_CLOSE_NEGATIVE and lock.get("pr_closed") is True:
|
||||
missing.append("PR close result")
|
||||
linked = fields.get("linked issue live status", "") or fields.get("issues closed", "")
|
||||
if not linked.strip():
|
||||
missing.append("linked issue result")
|
||||
|
||||
if not missing:
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reconcile.close_proof_fields",
|
||||
"block",
|
||||
"Reconciler close proof",
|
||||
"reconciler PR close reported without required proof field(s): "
|
||||
+ ", ".join(missing),
|
||||
"include close capability proof, ancestor proof, PR close result, "
|
||||
"and linked issue result in the handoff",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_reconcile_pagination_proof(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _INVENTORY_COMPLETE_RE.search(text):
|
||||
@@ -852,6 +1220,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,
|
||||
*,
|
||||
@@ -903,6 +1290,40 @@ 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"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.issue_acceptance_gate",
|
||||
result.get("reasons") or [],
|
||||
field="Controller acceptance",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"add Controller Issue Acceptance proof or state that controller "
|
||||
"acceptance is pending; do not claim issue complete from PR merge alone"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_lock_provenance.assess_author_reviewer_same_run_report(report_text)
|
||||
if result.get("proven"):
|
||||
@@ -919,6 +1340,81 @@ 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():
|
||||
return []
|
||||
findings: list[dict[str, str]] = []
|
||||
has_helper = bool(_WORKFLOW_LOAD_HELPER_RE.search(report_text))
|
||||
has_hash = bool(_WORKFLOW_LOAD_HASH_RE.search(report_text))
|
||||
has_boundary = bool(_WORKFLOW_LOAD_BOUNDARY_RE.search(report_text))
|
||||
has_narrative_only = bool(_WORKFLOW_FILE_VIEW_NARRATIVE_RE.search(report_text))
|
||||
|
||||
if has_narrative_only and not has_helper:
|
||||
findings.append(validator_finding(
|
||||
"reviewer.workflow_load_boundary",
|
||||
"block",
|
||||
"Workflow-load helper result",
|
||||
(
|
||||
"canonical workflow file-view narrative without structured "
|
||||
"gitea_load_review_workflow helper result"
|
||||
),
|
||||
(
|
||||
"include Workflow-load helper result with workflow_hash and "
|
||||
"boundary_status from gitea_load_review_workflow"
|
||||
),
|
||||
))
|
||||
return findings
|
||||
|
||||
if has_helper and (not has_hash or not has_boundary):
|
||||
missing = []
|
||||
if not has_hash:
|
||||
missing.append("workflow_hash")
|
||||
if not has_boundary:
|
||||
missing.append("boundary_status")
|
||||
findings.append(validator_finding(
|
||||
"reviewer.workflow_load_boundary",
|
||||
"block",
|
||||
"Workflow-load helper result",
|
||||
(
|
||||
"workflow-load helper result incomplete; missing "
|
||||
+ ", ".join(missing)
|
||||
),
|
||||
(
|
||||
"copy workflow_load_helper_result fields from "
|
||||
"gitea_load_review_workflow into the final report"
|
||||
),
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def _rule_audit_reconciliation_boundary(report_text: str) -> list[dict[str, str]]:
|
||||
from audit_reconciliation_mode import assess_audit_reconciliation_report
|
||||
|
||||
result = assess_audit_reconciliation_report(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reconcile.audit_cleanup_boundary",
|
||||
result.get("reasons") or [],
|
||||
field="Audit/cleanup phase",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "separate audit from authorized cleanup and classify mutations",
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_review_mutation(
|
||||
report_text: str,
|
||||
*,
|
||||
@@ -938,16 +1434,100 @@ 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
|
||||
|
||||
result = assess_mutation_capability_proof(report_text)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.mutation_capability_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Capabilities proven",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "document exact per-mutation capability proof before each mutation",
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
|
||||
result = assess_post_merge_cleanup_proof(report_text)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.post_merge_cleanup_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Cleanup status",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "report CLEANUP_SKIPPED with blocker or full cleanup checklist",
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, str]]:
|
||||
result = assess_mcp_native_cleanup_proof(report_text)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.mcp_native_cleanup_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Cleanup mutations",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "use authorized reconciler MCP cleanup tools; never raw scripts",
|
||||
)
|
||||
|
||||
|
||||
_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,
|
||||
@@ -955,57 +1535,111 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_git_fetch_readonly,
|
||||
_rule_reviewer_validation_command,
|
||||
_rule_reviewer_validation_failure_history,
|
||||
_rule_reviewer_validation_cwd_proof,
|
||||
_rule_reviewer_validation_structured,
|
||||
_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,
|
||||
_rule_reviewer_mutation_capability_proof,
|
||||
_rule_reviewer_post_merge_cleanup_proof,
|
||||
*_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,
|
||||
_rule_reconcile_eligible_reviewed,
|
||||
_rule_reconcile_linked_issue_live,
|
||||
_rule_reconcile_close_proof,
|
||||
_rule_reconcile_pagination_proof,
|
||||
_rule_reviewer_premerge_baseline_proof,
|
||||
_rule_reviewer_git_fetch_readonly,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
_rule_audit_reconciliation_boundary,
|
||||
],
|
||||
"author_issue": [
|
||||
_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,
|
||||
],
|
||||
"work_issue": [
|
||||
_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,
|
||||
],
|
||||
"issue_filing": [
|
||||
_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,
|
||||
],
|
||||
"issue_selection": [
|
||||
_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,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1068,6 +1702,7 @@ def assess_final_report_validator(
|
||||
issue_filing_lock: dict | None = None,
|
||||
session_pr_opened: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
reconciler_close_lock: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate final-report text against task-specific proof rules (#327).
|
||||
|
||||
@@ -1124,6 +1759,7 @@ def assess_final_report_validator(
|
||||
"local_edits": local_edits,
|
||||
"session_pr_opened": session_pr_opened,
|
||||
"validation_session": validation_session,
|
||||
"reconciler_close_lock": reconciler_close_lock,
|
||||
}
|
||||
|
||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||
|
||||
@@ -52,8 +52,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 +74,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": {
|
||||
|
||||
@@ -104,6 +104,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 +120,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 +130,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
|
||||
|
||||
+4290
-215
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
||||
"""Controller issue-acceptance gate helpers (#500).
|
||||
|
||||
Pure validation for controller acceptance comments and final-report claims
|
||||
that an issue is complete. Does not post comments or close issues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
ACCEPTANCE_HEADING = "controller issue acceptance"
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"STATE",
|
||||
"WHO_IS_NEXT",
|
||||
"NEXT_ACTION",
|
||||
"NEXT_PROMPT",
|
||||
"ISSUE",
|
||||
"MERGED_PR",
|
||||
"MERGE_COMMIT",
|
||||
"ACCEPTANCE_CRITERIA_CHECKED",
|
||||
"VALIDATION_REVIEWED",
|
||||
"CONTROLLER_DECISION",
|
||||
"WHY",
|
||||
)
|
||||
|
||||
ACCEPTED_STATES = frozenset({"accepted"})
|
||||
REJECTION_STATES = frozenset({
|
||||
"more-work-required",
|
||||
"more_work_required",
|
||||
"needs-tests",
|
||||
"needs_tests",
|
||||
"needs-docs",
|
||||
"needs_docs",
|
||||
"needs-feature-enhancement",
|
||||
"needs_feature_enhancement",
|
||||
"needs-follow-up-issue",
|
||||
"needs_follow_up_issue",
|
||||
"blocked",
|
||||
})
|
||||
|
||||
ALLOWED_NEXT_ACTORS = frozenset({
|
||||
"controller",
|
||||
"author",
|
||||
"reviewer",
|
||||
"merger",
|
||||
"reconciler",
|
||||
"user",
|
||||
})
|
||||
|
||||
_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)
|
||||
_ISSUE_REF_RE = re.compile(r"#\d+")
|
||||
_PR_REF_RE = re.compile(r"#\d+")
|
||||
_CHECKED_ITEM_RE = re.compile(r"\[[xX]\]")
|
||||
_UNCHECKED_ITEM_RE = re.compile(r"\[[\s]\]")
|
||||
|
||||
_CLAIMS_ISSUE_COMPLETE_RE = re.compile(
|
||||
r"\bissue\s+(?:is\s+)?(?:complete|completed|accepted|closed\s+as\s+complete|fully\s+satisfied)\b|"
|
||||
r"\bissue\s+acceptance\s*:\s*accepted\b|"
|
||||
r"\bcontroller\s+acceptance\s*:\s*(?:accepted|complete)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_ONLY_COMPLETE_RE = re.compile(
|
||||
r"(?:pr\s+merged|merged\s+pr|merge\s+result\s*:\s*merged).{0,120}"
|
||||
r"(?:issue\s+(?:is\s+)?(?:complete|closed|accepted)|issue\s+complete)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_CLAIMS_CONTROLLER_ACCEPTANCE_RE = re.compile(
|
||||
r"controller\s+issue\s+acceptance|controller\s+acceptance\s+(?:posted|complete|pending)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PENDING_ACCEPTANCE_RE = re.compile(
|
||||
r"controller\s+acceptance\s+(?:pending|required|not\s+(?:yet\s+)?(?:performed|complete))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def render_controller_acceptance_template() -> str:
|
||||
"""Return the canonical controller issue-acceptance comment template."""
|
||||
return """## Controller Issue Acceptance
|
||||
|
||||
STATE:
|
||||
<accepted | more-work-required | needs-tests | needs-docs | needs-feature-enhancement | needs-follow-up-issue | blocked>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<author | reviewer | merger | reconciler | controller | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<one sentence>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next LLM>
|
||||
|
||||
ISSUE:
|
||||
#...
|
||||
|
||||
MERGED_PR:
|
||||
#...
|
||||
|
||||
MERGE_COMMIT:
|
||||
<40-character SHA>
|
||||
|
||||
ACCEPTANCE_CRITERIA_CHECKED:
|
||||
- [x] ...
|
||||
- [ ] ...
|
||||
|
||||
VALIDATION_REVIEWED:
|
||||
<tests/proofs reviewed>
|
||||
|
||||
CONTROLLER_DECISION:
|
||||
<accepted or rejected>
|
||||
|
||||
WHY:
|
||||
<reasoning>
|
||||
|
||||
MISSING_WORK:
|
||||
<none, or exact missing work>
|
||||
|
||||
FOLLOW_UP_ISSUES:
|
||||
<none, or issue list to create>
|
||||
|
||||
BLOCKERS:
|
||||
<none, or exact blockers>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
"""
|
||||
|
||||
|
||||
def extract_acceptance_fields(text: str | None) -> dict[str, str]:
|
||||
"""Return upper-case labeled fields from a controller acceptance 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:
|
||||
existing = fields.get(current_key, "")
|
||||
fields[current_key] = (
|
||||
f"{existing}\n{stripped}" if existing else stripped
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def contains_acceptance_block(text: str | None) -> bool:
|
||||
return ACCEPTANCE_HEADING in (text or "").lower()
|
||||
|
||||
|
||||
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 _normalize_state(value: str | None) -> str:
|
||||
return (value or "").strip().lower().replace(" ", "_").replace("-", "_")
|
||||
|
||||
|
||||
def validate_controller_acceptance_comment(text: str | None) -> dict:
|
||||
"""Validate a controller issue-acceptance comment."""
|
||||
body = text or ""
|
||||
if not contains_acceptance_block(body):
|
||||
return {
|
||||
"valid": False,
|
||||
"fields": {},
|
||||
"reasons": ["missing Controller Issue Acceptance heading"],
|
||||
}
|
||||
|
||||
fields = extract_acceptance_fields(body)
|
||||
reasons: list[str] = []
|
||||
|
||||
for field in REQUIRED_FIELDS:
|
||||
if _empty_or_placeholder(fields.get(field)):
|
||||
reasons.append(f"missing required controller acceptance field: {field}")
|
||||
|
||||
state = _normalize_state(fields.get("STATE"))
|
||||
if state and state not in ACCEPTED_STATES and state not in REJECTION_STATES:
|
||||
reasons.append(
|
||||
"STATE must be accepted or a rejection path "
|
||||
"(more-work-required, needs-tests, needs-docs, "
|
||||
"needs-feature-enhancement, needs-follow-up-issue, blocked)"
|
||||
)
|
||||
|
||||
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 not _ISSUE_REF_RE.search(fields.get("ISSUE") or ""):
|
||||
reasons.append("ISSUE must cite an issue number (#N)")
|
||||
if not _PR_REF_RE.search(fields.get("MERGED_PR") or ""):
|
||||
reasons.append("MERGED_PR must cite a merged PR number (#N)")
|
||||
if not _FULL_SHA_RE.search(fields.get("MERGE_COMMIT") or ""):
|
||||
reasons.append("MERGE_COMMIT must include a full 40-character SHA")
|
||||
|
||||
criteria = fields.get("ACCEPTANCE_CRITERIA_CHECKED") or ""
|
||||
if not _CHECKED_ITEM_RE.search(criteria) and not _UNCHECKED_ITEM_RE.search(criteria):
|
||||
reasons.append(
|
||||
"ACCEPTANCE_CRITERIA_CHECKED must list checked/unchecked criteria items"
|
||||
)
|
||||
|
||||
decision = (fields.get("CONTROLLER_DECISION") or "").strip().lower()
|
||||
if state in ACCEPTED_STATES:
|
||||
if decision not in {"accepted", "accept"}:
|
||||
reasons.append("accepted STATE requires CONTROLLER_DECISION: accepted")
|
||||
if not _CHECKED_ITEM_RE.search(criteria):
|
||||
reasons.append(
|
||||
"accepted STATE requires at least one checked acceptance criterion"
|
||||
)
|
||||
if _empty_or_placeholder(fields.get("WHY")):
|
||||
reasons.append("accepted STATE requires WHY with acceptance rationale")
|
||||
elif state in REJECTION_STATES:
|
||||
if decision not in {"rejected", "reject", "more_work_required"}:
|
||||
reasons.append(
|
||||
"rejection STATE requires CONTROLLER_DECISION: rejected"
|
||||
)
|
||||
if _empty_or_placeholder(fields.get("NEXT_PROMPT")):
|
||||
reasons.append(
|
||||
"rejection STATE requires a paste-ready NEXT_PROMPT for the next actor"
|
||||
)
|
||||
missing = fields.get("MISSING_WORK") or ""
|
||||
if _empty_or_placeholder(missing):
|
||||
reasons.append(
|
||||
"rejection STATE requires MISSING_WORK describing what is still needed"
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"fields": fields,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def claims_issue_complete(text: str | None) -> bool:
|
||||
"""Return True when text claims an issue is complete/accepted."""
|
||||
return bool(_CLAIMS_ISSUE_COMPLETE_RE.search(text or ""))
|
||||
|
||||
|
||||
def claims_merge_only_issue_complete(text: str | None) -> bool:
|
||||
"""Return True when text treats PR merge as issue completion."""
|
||||
return bool(_MERGE_ONLY_COMPLETE_RE.search(text or ""))
|
||||
|
||||
|
||||
def claims_controller_acceptance_update(text: str | None) -> bool:
|
||||
return bool(_CLAIMS_CONTROLLER_ACCEPTANCE_RE.search(text or ""))
|
||||
|
||||
|
||||
def notes_controller_acceptance_pending(text: str | None) -> bool:
|
||||
return bool(_PENDING_ACCEPTANCE_RE.search(text or ""))
|
||||
|
||||
|
||||
def validate_final_report_issue_acceptance(report_text: str | None) -> dict:
|
||||
"""Validate issue-completion and controller-acceptance claims in final reports."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
complete_claim = claims_issue_complete(text)
|
||||
merge_only = claims_merge_only_issue_complete(text)
|
||||
acceptance_claim = claims_controller_acceptance_update(text)
|
||||
pending_noted = notes_controller_acceptance_pending(text)
|
||||
has_block = contains_acceptance_block(text)
|
||||
|
||||
if merge_only and not (has_block and validate_controller_acceptance_comment(text)["valid"]):
|
||||
reasons.append(
|
||||
"final report treats PR merge as issue completion without controller acceptance proof"
|
||||
)
|
||||
|
||||
if complete_claim and not pending_noted:
|
||||
if not has_block:
|
||||
reasons.append(
|
||||
"final report claims issue complete but includes no Controller Issue Acceptance block"
|
||||
)
|
||||
else:
|
||||
result = validate_controller_acceptance_comment(text)
|
||||
if not result["valid"]:
|
||||
reasons.extend(result["reasons"])
|
||||
else:
|
||||
state = _normalize_state(result["fields"].get("STATE"))
|
||||
if state not in ACCEPTED_STATES:
|
||||
reasons.append(
|
||||
"final report claims issue complete but controller STATE is not accepted"
|
||||
)
|
||||
|
||||
if acceptance_claim and has_block:
|
||||
result = validate_controller_acceptance_comment(text)
|
||||
if not result["valid"]:
|
||||
reasons.extend(result["reasons"])
|
||||
|
||||
applicable = complete_claim or merge_only or acceptance_claim or pending_noted
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"valid": not reasons,
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#442 / #443).
|
||||
|
||||
When an issue's own already-pushed branch exists, lock reacquisition must be
|
||||
allowed (adoption) instead of being treated as #400 duplicate competing work.
|
||||
This module isolates the pure decision so it can be unit-tested apart from the
|
||||
MCP server's live Gitea calls.
|
||||
|
||||
Adoption is granted only for the issue's *exact* requested branch. Any other
|
||||
branch that merely contains the same ``issue-<n>`` marker is competing work and
|
||||
stays fail-closed. Open-PR, competing-live-lock, capability, and worktree
|
||||
safety checks are enforced by the caller before this decision is consulted;
|
||||
this module additionally records whether they passed for proof purposes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
ADOPT = "adopt_existing_branch"
|
||||
BLOCK_COMPETING = "block_competing_branch"
|
||||
NO_MATCH = "no_matching_branch"
|
||||
|
||||
# Citable decision labels aligned with the ``assess_own_branch_adoption``
|
||||
# outcomes, surfaced verbatim in the live ``gitea_lock_issue`` response so
|
||||
# recovery reports (#473-style) can quote the lock tool output directly
|
||||
# instead of inferring adoption from separate offline checks (#477).
|
||||
DECISION_LABELS = {
|
||||
ADOPT: "ADOPT",
|
||||
BLOCK_COMPETING: "BLOCK_COMPETING",
|
||||
NO_MATCH: "NO_MATCH",
|
||||
}
|
||||
|
||||
_SAFE_NEXT_ACTIONS = {
|
||||
ADOPT: (
|
||||
"Own existing branch adopted for lock recovery; proceed to "
|
||||
"gitea_create_pr for this issue and cite this adoption proof."
|
||||
),
|
||||
BLOCK_COMPETING: (
|
||||
"Competing same-issue branch(es) exist; resolve branch ownership "
|
||||
"before locking. No adoption performed (fail closed)."
|
||||
),
|
||||
NO_MATCH: (
|
||||
"No existing branch carries this issue marker; normal lock path "
|
||||
"applied. No adoption performed."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def decision_label(outcome: str) -> str:
|
||||
"""Map an ``assess_own_branch_adoption`` outcome to its citable label."""
|
||||
return DECISION_LABELS.get(outcome, "UNKNOWN")
|
||||
|
||||
|
||||
def safe_next_action(outcome: str) -> str:
|
||||
"""Return the safe next action string for an adoption *outcome*."""
|
||||
return _SAFE_NEXT_ACTIONS.get(
|
||||
outcome, "Unknown adoption outcome; treat as fail closed."
|
||||
)
|
||||
|
||||
|
||||
def _branch_name(entry) -> str:
|
||||
if isinstance(entry, dict):
|
||||
return str(entry.get("name") or "")
|
||||
return str(entry or "")
|
||||
|
||||
|
||||
def _branch_sha(entry) -> str | None:
|
||||
if isinstance(entry, dict):
|
||||
sha = entry.get("commit_sha")
|
||||
if sha:
|
||||
return str(sha)
|
||||
return None
|
||||
|
||||
|
||||
def _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
||||
"""Return True when *branch_name* references issue *issue_number* exactly.
|
||||
|
||||
Uses a numeric word-boundary so ``issue-42`` does not match inside
|
||||
``issue-420`` (AC6 / #440).
|
||||
"""
|
||||
name = (branch_name or "").strip()
|
||||
if not name:
|
||||
return False
|
||||
pattern = rf"(?:^|/)issue-{int(issue_number)}(?![0-9])"
|
||||
return re.search(pattern, name) is not None
|
||||
|
||||
|
||||
def assess_own_branch_adoption(
|
||||
*,
|
||||
issue_number: int,
|
||||
requested_branch: str,
|
||||
existing_branches,
|
||||
) -> dict:
|
||||
"""Decide whether an existing matching branch is adoptable.
|
||||
|
||||
Args:
|
||||
issue_number: The tracking issue number being locked.
|
||||
requested_branch: The exact branch the caller wants to lock.
|
||||
existing_branches: Iterable of remote branch entries — either names or
|
||||
dicts with ``name`` and optional ``commit_sha``.
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
* ``outcome`` — one of ADOPT / BLOCK_COMPETING / NO_MATCH
|
||||
* ``adopt`` (bool), ``block`` (bool)
|
||||
* ``reason`` (str)
|
||||
* ``matched_branch`` (str | None), ``matched_head_sha`` (str | None)
|
||||
* ``competing_branches`` (list[str])
|
||||
|
||||
ADOPT: the issue's exact branch exists and no other same-issue branch does.
|
||||
BLOCK_COMPETING: at least one same-issue branch is not the requested branch.
|
||||
NO_MATCH: no branch carries the issue marker — normal lock path applies.
|
||||
"""
|
||||
requested = (requested_branch or "").strip()
|
||||
|
||||
matches: list[tuple[str, str | None]] = []
|
||||
for entry in existing_branches or []:
|
||||
name = _branch_name(entry).strip()
|
||||
if _branch_carries_issue_marker(name, issue_number):
|
||||
matches.append((name, _branch_sha(entry)))
|
||||
|
||||
competing = sorted({name for name, _ in matches if name != requested})
|
||||
exact = [(name, sha) for name, sha in matches if name == requested]
|
||||
|
||||
# Fail closed whenever any non-requested same-issue branch exists, even if
|
||||
# the requested branch is also present: ownership is then ambiguous.
|
||||
if competing:
|
||||
return {
|
||||
"outcome": BLOCK_COMPETING,
|
||||
"adopt": False,
|
||||
"block": True,
|
||||
"reason": (
|
||||
f"issue #{issue_number} already has matching branch(es) "
|
||||
f"{competing} that are not the requested branch "
|
||||
f"'{requested}' (fail closed)"
|
||||
),
|
||||
"matched_branch": None,
|
||||
"matched_head_sha": None,
|
||||
"competing_branches": competing,
|
||||
}
|
||||
|
||||
if exact:
|
||||
name, sha = exact[0]
|
||||
return {
|
||||
"outcome": ADOPT,
|
||||
"adopt": True,
|
||||
"block": False,
|
||||
"reason": (
|
||||
f"existing branch '{name}' is the exact requested branch for "
|
||||
f"issue #{issue_number}; adopting it for lock recovery"
|
||||
),
|
||||
"matched_branch": name,
|
||||
"matched_head_sha": sha,
|
||||
"competing_branches": [],
|
||||
}
|
||||
|
||||
return {
|
||||
"outcome": NO_MATCH,
|
||||
"adopt": False,
|
||||
"block": False,
|
||||
"reason": f"no existing branch matches issue #{issue_number}",
|
||||
"matched_branch": None,
|
||||
"matched_head_sha": None,
|
||||
"competing_branches": [],
|
||||
}
|
||||
|
||||
|
||||
def _matcher_summary(issue_number: int, assessment: dict) -> str:
|
||||
"""Explain, citably, why the assessed branch did or did not qualify.
|
||||
|
||||
Names the numeric word-boundary rule so reports can show that
|
||||
``issue-42`` was not matched inside ``issue-420`` (#440 / #477 AC3).
|
||||
"""
|
||||
outcome = assessment.get("outcome")
|
||||
matched = assessment.get("matched_branch")
|
||||
competing = assessment.get("competing_branches") or []
|
||||
if outcome == ADOPT and matched:
|
||||
return (
|
||||
f"branch '{matched}' exactly matches the issue-{int(issue_number)} "
|
||||
f"marker (numeric word-boundary; 'issue-{int(issue_number)}' is not "
|
||||
f"matched inside 'issue-{int(issue_number)}0')"
|
||||
)
|
||||
if outcome == BLOCK_COMPETING:
|
||||
return (
|
||||
f"competing same-issue branch(es) {competing} carry the "
|
||||
f"issue-{int(issue_number)} marker but are not the requested "
|
||||
f"branch; ownership is ambiguous (fail closed)"
|
||||
)
|
||||
return (
|
||||
f"no existing branch carries the issue-{int(issue_number)} marker "
|
||||
f"under the numeric word-boundary rule"
|
||||
)
|
||||
|
||||
|
||||
def _competing_branch_check(assessment: dict) -> dict:
|
||||
"""Structured competing-branch verdict for the proof block."""
|
||||
competing = list(assessment.get("competing_branches") or [])
|
||||
return {
|
||||
"result": "blocked" if competing else "clear",
|
||||
"competing_branches": competing,
|
||||
}
|
||||
|
||||
|
||||
def build_adoption_proof(
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
assessment: dict,
|
||||
open_pr_checked: bool,
|
||||
competing_lock_checked: bool,
|
||||
lock_file_path: str,
|
||||
lock_file_status: str,
|
||||
) -> dict:
|
||||
"""Assemble the proof block returned by ``gitea_lock_issue`` on adoption.
|
||||
|
||||
Requirement #4: adoption results must carry issue number, branch name,
|
||||
branch head commit, adoption reason, no-existing-PR proof, no-competing-
|
||||
live-lock proof, and lock file path/status.
|
||||
|
||||
#477: additionally surface explicit, citable adoption-proof fields tied to
|
||||
the ``assess_own_branch_adoption`` outcome (``adoption_decision``,
|
||||
``adopted``, ``adopted_branch``, ``adopted_branch_head``,
|
||||
``matcher_summary``, ``competing_branch_check``, ``safe_next_action``) so a
|
||||
recovery session can quote the live lock response directly. The explicit
|
||||
fields are populated for any outcome; ``adopted_branch`` /
|
||||
``adopted_branch_head`` are set only when the outcome is ADOPT so a
|
||||
non-adoption proof can never be misread as claiming adoption.
|
||||
"""
|
||||
outcome = assessment.get("outcome")
|
||||
adopted = outcome == ADOPT
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"branch_head_commit": assessment.get("matched_head_sha"),
|
||||
"adoption_reason": assessment.get("reason"),
|
||||
"no_existing_pr_proof": bool(open_pr_checked),
|
||||
"no_competing_live_lock_proof": bool(competing_lock_checked),
|
||||
"lock_file_path": lock_file_path,
|
||||
"lock_file_status": lock_file_status,
|
||||
# Explicit citable fields (#477).
|
||||
"adoption_decision": decision_label(outcome),
|
||||
"adopted": adopted,
|
||||
"adopted_branch": branch_name if adopted else None,
|
||||
"adopted_branch_head": assessment.get("matched_head_sha") if adopted else None,
|
||||
"matcher_summary": _matcher_summary(issue_number, assessment),
|
||||
"competing_branch_check": _competing_branch_check(assessment),
|
||||
"safe_next_action": safe_next_action(outcome),
|
||||
}
|
||||
|
||||
|
||||
def build_non_adoption_lock_proof(*, issue_number: int, branch_name: str) -> dict:
|
||||
"""Safe, adoption-free proof metadata for a normal (NO_MATCH) lock.
|
||||
|
||||
Requirement #477 AC2: non-adoption lock responses must stay clear and must
|
||||
not imply adoption. This returns explicit ``adopted: False`` metadata with
|
||||
the ``NO_MATCH`` decision so a normal lock response can carry citable proof
|
||||
without ever asserting a branch was adopted.
|
||||
"""
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"adoption_decision": DECISION_LABELS[NO_MATCH],
|
||||
"adopted": False,
|
||||
"adopted_branch": None,
|
||||
"adopted_branch_head": None,
|
||||
"matcher_summary": (
|
||||
f"no existing branch carries the issue-{int(issue_number)} marker; "
|
||||
f"normal lock path (no adoption)"
|
||||
),
|
||||
"competing_branch_check": {"result": "clear", "competing_branches": []},
|
||||
"safe_next_action": safe_next_action(NO_MATCH),
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
"""Keyed, persistent issue-lock storage (#443) with flock hardening (#438).
|
||||
|
||||
Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue
|
||||
lock files under ``GITEA_ISSUE_LOCK_DIR`` (default
|
||||
``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock
|
||||
via a per-process pointer file so concurrent repos/issues never clobber each
|
||||
other. Acquisition is serialized per issue with ``fcntl.flock``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR"
|
||||
DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks")
|
||||
WORK_LEASE_TTL_HOURS = 4
|
||||
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||
|
||||
|
||||
class LockContentionError(RuntimeError):
|
||||
"""Raised when an exclusive per-issue lock cannot be acquired."""
|
||||
|
||||
|
||||
def default_lock_dir() -> str:
|
||||
raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip()
|
||||
return raw or DEFAULT_LOCK_DIR
|
||||
|
||||
|
||||
def _sanitize_segment(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return "_"
|
||||
return _SAFE_SEGMENT_RE.sub("_", text)
|
||||
|
||||
|
||||
def lock_key(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
) -> str:
|
||||
return "-".join(
|
||||
_sanitize_segment(part)
|
||||
for part in (remote, org, repo, str(issue_number))
|
||||
)
|
||||
|
||||
|
||||
def lock_file_path(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
lock_dir: str | None = None,
|
||||
) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
return os.path.join(root, f"{lock_key(remote=remote, org=org, repo=repo, issue_number=issue_number)}.json")
|
||||
|
||||
|
||||
def session_pointer_path(lock_dir: str | None = None) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
return os.path.join(root, f"session-{os.getpid()}.json")
|
||||
|
||||
|
||||
def _ensure_lock_dir(lock_dir: str | None = None) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
os.makedirs(root, mode=0o700, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def flock_path(json_path: str) -> str:
|
||||
return f"{json_path}.lock"
|
||||
|
||||
|
||||
def is_process_alive(pid: int | None) -> bool:
|
||||
if not pid or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
return True
|
||||
except OSError as exc:
|
||||
return exc.errno != errno.ESRCH
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_file_lock(lock_path: str):
|
||||
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise LockContentionError(
|
||||
f"could not acquire exclusive lock on '{lock_path}'"
|
||||
) from exc
|
||||
yield fd
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def read_lock_file(path: str) -> dict[str, Any] | None:
|
||||
lock_path = (path or "").strip()
|
||||
if not lock_path or not os.path.exists(lock_path):
|
||||
return None
|
||||
try:
|
||||
with open(lock_path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def save_lock_file(path: str, data: dict[str, Any]) -> None:
|
||||
lock_path = (path or "").strip()
|
||||
if not lock_path:
|
||||
raise ValueError("lock path is required (fail closed)")
|
||||
parent = os.path.dirname(lock_path) or "."
|
||||
os.makedirs(parent, mode=0o700, exist_ok=True)
|
||||
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".lock-", suffix=".json", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_path, lock_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session."""
|
||||
remote = str(lock_data.get("remote") or "")
|
||||
org = str(lock_data.get("org") or "")
|
||||
repo = str(lock_data.get("repo") or "")
|
||||
issue_number = int(lock_data.get("issue_number") or 0)
|
||||
if not remote or not org or not repo or issue_number <= 0:
|
||||
raise ValueError("lock record must include remote, org, repo, and issue_number")
|
||||
|
||||
root = _ensure_lock_dir(lock_dir)
|
||||
path = lock_file_path(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
lock_dir=root,
|
||||
)
|
||||
record = dict(lock_data)
|
||||
record["lock_file_path"] = path
|
||||
record["session_pid"] = os.getpid()
|
||||
record.setdefault("pid", os.getpid())
|
||||
|
||||
pointer = {
|
||||
"pid": os.getpid(),
|
||||
"lock_file_path": path,
|
||||
"issue_number": issue_number,
|
||||
"branch_name": record.get("branch_name"),
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
}
|
||||
sentinel = flock_path(path)
|
||||
try:
|
||||
with _exclusive_file_lock(sentinel):
|
||||
existing = read_lock_file(path)
|
||||
overwrite_block = assess_foreign_lock_overwrite(existing, record)
|
||||
if overwrite_block:
|
||||
raise RuntimeError(overwrite_block)
|
||||
lease_block = assess_same_issue_lease_conflict(
|
||||
existing,
|
||||
issue_number=issue_number,
|
||||
branch_name=str(record.get("branch_name") or ""),
|
||||
worktree_path=str(record.get("worktree_path") or ""),
|
||||
)
|
||||
if lease_block:
|
||||
raise RuntimeError(lease_block)
|
||||
save_lock_file(path, record)
|
||||
save_lock_file(session_pointer_path(root), pointer)
|
||||
except LockContentionError as exc:
|
||||
competing = read_lock_file(path)
|
||||
if competing:
|
||||
owner_pid = competing.get("session_pid") or competing.get("pid")
|
||||
raise RuntimeError(
|
||||
f"Issue #{issue_number} lock contention: {exc}; competing owner "
|
||||
f"pid={owner_pid} (fail closed)"
|
||||
) from exc
|
||||
raise RuntimeError(f"Issue #{issue_number} lock contention: {exc} (fail closed)") from exc
|
||||
return path
|
||||
|
||||
|
||||
def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
pointer = read_lock_file(session_pointer_path(root))
|
||||
if not pointer:
|
||||
return None
|
||||
lock_path = str(pointer.get("lock_file_path") or "").strip()
|
||||
if not lock_path:
|
||||
return None
|
||||
return read_lock_file(lock_path)
|
||||
|
||||
|
||||
def load_issue_lock(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
return read_lock_file(
|
||||
lock_file_path(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
lock_dir=lock_dir,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def iter_lock_files(lock_dir: str | None = None) -> list[str]:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
paths: list[str] = []
|
||||
for name in os.listdir(root):
|
||||
if not name.endswith(".json") or name.startswith("session-"):
|
||||
continue
|
||||
paths.append(os.path.join(root, name))
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def find_lock_for_branch(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
branch_name: str,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
target = (branch_name or "").strip()
|
||||
if not target:
|
||||
return None
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if (
|
||||
str(lock.get("remote") or "") == remote
|
||||
and str(lock.get("org") or "") == org
|
||||
and str(lock.get("repo") or "") == repo
|
||||
and str(lock.get("branch_name") or "").strip() == target
|
||||
):
|
||||
lock = dict(lock)
|
||||
lock.setdefault("lock_file_path", path)
|
||||
return lock
|
||||
return None
|
||||
|
||||
|
||||
def _lease_now(now: datetime | None = None) -> datetime:
|
||||
return now or datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_lease_timestamp(value: str | None) -> datetime | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None:
|
||||
if not lock:
|
||||
return None
|
||||
lease = lock.get("work_lease")
|
||||
if not isinstance(lease, dict):
|
||||
return None
|
||||
return _parse_lease_timestamp(lease.get("expires_at"))
|
||||
|
||||
|
||||
def is_lease_expired(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
||||
expires = lease_expires_at(lock)
|
||||
if expires is None:
|
||||
return False
|
||||
return expires <= _lease_now(now)
|
||||
|
||||
|
||||
def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
||||
return assess_lock_freshness(lock, now=now)["live"]
|
||||
|
||||
|
||||
def assess_lock_freshness(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify a lock as live, expired, stale, or absent."""
|
||||
current = _lease_now(now)
|
||||
if not lock_data:
|
||||
return {
|
||||
"status": "absent",
|
||||
"live": False,
|
||||
"stale": False,
|
||||
"reason": "no lock record",
|
||||
}
|
||||
|
||||
expires_at = lease_expires_at(lock_data)
|
||||
lease = lock_data.get("work_lease")
|
||||
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
|
||||
if heartbeat_at is None and isinstance(lease, dict):
|
||||
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
|
||||
|
||||
pid = lock_data.get("session_pid")
|
||||
if pid is None:
|
||||
pid = lock_data.get("pid")
|
||||
pid_alive = is_process_alive(pid) if pid is not None else False
|
||||
|
||||
if expires_at and expires_at <= current:
|
||||
return {
|
||||
"status": "expired",
|
||||
"live": False,
|
||||
"stale": True,
|
||||
"reason": f"lease expired at {expires_at.isoformat()}",
|
||||
"pid_alive": pid_alive,
|
||||
}
|
||||
|
||||
if pid is not None and not pid_alive:
|
||||
return {
|
||||
"status": "stale",
|
||||
"live": False,
|
||||
"stale": True,
|
||||
"reason": f"owner pid {pid} is not alive",
|
||||
"pid_alive": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "live",
|
||||
"live": True,
|
||||
"stale": False,
|
||||
"reason": "lock heartbeat and lease are fresh",
|
||||
"pid_alive": pid_alive,
|
||||
"heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None,
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
try:
|
||||
return os.path.realpath(left) == os.path.realpath(right)
|
||||
except OSError:
|
||||
return left == right
|
||||
|
||||
|
||||
def assess_same_issue_lease_conflict(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
|
||||
now: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Return a fail-closed error when a competing live lease blocks acquisition."""
|
||||
if not existing_lock:
|
||||
return None
|
||||
|
||||
existing_issue = existing_lock.get("issue_number")
|
||||
lease = existing_lock.get("work_lease")
|
||||
existing_operation = (
|
||||
lease.get("operation_type")
|
||||
if isinstance(lease, dict)
|
||||
else AUTHOR_ISSUE_WORK_LEASE
|
||||
)
|
||||
if existing_issue != issue_number or existing_operation != operation_type:
|
||||
return None
|
||||
|
||||
existing_branch = existing_lock.get("branch_name")
|
||||
existing_worktree = existing_lock.get("worktree_path")
|
||||
same_owner = (
|
||||
existing_branch == branch_name
|
||||
and _same_realpath(str(existing_worktree or ""), worktree_path)
|
||||
)
|
||||
if is_lease_expired(existing_lock, now=now):
|
||||
return (
|
||||
f"Issue #{issue_number} has an expired {operation_type} lease on "
|
||||
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
|
||||
"Recovery review is required before takeover (fail closed)"
|
||||
)
|
||||
if same_owner:
|
||||
return None
|
||||
return (
|
||||
f"Issue #{issue_number} already has an active {operation_type} lease on "
|
||||
f"branch '{existing_branch}' from worktree '{existing_worktree}' "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
|
||||
def assess_foreign_lock_overwrite(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
incoming_lock: dict[str, Any],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Block writes that would clobber an unrelated live lease on the same key."""
|
||||
if not existing_lock:
|
||||
return None
|
||||
|
||||
same_issue = existing_lock.get("issue_number") == incoming_lock.get("issue_number")
|
||||
same_branch = existing_lock.get("branch_name") == incoming_lock.get("branch_name")
|
||||
same_worktree = _same_realpath(
|
||||
str(existing_lock.get("worktree_path") or ""),
|
||||
str(incoming_lock.get("worktree_path") or ""),
|
||||
)
|
||||
if same_issue and same_branch and same_worktree:
|
||||
return None
|
||||
if not is_lease_live(existing_lock, now=now):
|
||||
return None
|
||||
return (
|
||||
"Refusing to overwrite a live foreign issue lock "
|
||||
f"(issue #{existing_lock.get('issue_number')}, "
|
||||
f"branch '{existing_lock.get('branch_name')}', "
|
||||
f"worktree '{existing_lock.get('worktree_path')}') (fail closed)"
|
||||
)
|
||||
|
||||
|
||||
def find_live_lock_for_branch(
|
||||
branch_name: str,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
target = (branch_name or "").strip()
|
||||
if not target:
|
||||
return None
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if str(lock.get("branch_name") or "").strip() != target:
|
||||
continue
|
||||
if not is_lease_live(lock):
|
||||
continue
|
||||
record = dict(lock)
|
||||
record.setdefault("lock_file_path", path)
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def resolve_locked_branch_for_session(
|
||||
branch_name: str | None = None,
|
||||
lock_dir: str | None = None,
|
||||
) -> str:
|
||||
if branch_name:
|
||||
lock = find_live_lock_for_branch(branch_name, lock_dir)
|
||||
if lock:
|
||||
return str(lock.get("branch_name") or "")
|
||||
lock = read_session_issue_lock(lock_dir)
|
||||
return str((lock or {}).get("branch_name") or "")
|
||||
|
||||
|
||||
def has_active_issue_lock(
|
||||
branch: str,
|
||||
*,
|
||||
lock_dir: str | None = None,
|
||||
) -> bool:
|
||||
target = (branch or "").strip()
|
||||
if not target:
|
||||
return False
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if str(lock.get("branch_name") or "").strip() != target:
|
||||
continue
|
||||
if is_lease_live(lock):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify_lock_for_mutation(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
issue_number: int | None = None,
|
||||
branch_name: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Re-check lock ownership immediately before a mutation (#438)."""
|
||||
reasons: list[str] = []
|
||||
if not lock_data:
|
||||
return {"proven": False, "block": True, "reasons": ["issue lock is missing (fail closed)"]}
|
||||
|
||||
freshness = assess_lock_freshness(lock_data)
|
||||
if not freshness["live"]:
|
||||
reasons.append(f"issue lock is not live: {freshness['reason']} (fail closed)")
|
||||
|
||||
if issue_number is not None and lock_data.get("issue_number") != issue_number:
|
||||
reasons.append(
|
||||
f"issue lock targets #{lock_data.get('issue_number')}, expected #{issue_number} (fail closed)"
|
||||
)
|
||||
|
||||
if branch_name is not None and lock_data.get("branch_name") != branch_name:
|
||||
reasons.append(
|
||||
f"issue lock branch '{lock_data.get('branch_name')}' does not match "
|
||||
f"'{branch_name}' (fail closed)"
|
||||
)
|
||||
|
||||
if worktree_path is not None:
|
||||
locked = os.path.realpath(str(lock_data.get("worktree_path") or ""))
|
||||
declared = os.path.realpath(worktree_path)
|
||||
if locked != declared:
|
||||
reasons.append(
|
||||
f"issue lock worktree '{locked}' does not match declared '{declared}' (fail closed)"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"freshness": freshness,
|
||||
"lock_proof": format_lock_proof(lock_data, freshness=freshness),
|
||||
}
|
||||
|
||||
|
||||
def list_live_locks(
|
||||
*,
|
||||
lock_dir: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return live per-issue locks for queue visibility."""
|
||||
live: list[dict[str, Any]] = []
|
||||
for path in iter_lock_files(lock_dir):
|
||||
record = read_lock_file(path)
|
||||
if not record:
|
||||
continue
|
||||
freshness = assess_lock_freshness(record, now=now)
|
||||
if not freshness["live"]:
|
||||
continue
|
||||
live.append(
|
||||
{
|
||||
"issue_number": record.get("issue_number"),
|
||||
"branch_name": record.get("branch_name"),
|
||||
"remote": record.get("remote"),
|
||||
"org": record.get("org"),
|
||||
"repo": record.get("repo"),
|
||||
"worktree_path": record.get("worktree_path"),
|
||||
"pid": record.get("session_pid") or record.get("pid"),
|
||||
"claimant": (
|
||||
record.get("claimant")
|
||||
or (record.get("work_lease") or {}).get("claimant")
|
||||
),
|
||||
"freshness": freshness,
|
||||
"lock_path": record.get("lock_file_path") or path,
|
||||
}
|
||||
)
|
||||
return live
|
||||
|
||||
|
||||
def format_lock_proof(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
freshness: dict[str, Any] | None = None,
|
||||
competing_live_locks: list[dict[str, Any]] | None = None,
|
||||
released: bool | None = None,
|
||||
) -> str:
|
||||
"""Canonical issue-lock proof string for final reports."""
|
||||
if not lock_data:
|
||||
return "issue lock proof: not acquired"
|
||||
fresh = freshness or assess_lock_freshness(lock_data)
|
||||
owner = lock_data.get("claimant") or {}
|
||||
if not owner and isinstance(lock_data.get("work_lease"), dict):
|
||||
owner = lock_data["work_lease"].get("claimant") or {}
|
||||
parts = [
|
||||
"issue lock proof:",
|
||||
f"acquired issue #{lock_data.get('issue_number')}",
|
||||
f"branch {lock_data.get('branch_name')}",
|
||||
f"owner {owner.get('profile') or 'unknown'}",
|
||||
f"pid {lock_data.get('session_pid') or lock_data.get('pid')}",
|
||||
f"freshness {fresh.get('status')}",
|
||||
]
|
||||
if competing_live_locks is not None:
|
||||
parts.append(
|
||||
"no competing live lock"
|
||||
if not competing_live_locks
|
||||
else f"competing live locks {len(competing_live_locks)}"
|
||||
)
|
||||
if released is True:
|
||||
parts.append("lock released")
|
||||
elif released is False:
|
||||
parts.append("lock retained")
|
||||
return "; ".join(parts)
|
||||
+26
-5
@@ -30,8 +30,16 @@ def resolve_author_worktree_path(
|
||||
return os.path.realpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def read_worktree_git_state(worktree_path: str) -> dict:
|
||||
"""Read branch name and porcelain status from a git worktree."""
|
||||
def read_worktree_git_state(
|
||||
worktree_path: str,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
) -> dict:
|
||||
"""Read branch name and porcelain status from a git worktree.
|
||||
|
||||
``extra_bases`` names additional branches (e.g. an approved stacked base)
|
||||
that may anchor base-equivalence in addition to master/main/dev. When empty
|
||||
(the default), only the normal base branches are considered.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
if not path:
|
||||
return {"current_branch": None, "porcelain_status": ""}
|
||||
@@ -63,7 +71,7 @@ def read_worktree_git_state(worktree_path: str) -> dict:
|
||||
check=False,
|
||||
)
|
||||
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
|
||||
base_branch, base_sha = _find_matching_base_ref(path, head_sha)
|
||||
base_branch, base_sha = _find_matching_base_ref(path, head_sha, extra_bases)
|
||||
return {
|
||||
"current_branch": current_branch,
|
||||
"porcelain_status": status_res.stdout or "",
|
||||
@@ -203,13 +211,26 @@ def _assessment(
|
||||
}
|
||||
|
||||
|
||||
def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
|
||||
"""Return the stable branch ref whose commit matches HEAD, if any."""
|
||||
def _find_matching_base_ref(
|
||||
path: str,
|
||||
head_sha: str | None,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return the stable branch ref whose commit matches HEAD, if any.
|
||||
|
||||
Normal base branches (master/main/dev) are always considered. ``extra_bases``
|
||||
adds explicitly-approved stacked bases; each is checked as a local ref and via
|
||||
the ``prgs``/``origin`` remotes.
|
||||
"""
|
||||
if not head_sha:
|
||||
return None, None
|
||||
candidates: list[str] = []
|
||||
for branch in sorted(BASE_BRANCHES):
|
||||
candidates.extend((f"origin/{branch}", branch))
|
||||
for branch in extra_bases:
|
||||
name = (branch or "").strip()
|
||||
if name:
|
||||
candidates.extend((f"prgs/{name}", f"origin/{name}", name))
|
||||
for ref in candidates:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--verify", ref],
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""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: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)",
|
||||
),
|
||||
)
|
||||
|
||||
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
TYPE_LABEL_SPECS + STATUS_LABEL_SPECS + VALIDATION_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
|
||||
)
|
||||
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",
|
||||
"pr_open": "status:pr-open",
|
||||
"pr-open": "status:pr-open",
|
||||
"approved": "status:approved",
|
||||
"merge": "status:reconcile",
|
||||
"merged": "status:reconcile",
|
||||
"reconcile": "status:reconcile",
|
||||
"done": "status:done",
|
||||
"complete": "status:done",
|
||||
"duplicate": "status:duplicate",
|
||||
"wontfix": "status:wontfix",
|
||||
}
|
||||
|
||||
|
||||
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 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)
|
||||
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)
|
||||
)
|
||||
|
||||
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}'")
|
||||
|
||||
return {
|
||||
"valid": not errors,
|
||||
"labels": names,
|
||||
"type_labels": found_types,
|
||||
"status_labels": found_statuses,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
+5
-2
@@ -23,6 +23,7 @@ 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
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Master-parity staleness gate (#420).
|
||||
|
||||
The Gitea MCP server loads its capability-gate code and workflow logic into
|
||||
memory when the process starts. When ``master`` advances -- for example a newly
|
||||
merged security gate such as the branch-delete capability gate (#408/#410) --
|
||||
the running process keeps executing the *old* code until it is restarted. A
|
||||
stale server can therefore still perform a mutation that the updated codebase
|
||||
would forbid.
|
||||
|
||||
Runtime profile/config data is already read live from disk on every call
|
||||
(``gitea_config.load_config`` re-reads the JSON file each time), so profile
|
||||
``allowed_operations`` changes take effect immediately without a restart. The
|
||||
gap this module closes is *code* parity: it captures the server process's
|
||||
source-tree commit at startup and detects, at mutation time, when the on-disk
|
||||
``master`` HEAD has advanced past it. Detected staleness fails closed with a
|
||||
restart-required recovery report, while read-only operations stay allowed so a
|
||||
stale server can still be inspected.
|
||||
|
||||
The core assessment is pure -- callers inject the observed HEAD SHAs -- so the
|
||||
logic is fully unit-testable without a git checkout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Environment escape hatches (ops + tests):
|
||||
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
|
||||
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
|
||||
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
|
||||
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
|
||||
|
||||
|
||||
def read_git_head(root: str) -> str | None:
|
||||
"""Return the current ``HEAD`` commit SHA of *root*, or ``None``.
|
||||
|
||||
``None`` means the SHA could not be determined (not a git checkout, git
|
||||
unavailable, or an error). A test override via ``GITEA_TEST_CURRENT_HEAD``
|
||||
takes precedence so the gate can be exercised deterministically.
|
||||
"""
|
||||
forced = os.environ.get(ENV_TEST_CURRENT_HEAD)
|
||||
if forced is not None:
|
||||
return forced.strip() or None
|
||||
if not root:
|
||||
return None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
return (res.stdout or "").strip() or None
|
||||
|
||||
|
||||
def capture_startup_parity(root: str, head: str | None = None) -> dict:
|
||||
"""Capture the process source-tree baseline once at server startup.
|
||||
|
||||
*head* may be injected (tests); otherwise it is read from *root*. The result
|
||||
is an opaque baseline handed back to :func:`assess_master_parity`.
|
||||
"""
|
||||
startup_head = head if head is not None else read_git_head(root)
|
||||
return {"root": root, "startup_head": startup_head}
|
||||
|
||||
|
||||
def _short(sha: str | None) -> str:
|
||||
return sha[:12] if sha else "unknown"
|
||||
|
||||
|
||||
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
|
||||
"""Compare the startup baseline against the current on-disk ``HEAD``.
|
||||
|
||||
Pure: both HEADs are supplied by the caller. Returns a structured result:
|
||||
|
||||
- ``in_parity`` -- server code matches the on-disk master (or parity
|
||||
could not be determined, which is not treated as stale).
|
||||
- ``stale`` -- the on-disk master has definitively advanced past the
|
||||
running process.
|
||||
- ``restart_required`` -- alias of ``stale``; the recovery action.
|
||||
- ``determinable`` -- whether both HEADs were known well enough to compare.
|
||||
- ``startup_head`` / ``current_head`` / ``reasons``.
|
||||
"""
|
||||
startup_head = (startup or {}).get("startup_head")
|
||||
reasons: list[str] = []
|
||||
|
||||
if startup_head is None:
|
||||
reasons.append(
|
||||
"startup commit was not captured; code parity cannot be enforced")
|
||||
return _result(True, False, False, startup_head, current_head, reasons)
|
||||
|
||||
if current_head is None:
|
||||
reasons.append(
|
||||
"current workspace HEAD could not be read; code parity cannot be "
|
||||
"enforced")
|
||||
return _result(True, False, False, startup_head, current_head, reasons)
|
||||
|
||||
if startup_head == current_head:
|
||||
return _result(True, False, True, startup_head, current_head, reasons)
|
||||
|
||||
reasons.append(
|
||||
f"MCP server started at commit {_short(startup_head)} but the workspace "
|
||||
f"master is now {_short(current_head)}; restart the server to load the "
|
||||
f"current capability gates")
|
||||
return _result(False, True, True, startup_head, current_head, reasons)
|
||||
|
||||
|
||||
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
|
||||
return {
|
||||
"in_parity": in_parity,
|
||||
"stale": stale,
|
||||
"restart_required": stale,
|
||||
"determinable": determinable,
|
||||
"startup_head": startup_head,
|
||||
"current_head": current_head,
|
||||
"reasons": list(reasons),
|
||||
}
|
||||
|
||||
|
||||
def gate_disabled() -> bool:
|
||||
"""Whether the parity gate is disabled by env escape hatch."""
|
||||
return bool((os.environ.get(ENV_DISABLE) or "").strip())
|
||||
|
||||
|
||||
def parity_block_reasons(assessment: dict) -> list[str]:
|
||||
"""Block reasons for a mutation gate (empty when the mutation may proceed).
|
||||
|
||||
A disabled gate or an in-parity / non-determinable assessment yields no
|
||||
reasons; only a definitively stale server blocks.
|
||||
"""
|
||||
if gate_disabled():
|
||||
return []
|
||||
if assessment.get("stale"):
|
||||
return list(assessment.get("reasons") or
|
||||
["server code is stale relative to master (fail closed)"])
|
||||
return []
|
||||
|
||||
|
||||
def parity_report(assessment: dict) -> dict:
|
||||
"""Structured stale-server report for permission-block payloads."""
|
||||
return {
|
||||
"kind": "server_stale",
|
||||
"restart_required": True,
|
||||
"startup_head": assessment.get("startup_head"),
|
||||
"current_head": assessment.get("current_head"),
|
||||
"reasons": list(assessment.get("reasons") or []),
|
||||
"recovery": [
|
||||
"The running MCP server is executing code older than the current "
|
||||
"master and may not enforce newly merged capability gates.",
|
||||
"Restart the Gitea MCP server so it reloads master's capability "
|
||||
"gates and execution profiles before retrying the mutation.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def format_parity(assessment: dict) -> str:
|
||||
"""One-line human summary for logs / runtime context."""
|
||||
if assessment.get("stale"):
|
||||
return (f"STALE: started {_short(assessment.get('startup_head'))}, "
|
||||
f"master now {_short(assessment.get('current_head'))} "
|
||||
f"(restart required)")
|
||||
if not assessment.get("determinable"):
|
||||
return "parity indeterminate (baseline or current HEAD unknown)"
|
||||
return f"in parity at {_short(assessment.get('current_head'))}"
|
||||
Executable
+269
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env bash
|
||||
# mcp-menu.sh — Repository-root operator menu for MCP/Gitea workflow onboarding.
|
||||
#
|
||||
# Safe by default: read-only status and copy-paste prompts unless an action is
|
||||
# explicitly labeled and confirmed. No branch deletion, force-push, lock-file
|
||||
# editing, or raw API bypass.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$SCRIPT_DIR"
|
||||
|
||||
pause() {
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
}
|
||||
|
||||
print_banner() {
|
||||
printf '\n=== Gitea-Tools MCP Operator Menu ===\n'
|
||||
printf 'Repository: %s\n' "$REPO_ROOT"
|
||||
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
|
||||
}
|
||||
|
||||
show_root_checkout_health() {
|
||||
printf '\n--- Project status / root checkout health ---\n\n'
|
||||
printf 'Current directory: %s\n' "$(pwd)"
|
||||
local branch head_sha prgs_master_sha dirty
|
||||
branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)"
|
||||
if [[ -z "$branch" ]]; then
|
||||
branch="(detached HEAD)"
|
||||
fi
|
||||
printf 'Current branch: %s\n' "$branch"
|
||||
printf '\nGit status (short, branch):\n'
|
||||
git -C "$REPO_ROOT" status --short --branch || true
|
||||
head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')"
|
||||
printf '\nHEAD SHA: %s\n' "$head_sha"
|
||||
if git -C "$REPO_ROOT" rev-parse --verify prgs/master >/dev/null 2>&1; then
|
||||
prgs_master_sha="$(git -C "$REPO_ROOT" rev-parse prgs/master)"
|
||||
printf 'prgs/master SHA: %s\n' "$prgs_master_sha"
|
||||
if [[ "$head_sha" != "$prgs_master_sha" ]]; then
|
||||
printf '\nWARNING: root checkout HEAD does not match prgs/master.\n'
|
||||
printf 'Keep the stable control checkout on master/prgs/master.\n'
|
||||
fi
|
||||
else
|
||||
printf 'prgs/master SHA: unavailable (remote ref not fetched)\n'
|
||||
fi
|
||||
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null || true)" ]]; then
|
||||
printf '\nWARNING: root checkout has uncommitted changes (dirty).\n'
|
||||
printf 'Author mutations belong in a session worktree under branches/.\n'
|
||||
fi
|
||||
if [[ "$branch" != "master" && "$branch" != "main" && "$branch" != "dev" ]]; then
|
||||
printf '\nWARNING: root checkout is not on a stable base branch (master/main/dev).\n'
|
||||
printf 'Return to master before using the control checkout.\n'
|
||||
fi
|
||||
pause
|
||||
}
|
||||
|
||||
print_prompt_block() {
|
||||
local title="$1"
|
||||
local body="$2"
|
||||
printf '\n--- %s ---\n\n' "$title"
|
||||
printf '%s\n' "$body"
|
||||
printf '\n(Copy the prompt above into your LLM session.)\n'
|
||||
pause
|
||||
}
|
||||
|
||||
show_author_prompts() {
|
||||
while true; do
|
||||
printf '\n--- Author workflow prompts ---\n'
|
||||
printf ' 1) Work issue (author/coder)\n'
|
||||
printf ' 2) Conflict-fix author session\n'
|
||||
printf ' 3) Root checkout recovery session\n'
|
||||
printf ' 0) Back\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1)
|
||||
print_prompt_block "Author — work issue" \
|
||||
"You are the AUTHOR session for <org>/<repo>.
|
||||
|
||||
Goal: implement issue #<N> only.
|
||||
|
||||
Workflow:
|
||||
1. Preflight: prove identity, work_issue/create_pr capability, clean session worktree under branches/.
|
||||
2. gitea_lock_issue for issue #<N> and branch feat/issue-<N>-<short-desc>.
|
||||
3. Implement in the locked worktree only — never mutate the root control checkout.
|
||||
4. Validate, commit, push, gitea_create_pr. Final report with issue, branch, SHA, PR, tests, mutation ledger."
|
||||
;;
|
||||
2)
|
||||
print_prompt_block "Author — conflict-fix session" \
|
||||
"You are the AUTHOR session for <org>/<repo> in conflict-fix mode.
|
||||
|
||||
Goal: resolve merge conflicts on PR #<P> / branch <branch> only.
|
||||
|
||||
Workflow:
|
||||
1. Preflight: prove author identity and exact push/commit capability for the locked PR branch.
|
||||
2. Confirm conflict-fix lease and stale-head protection before pushing.
|
||||
3. Work only in the session-owned worktree under branches/ — never the root checkout.
|
||||
4. Rebase or merge target branch, run tests, push, update PR. No force-push without explicit operator approval.
|
||||
5. Final report: conflict resolution proof, new HEAD SHA, tests, mutation ledger."
|
||||
;;
|
||||
3)
|
||||
print_prompt_block "Author — root checkout recovery" \
|
||||
"You are a RECOVERY session for <org>/<repo>.
|
||||
|
||||
Goal: restore the stable root control checkout to clean master/prgs/master.
|
||||
|
||||
Workflow:
|
||||
1. Inspect root checkout: branch, git status --short --branch, HEAD vs prgs/master.
|
||||
2. Do not implement features from the root checkout. Stash or move work to branches/<session> first.
|
||||
3. Return root to master (or main/dev per project policy) matching prgs/master with no dirty tracked files.
|
||||
4. Report before/after branch, SHA, dirty state, and safe next action for author worktree creation."
|
||||
;;
|
||||
0) return ;;
|
||||
*) printf 'Invalid choice.\n' ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_reviewer_prompts() {
|
||||
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.
|
||||
|
||||
Workflow:
|
||||
1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md
|
||||
2. Preflight: prove reviewer identity, review_pr capability, clean review worktree.
|
||||
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() {
|
||||
print_prompt_block "Merger — PR merge" \
|
||||
"You are the MERGER session for <org>/<repo>.
|
||||
|
||||
Goal: merge PR #<P> only after every gate passes.
|
||||
|
||||
Workflow:
|
||||
1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md
|
||||
2. Preflight: prove merger identity and exact merge_pr capability for the current PR head SHA.
|
||||
3. Confirm approval pins the current head SHA; re-validate if the branch moved.
|
||||
4. gitea_merge_pr only on explicit operator approval after all gates pass.
|
||||
5. Final report: merged SHA, cleanup handoff, mutation ledger."
|
||||
}
|
||||
|
||||
show_reconciler_prompts() {
|
||||
print_prompt_block "Reconciler — already-landed / closed PR cleanup" \
|
||||
"You are the RECONCILER session for <org>/<repo>.
|
||||
|
||||
Goal: reconcile already-landed open PRs — close or comment only when exact capability is proven.
|
||||
|
||||
Workflow:
|
||||
1. Load canonical workflow: skills/llm-project-workflow/workflows/reconcile-landed-pr.md
|
||||
2. Preflight: prove reconciler identity and gitea.pr.close (or authorized close) capability.
|
||||
3. Do not review, merge, implement code, or create branches.
|
||||
4. gitea_scan_already_landed_open_prs / gitea_reconcile_already_landed_pr as appropriate.
|
||||
5. Final report: PR numbers handled, close proof, mutation ledger."
|
||||
}
|
||||
|
||||
show_onboarding_prompt() {
|
||||
print_prompt_block "Onboarding — new project to MCP workflow" \
|
||||
"Onboard <org>/<repo> into the MCP Control Plane workflow.
|
||||
|
||||
Checklist:
|
||||
1. Prove identity and task capability via gitea_whoami and gitea_resolve_task_capability.
|
||||
2. Configure separate MCP namespaces/profiles: author, reviewer, merger/reconciler as needed.
|
||||
3. Register gitea-tools (and jenkins-mcp / glitchtip-mcp if applicable) in the client MCP config.
|
||||
4. Copy skills/llm-project-workflow/SKILL.md guidance into the target repo or ECC install.
|
||||
5. Verify gitea_get_runtime_context, gitea_lock_issue, and worktree rules under branches/.
|
||||
6. Run ./mcp-menu.sh for day-to-day prompts; use docs/mcp-menu.md and docs/llm-workflow-runbooks.md.
|
||||
|
||||
Canonical router: skills/llm-project-workflow/SKILL.md"
|
||||
}
|
||||
|
||||
show_proxmox_placeholder() {
|
||||
printf '\n--- Proxmox deployment (placeholder) ---\n\n'
|
||||
printf 'Push this project to Proxmox — TODO / issue-backed\n'
|
||||
printf 'Create Proxmox LXC — TODO / issue-backed\n\n'
|
||||
printf 'These actions are NOT implemented yet.\n'
|
||||
printf 'Track deployment automation in dedicated Gitea issues before enabling here.\n'
|
||||
printf 'This menu will not run deploy scripts until sanctioned tooling exists.\n'
|
||||
pause
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
printf '\n--- Run tests ---\n\n'
|
||||
if [[ -x "$REPO_ROOT/run-tests.sh" ]]; then
|
||||
printf 'Running ./run-tests.sh ...\n\n'
|
||||
(cd "$REPO_ROOT" && ./run-tests.sh)
|
||||
pause
|
||||
return
|
||||
fi
|
||||
if [[ -x "$REPO_ROOT/venv/bin/python" ]]; then
|
||||
printf 'run-tests.sh not found; falling back to venv/bin/python -m pytest\n\n'
|
||||
(cd "$REPO_ROOT" && ./venv/bin/python -m pytest)
|
||||
pause
|
||||
return
|
||||
fi
|
||||
printf 'ERROR: No test runner available (fail closed).\n' >&2
|
||||
printf 'Expected ./run-tests.sh or venv/bin/python for pytest fallback.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
main_menu() {
|
||||
while true; do
|
||||
print_banner
|
||||
printf ' 1) Project status / root checkout health\n'
|
||||
printf ' 2) Author workflow prompts\n'
|
||||
printf ' 3) Reviewer workflow prompts\n'
|
||||
printf ' 4) Merger workflow prompts\n'
|
||||
printf ' 5) Reconciler workflow prompts\n'
|
||||
printf ' 6) Onboarding new project to this MCP workflow\n'
|
||||
printf ' 7) Proxmox deployment menu placeholder\n'
|
||||
printf ' 8) Create Proxmox LXC placeholder\n'
|
||||
printf ' 9) Run tests\n'
|
||||
printf ' 0) Exit\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1) show_root_checkout_health ;;
|
||||
2) show_author_prompts ;;
|
||||
3) show_reviewer_prompts ;;
|
||||
4) show_merger_prompts ;;
|
||||
5) show_reconciler_prompts ;;
|
||||
6) show_onboarding_prompt ;;
|
||||
7|8) show_proxmox_placeholder ;;
|
||||
9) run_tests ;;
|
||||
0) printf 'Goodbye.\n'; exit 0 ;;
|
||||
*) printf 'Invalid choice.\n'; pause ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
main_menu
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Sanctioned MCP daemon guards for imports and credential access (#558).
|
||||
|
||||
Direct ``import gitea_mcp_server`` / ``import gitea_auth`` from a shell, plus
|
||||
raw keychain dumps, bypass preflight purity and role gates. Mutation helpers
|
||||
and keychain fallbacks therefore require an explicit sanctioned runtime.
|
||||
|
||||
Sanctioned contexts (any one):
|
||||
- ``GITEA_MCP_SANCTIONED_DAEMON=1`` (set by the official MCP entrypoint)
|
||||
- pytest (``PYTEST_CURRENT_TEST`` present)
|
||||
- explicit operator opt-in ``GITEA_ALLOW_DIRECT_MCP_IMPORT=1`` (tests/tools only)
|
||||
|
||||
Credential keychain fill additionally allows:
|
||||
- ``GITEA_ALLOW_KEYCHAIN_CLI=1`` for operator-only non-MCP scripts that must
|
||||
use git-credential (never the default for LLM shells).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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"
|
||||
|
||||
|
||||
class UnsanctionedRuntimeError(RuntimeError):
|
||||
"""Raised when mutation/credential code runs outside a sanctioned MCP daemon."""
|
||||
|
||||
|
||||
def is_pytest_runtime() -> bool:
|
||||
return bool((os.environ.get("PYTEST_CURRENT_TEST") or "").strip())
|
||||
|
||||
|
||||
def is_sanctioned_mcp_daemon() -> bool:
|
||||
if (os.environ.get(SANCTIONED_DAEMON_ENV) or "").strip() in {"1", "true", "yes"}:
|
||||
return True
|
||||
if (os.environ.get(ALLOW_DIRECT_IMPORT_ENV) or "").strip() in {"1", "true", "yes"}:
|
||||
return True
|
||||
if is_pytest_runtime():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def mark_sanctioned_daemon() -> None:
|
||||
"""Call from the official MCP server entrypoint before serving tools."""
|
||||
os.environ[SANCTIONED_DAEMON_ENV] = "1"
|
||||
|
||||
|
||||
def assert_sanctioned_mutation_runtime(context: str = "mutation") -> None:
|
||||
"""Fail closed when server mutation code is used outside the MCP daemon."""
|
||||
if is_sanctioned_mcp_daemon():
|
||||
return
|
||||
raise UnsanctionedRuntimeError(
|
||||
f"Unsanctioned runtime blocked {context} (#558). "
|
||||
"Do not import gitea_mcp_server / call mutation helpers from a raw "
|
||||
"shell or ad-hoc script. Use the official MCP daemon entrypoint "
|
||||
f"(sets {SANCTIONED_DAEMON_ENV}=1), or run under pytest. "
|
||||
f"Operator-only override: {ALLOW_DIRECT_IMPORT_ENV}=1 (not for LLM sessions)."
|
||||
)
|
||||
|
||||
|
||||
def assert_keychain_access_allowed() -> None:
|
||||
"""Fail closed for git-credential keychain fill outside sanctioned contexts."""
|
||||
if is_sanctioned_mcp_daemon():
|
||||
return
|
||||
if (os.environ.get(ALLOW_KEYCHAIN_CLI_ENV) or "").strip() in {"1", "true", "yes"}:
|
||||
return
|
||||
raise UnsanctionedRuntimeError(
|
||||
"Unsanctioned keychain/credential fill blocked (#558). "
|
||||
"Token extraction via git-credential is only allowed inside the "
|
||||
f"official MCP daemon ({SANCTIONED_DAEMON_ENV}=1), pytest, or with "
|
||||
f"explicit operator opt-in {ALLOW_KEYCHAIN_CLI_ENV}=1."
|
||||
)
|
||||
|
||||
|
||||
def runtime_status() -> dict[str, Any]:
|
||||
return {
|
||||
"sanctioned_daemon": is_sanctioned_mcp_daemon(),
|
||||
"pytest": is_pytest_runtime(),
|
||||
"sanctioned_env": SANCTIONED_DAEMON_ENV,
|
||||
"allow_direct_import_env": ALLOW_DIRECT_IMPORT_ENV,
|
||||
"allow_keychain_cli_env": ALLOW_KEYCHAIN_CLI_ENV,
|
||||
}
|
||||
@@ -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 []
|
||||
@@ -0,0 +1,359 @@
|
||||
"""MCP-native post-merge cleanup proof verifier (#517).
|
||||
|
||||
Post-merge cleanup of leases, comments, branches, and worktrees must be
|
||||
proven through explicit MCP tools (or approved helpers), never raw scripts or
|
||||
ad hoc git/API commands. Merger/reviewer sessions must hand cleanup to a
|
||||
reconciler profile with the right capability proof.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
AUTHORIZED_CLEANUP_TOOLS = frozenset({
|
||||
"gitea_cleanup_post_merge_moot_lease",
|
||||
"gitea_reconcile_merged_cleanups",
|
||||
"gitea_delete_branch",
|
||||
"gitea_cleanup_merged_pr_branch",
|
||||
"gitea_audit_worktree_cleanup",
|
||||
"gitea_capture_branches_worktree_snapshot",
|
||||
"gitea_assess_worktree_cleanup_integrity",
|
||||
"gitea_authorize_reconciliation_cleanup_phase",
|
||||
})
|
||||
|
||||
_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),
|
||||
)
|
||||
|
||||
_RAW_COMMENT_DELETE_PATTERNS = (
|
||||
re.compile(
|
||||
r"(?:curl|wget|httpie)\b[^\n\r]*\b(?:DELETE|delete)\b[^\n\r]*"
|
||||
r"(?:/comments/|issues/\d+/comments)",
|
||||
re.I,
|
||||
),
|
||||
re.compile(
|
||||
r"\bDELETE\b[^\n\r]*/repos/[^\n\r]*/issues/\d+/comments/\d+",
|
||||
re.I,
|
||||
),
|
||||
re.compile(
|
||||
r"\b(?:delete_issue_comment|remove_issue_comment|purge_comments?)\b",
|
||||
re.I,
|
||||
),
|
||||
re.compile(
|
||||
r"\b(?:raw|ad hoc|adhoc)\b[^\n\r]{0,40}\bcomment\b[^\n\r]{0,40}\bdelete",
|
||||
re.I,
|
||||
),
|
||||
)
|
||||
|
||||
_CLEANUP_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_GIT_REF_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*git ref mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_WORKTREE_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*worktree mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_MERGE_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_NONE_LEDGER_VALUES = frozenset({"", "none", "n/a"})
|
||||
_MUTATION_LEDGER_PATTERNS = (
|
||||
_CLEANUP_MUTATIONS_RE,
|
||||
_GIT_REF_MUTATIONS_RE,
|
||||
_WORKTREE_MUTATIONS_RE,
|
||||
)
|
||||
_RAW_WORKTREE_REMOVE_PATTERNS = (
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+worktree\s+remove\b", re.I),
|
||||
)
|
||||
_AUTHORIZED_TOOL_RE = re.compile(
|
||||
r"\b(" + "|".join(re.escape(t) for t in sorted(AUTHORIZED_CLEANUP_TOOLS)) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RECONCILER_CAPABILITY_RE = re.compile(
|
||||
r"(?:reconciler|gitea\.branch\.delete|delete_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGER_CLEANUP_ROLE_RE = re.compile(
|
||||
r"(?:merger|reviewer).{0,80}(?:deleted|removed|cleaned).{0,80}"
|
||||
r"(?:branch|worktree|comment|lease)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_HANDOFF_TO_RECONCILER_RE = re.compile(
|
||||
r"(?:hand(?:ed)? off|defer(?:red)?|next actor).{0,60}reconciler",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _ledger_field_body(text: str, pattern: re.Pattern[str]) -> str | None:
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return (match.group(1) or "").strip()
|
||||
|
||||
|
||||
def _is_none_ledger_value(value: str) -> bool:
|
||||
return value.strip().lower() in _NONE_LEDGER_VALUES
|
||||
|
||||
|
||||
def scoped_cleanup_mutation_ledger_text(text: str | None) -> str:
|
||||
"""Return mutation-ledger bodies scoped to cleanup enforcement (#517)."""
|
||||
text = text or ""
|
||||
parts: list[str] = []
|
||||
for pattern in _MUTATION_LEDGER_PATTERNS:
|
||||
body = _ledger_field_body(text, pattern)
|
||||
if body is not None and not _is_none_ledger_value(body):
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _git_ref_cleanup_claimed(body: str) -> bool:
|
||||
return bool(raw_branch_delete_commands(body))
|
||||
|
||||
|
||||
def _worktree_cleanup_claimed(body: str) -> bool:
|
||||
for pattern in _RAW_WORKTREE_REMOVE_PATTERNS:
|
||||
if pattern.search(body):
|
||||
return True
|
||||
return bool(
|
||||
re.search(
|
||||
r"\b(?:removed|deleted)\b[^\n]{0,40}\bworktree\b",
|
||||
body,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_claiming_ledger_fields(text: str) -> list[tuple[str, str]]:
|
||||
claiming: list[tuple[str, str]] = []
|
||||
cleanup_body = _ledger_field_body(text, _CLEANUP_MUTATIONS_RE)
|
||||
if cleanup_body is not None and not _is_none_ledger_value(cleanup_body):
|
||||
claiming.append(("Cleanup mutations", cleanup_body))
|
||||
|
||||
git_ref_body = _ledger_field_body(text, _GIT_REF_MUTATIONS_RE)
|
||||
if git_ref_body is not None and not _is_none_ledger_value(git_ref_body):
|
||||
if _git_ref_cleanup_claimed(git_ref_body):
|
||||
claiming.append(("Git ref mutations", git_ref_body))
|
||||
|
||||
worktree_body = _ledger_field_body(text, _WORKTREE_MUTATIONS_RE)
|
||||
if worktree_body is not None and not _is_none_ledger_value(worktree_body):
|
||||
if _worktree_cleanup_claimed(worktree_body):
|
||||
claiming.append(("Worktree mutations", worktree_body))
|
||||
return claiming
|
||||
|
||||
|
||||
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 raw_comment_delete_commands(text: str | None) -> list[str]:
|
||||
"""Return raw comment-deletion commands/scripts cited in *text*."""
|
||||
if not text:
|
||||
return []
|
||||
commands: list[str] = []
|
||||
for pattern in _RAW_COMMENT_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 mutation ledgers cite raw git branch deletion."""
|
||||
commands = raw_branch_delete_commands(scoped_cleanup_mutation_ledger_text(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_delete_branch, gitea_reconcile_merged_cleanups, or another "
|
||||
"approved MCP cleanup helper with explicit branch.delete capability"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_raw_comment_delete_report(text: str | None) -> dict[str, Any]:
|
||||
"""Fail closed when mutation ledgers cite raw comment deletion."""
|
||||
commands = raw_comment_delete_commands(scoped_cleanup_mutation_ledger_text(text))
|
||||
reasons = [
|
||||
(
|
||||
"raw comment deletion bypasses MCP lease 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_post_merge_moot_lease (append-only release comment) "
|
||||
"or hand cleanup to a reconciler session; never delete lease comments"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_merge_cleanup_mutation_separation(text: str | None) -> dict[str, Any]:
|
||||
"""Require merge mutations and cleanup mutations in separate ledger fields."""
|
||||
text = text or ""
|
||||
merge_match = _MERGE_MUTATIONS_RE.search(text)
|
||||
cleanup_match = _CLEANUP_MUTATIONS_RE.search(text)
|
||||
reasons: list[str] = []
|
||||
|
||||
if cleanup_match and not merge_match:
|
||||
combined = re.search(
|
||||
r"merge.{0,40}cleanup mutations|cleanup.{0,40}merge mutations",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if combined:
|
||||
reasons.append(
|
||||
"merge and cleanup mutations must use separate 'Merge mutations' "
|
||||
"and 'Cleanup mutations' ledger fields"
|
||||
)
|
||||
|
||||
if merge_match and cleanup_match:
|
||||
merge_val = (merge_match.group(1) or "").strip().lower()
|
||||
cleanup_val = (cleanup_match.group(1) or "").strip().lower()
|
||||
if merge_val == cleanup_val and merge_val not in {"", "none"}:
|
||||
reasons.append(
|
||||
"merge mutations and cleanup mutations must not duplicate the "
|
||||
"same ledger entry"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"split merge mutations (gitea_merge_pr) from cleanup mutations "
|
||||
"(reconciler MCP tools) in the controller handoff"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_authorized_reconciler_cleanup_path(text: str | None) -> dict[str, Any]:
|
||||
"""Validate cleanup claims cite authorized MCP tools and reconciler capability."""
|
||||
text = text or ""
|
||||
claiming = _cleanup_claiming_ledger_fields(text)
|
||||
if not claiming:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
reasons: list[str] = []
|
||||
for field_name, body in claiming:
|
||||
if not _AUTHORIZED_TOOL_RE.search(body):
|
||||
reasons.append(
|
||||
f"{field_name} cleanup must name an authorized MCP cleanup tool "
|
||||
f"({', '.join(sorted(AUTHORIZED_CLEANUP_TOOLS))})"
|
||||
)
|
||||
if not _RECONCILER_CAPABILITY_RE.search(text):
|
||||
reasons.append(
|
||||
"post-merge cleanup requires reconciler capability proof "
|
||||
"(reconciler profile, gitea.branch.delete, or reconcile_merged_cleanups)"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"hand cleanup to a prgs-reconciler session and cite the exact MCP tool "
|
||||
"plus delete_branch/reconcile_merged_cleanups capability proof"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_merger_cleanup_handoff_guidance(text: str | None) -> dict[str, Any]:
|
||||
"""Merger sessions that performed cleanup must hand off to reconciler."""
|
||||
text = text or ""
|
||||
if not _MERGER_CLEANUP_ROLE_RE.search(text):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
if _HANDOFF_TO_RECONCILER_RE.search(text):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"merger/reviewer session performed cleanup but did not hand off to "
|
||||
"reconciler for MCP-native cleanup"
|
||||
],
|
||||
"safe_next_action": (
|
||||
"merger sessions must end with cleanup handed to prgs-reconciler; "
|
||||
"do not perform ad hoc branch/comment/worktree cleanup as merger"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_mcp_native_cleanup_proof(report_text: str | None) -> dict[str, Any]:
|
||||
"""Composite #517 verifier for MCP-native post-merge cleanup proof."""
|
||||
text = report_text or ""
|
||||
checks = (
|
||||
assess_raw_branch_delete_report(text),
|
||||
assess_raw_comment_delete_report(text),
|
||||
assess_merge_cleanup_mutation_separation(text),
|
||||
assess_authorized_reconciler_cleanup_path(text),
|
||||
assess_merger_cleanup_handoff_guidance(text),
|
||||
)
|
||||
reasons: list[str] = []
|
||||
safe_next = "proceed"
|
||||
for result in checks:
|
||||
reasons.extend(result.get("reasons") or [])
|
||||
if result.get("block") and result.get("safe_next_action"):
|
||||
safe_next = result["safe_next_action"]
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"proven": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": safe_next,
|
||||
"raw_branch_commands": raw_branch_delete_commands(
|
||||
scoped_cleanup_mutation_ledger_text(text)
|
||||
),
|
||||
"raw_comment_commands": raw_comment_delete_commands(
|
||||
scoped_cleanup_mutation_ledger_text(text)
|
||||
),
|
||||
}
|
||||
@@ -37,6 +37,17 @@ def check_conflict_markers():
|
||||
|
||||
check_conflict_markers()
|
||||
|
||||
# #558: official entrypoint marks the process as the sanctioned MCP daemon
|
||||
# before loading mutation modules (blocks raw shell import bypasses).
|
||||
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 sanctioned 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:
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Durable MCP session validation state shared across daemon process pools (#559).
|
||||
|
||||
The IDE often routes sequential MCP tool calls to different daemon processes.
|
||||
Session-scoped proofs (workflow load, review decision lock) must therefore
|
||||
survive process boundaries while remaining fail-closed against spoofing.
|
||||
|
||||
Security notes (extends #211):
|
||||
- Never store under host-global ``/tmp`` (world-writable, spoofable).
|
||||
- Default root is ``~/.cache/gitea-tools/session-state`` (mode ``0o700``).
|
||||
- Files are written atomically with mode ``0o600``.
|
||||
- Records are keyed by remote + org + repo + profile identity, not by PID.
|
||||
- TTL prevents indefinitely stale reuse across unrelated sessions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
STATE_DIR_ENV = "GITEA_MCP_SESSION_STATE_DIR"
|
||||
DEFAULT_STATE_DIR = os.path.expanduser("~/.cache/gitea-tools/session-state")
|
||||
TTL_HOURS_ENV = "GITEA_MCP_SESSION_STATE_TTL_HOURS"
|
||||
DEFAULT_TTL_HOURS = 4.0
|
||||
|
||||
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
||||
KIND_DECISION_LOCK = "review_decision_lock"
|
||||
KIND_REVIEW_DRAFT = "review_draft"
|
||||
|
||||
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
||||
|
||||
|
||||
def default_state_dir() -> str:
|
||||
raw = (os.environ.get(STATE_DIR_ENV) or DEFAULT_STATE_DIR).strip()
|
||||
return raw or DEFAULT_STATE_DIR
|
||||
|
||||
|
||||
def ttl_hours() -> float:
|
||||
raw = (os.environ.get(TTL_HOURS_ENV) or "").strip()
|
||||
if not raw:
|
||||
return DEFAULT_TTL_HOURS
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return DEFAULT_TTL_HOURS
|
||||
return value if value > 0 else DEFAULT_TTL_HOURS
|
||||
|
||||
|
||||
def _sanitize_segment(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return "_"
|
||||
return _SAFE_SEGMENT_RE.sub("_", text)
|
||||
|
||||
|
||||
def current_profile_identity(
|
||||
profile_name: str | None = None,
|
||||
session_profile_lock: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the session profile identity used as the durable key."""
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
explicit = (profile_identity or session_profile_lock or "").strip()
|
||||
lock = (explicit or env_lock or "").strip()
|
||||
name = (profile_name or "").strip()
|
||||
return lock or name or "unknown-profile"
|
||||
|
||||
|
||||
def state_key(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> str:
|
||||
"""Build durable filename key.
|
||||
|
||||
Session proofs are one-active-per-profile (workflow load / decision lock),
|
||||
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.
|
||||
"""
|
||||
# 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",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def state_file_path(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
name = state_key(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
)
|
||||
return os.path.join(root, f"{name}.json")
|
||||
|
||||
|
||||
def _ensure_state_dir(state_dir: str | None = None) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
os.makedirs(root, mode=0o700, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_iso(value: str | None) -> datetime | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
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)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_file_lock(lock_path: str):
|
||||
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
yield fd
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _read_json(path: str) -> dict[str, Any] | None:
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_json(path: str, data: dict[str, Any]) -> None:
|
||||
parent = os.path.dirname(path) or "."
|
||||
os.makedirs(parent, mode=0o700, exist_ok=True)
|
||||
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".session-", suffix=".json", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temp_path, 0o600)
|
||||
os.replace(temp_path, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def identity_match_reasons(
|
||||
record: dict[str, Any] | None,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return fail-closed reasons when durable record identity does not match."""
|
||||
if record is None:
|
||||
return []
|
||||
reasons: list[str] = []
|
||||
expected_profile = current_profile_identity(profile_identity=profile_identity)
|
||||
stored_profile = (
|
||||
(record.get("session_profile_lock") or record.get("profile_identity") or "")
|
||||
.strip()
|
||||
)
|
||||
if stored_profile and expected_profile and stored_profile != expected_profile:
|
||||
if expected_profile != "unknown-profile":
|
||||
reasons.append(
|
||||
"session state profile identity mismatch "
|
||||
f"(stored={stored_profile!r}, active={expected_profile!r}; fail closed)"
|
||||
)
|
||||
|
||||
for field, expected in (
|
||||
("remote", remote),
|
||||
("org", org),
|
||||
("repo", repo),
|
||||
):
|
||||
want = (expected or "").strip()
|
||||
have = (str(record.get(field) or "")).strip()
|
||||
if want and have and want != have:
|
||||
reasons.append(
|
||||
f"session state {field} mismatch "
|
||||
f"(stored={have!r}, expected={want!r}; fail closed)"
|
||||
)
|
||||
|
||||
recorded_at = _parse_iso(record.get("recorded_at") or record.get("updated_at"))
|
||||
if recorded_at is None:
|
||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||
else:
|
||||
age = _now_utc() - recorded_at
|
||||
if age > timedelta(hours=ttl_hours()):
|
||||
reasons.append(
|
||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||
)
|
||||
if age < timedelta(0):
|
||||
reasons.append("session state recorded_at is in the future (fail closed)")
|
||||
return reasons
|
||||
|
||||
|
||||
def load_state(
|
||||
*,
|
||||
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] | None:
|
||||
"""Load durable state payload when identity checks pass."""
|
||||
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,
|
||||
)
|
||||
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
|
||||
# Identity fields live on both envelope and payload for convenience.
|
||||
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]
|
||||
reasons = identity_match_reasons(
|
||||
merged,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
)
|
||||
if reasons:
|
||||
return None
|
||||
return merged
|
||||
|
||||
|
||||
def save_state(
|
||||
*,
|
||||
kind: str,
|
||||
payload: dict[str, Any] | None,
|
||||
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] | None:
|
||||
"""Persist or clear durable state for the given session identity key."""
|
||||
profile = current_profile_identity(
|
||||
profile_name=payload.get("session_profile") if payload else None,
|
||||
session_profile_lock=(
|
||||
(payload or {}).get("session_profile_lock") or profile_identity
|
||||
),
|
||||
)
|
||||
# Prefer explicit args over payload fields for key location.
|
||||
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")
|
||||
|
||||
root = _ensure_state_dir(state_dir)
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=key_remote,
|
||||
org=key_org,
|
||||
repo=key_repo,
|
||||
profile_identity=profile,
|
||||
state_dir=root,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
if payload is None:
|
||||
for candidate in (path, lock_path):
|
||||
if os.path.exists(candidate):
|
||||
try:
|
||||
os.remove(candidate)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
now = _now_utc().isoformat().replace("+00:00", "Z")
|
||||
body = dict(payload)
|
||||
body.setdefault("session_pid", os.getpid())
|
||||
body["writer_pid"] = os.getpid()
|
||||
body["profile_identity"] = profile
|
||||
if not (body.get("session_profile_lock") or "").strip():
|
||||
body["session_profile_lock"] = profile
|
||||
body["recorded_at"] = body.get("recorded_at") or now
|
||||
body["updated_at"] = now
|
||||
if key_remote is not None:
|
||||
body["remote"] = key_remote
|
||||
if key_org is not None:
|
||||
body["org"] = key_org
|
||||
if key_repo is not None:
|
||||
body["repo"] = key_repo
|
||||
|
||||
envelope = {
|
||||
"kind": kind,
|
||||
"remote": key_remote,
|
||||
"org": key_org,
|
||||
"repo": key_repo,
|
||||
"profile_identity": profile,
|
||||
"session_profile_lock": body.get("session_profile_lock"),
|
||||
"recorded_at": body["recorded_at"],
|
||||
"updated_at": body["updated_at"],
|
||||
"writer_pid": body["writer_pid"],
|
||||
"payload": body,
|
||||
}
|
||||
_write_json(path, envelope)
|
||||
return dict(body)
|
||||
|
||||
|
||||
def clear_state(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> None:
|
||||
save_state(
|
||||
kind=kind,
|
||||
payload=None,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Merge approval must pin the current PR head SHA (#471).
|
||||
|
||||
Formal APPROVED reviews that predate the live PR head must not satisfy
|
||||
``gitea_merge_pr`` eligibility. Pure assessment helpers are isolated here
|
||||
for hermetic unit tests apart from MCP HTTP calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def assess_merge_approval_head(
|
||||
*,
|
||||
current_head_sha: str | None,
|
||||
latest_by_reviewer: dict,
|
||||
) -> dict:
|
||||
"""Return whether a visible approval applies to the live PR head.
|
||||
|
||||
Args:
|
||||
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.
|
||||
|
||||
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")
|
||||
]
|
||||
at_current = any(
|
||||
(entry.get("reviewed_head_sha") or "").strip() == current
|
||||
for entry in approved_entries
|
||||
if current
|
||||
)
|
||||
latest_approved = None
|
||||
if approved_entries:
|
||||
latest_entry = sorted(
|
||||
approved_entries,
|
||||
key=lambda entry: (
|
||||
entry.get("submitted_at") or "",
|
||||
entry.get("reviewed_head_sha") or "",
|
||||
),
|
||||
)[-1]
|
||||
latest_approved = (latest_entry.get("reviewed_head_sha") or "").strip() or None
|
||||
reason = None
|
||||
if 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); "
|
||||
"required next action: re-review PR at current head before merge"
|
||||
)
|
||||
|
||||
return {
|
||||
"approval_at_current_head": at_current,
|
||||
"latest_approved_head_sha": latest_approved,
|
||||
"stale_approval_block_reason": reason,
|
||||
}
|
||||
+447
-22
@@ -13,10 +13,16 @@ import subprocess
|
||||
from typing import Any
|
||||
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
import issue_lock_store
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
|
||||
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)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_linked_issue(title: str, body: str) -> int | None:
|
||||
@@ -36,23 +42,315 @@ 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(
|
||||
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
|
||||
records: list[dict[str, str | None]] = []
|
||||
current: dict[str, str | None] = {}
|
||||
for line in res.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
if current.get("worktree"):
|
||||
records.append(current)
|
||||
current = {}
|
||||
continue
|
||||
if line.startswith("worktree "):
|
||||
if current.get("worktree"):
|
||||
records.append(current)
|
||||
current = {"worktree": line[9:].strip(), "branch": None, "head": None}
|
||||
elif line.startswith("branch ") and current:
|
||||
ref = line[7:].strip()
|
||||
current["branch"] = (
|
||||
ref[11:] if ref.startswith("refs/heads/") else ref
|
||||
)
|
||||
elif line.startswith("HEAD ") and current:
|
||||
current["head"] = line[5:].strip()
|
||||
elif line == "detached" and current:
|
||||
current["branch"] = None
|
||||
if current.get("worktree"):
|
||||
records.append(current)
|
||||
return records
|
||||
|
||||
|
||||
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 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())
|
||||
if not match:
|
||||
return None
|
||||
return int(match.group("pr"))
|
||||
|
||||
|
||||
def discover_reviewer_scratch_worktrees(project_root: str) -> list[dict[str, Any]]:
|
||||
"""Discover detached reviewer scratch worktrees under ``branches/`` (#534).
|
||||
|
||||
Matches folder names ``review-pr<N>`` and ``review-pr<N>-submit`` only.
|
||||
These are never treated as author worktree paths.
|
||||
"""
|
||||
root = os.path.realpath(project_root)
|
||||
branches_root = os.path.realpath(os.path.join(root, "branches"))
|
||||
found: list[dict[str, Any]] = []
|
||||
for record in _git_worktree_porcelain(project_root):
|
||||
path = record.get("worktree")
|
||||
if not path:
|
||||
continue
|
||||
real = os.path.realpath(path)
|
||||
parent = os.path.dirname(real)
|
||||
if parent != branches_root:
|
||||
continue
|
||||
folder = os.path.basename(real)
|
||||
pr_number = parse_reviewer_scratch_folder(folder)
|
||||
if pr_number is None:
|
||||
continue
|
||||
found.append(
|
||||
{
|
||||
"pr_number": pr_number,
|
||||
"worktree_path": real,
|
||||
"folder_name": folder,
|
||||
"current_branch": record.get("branch"),
|
||||
"head_sha": record.get("head"),
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def assess_reviewer_scratch_cleanup(
|
||||
*,
|
||||
pr_number: int,
|
||||
worktree_path: str,
|
||||
folder_name: str,
|
||||
pr_merged: bool,
|
||||
pr_closed: bool,
|
||||
worktree_state: dict[str, Any],
|
||||
active_reviewer_lease: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether a reviewer scratch worktree is safe to remove (#534)."""
|
||||
reasons: list[str] = []
|
||||
exists = bool(worktree_state.get("exists"))
|
||||
if not (pr_merged or pr_closed):
|
||||
reasons.append("associated PR is still open")
|
||||
if not exists:
|
||||
reasons.append("reviewer scratch worktree not present")
|
||||
if exists and worktree_state.get("clean") is False:
|
||||
dirty = worktree_state.get("dirty_files") or []
|
||||
reasons.append(
|
||||
"reviewer scratch worktree has tracked edits"
|
||||
+ (f" ({', '.join(dirty)})" if dirty else "")
|
||||
)
|
||||
if active_reviewer_lease:
|
||||
reasons.append("active reviewer lease still requires this worktree")
|
||||
current_branch = worktree_state.get("current_branch")
|
||||
if current_branch:
|
||||
# Scratch trees are expected detached; a named branch is ambiguous ownership.
|
||||
reasons.append(
|
||||
f"reviewer scratch worktree is on branch '{current_branch}' "
|
||||
"(expected detached HEAD for review-pr scratch trees)"
|
||||
)
|
||||
|
||||
safe = exists and not reasons
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"worktree_path": worktree_path,
|
||||
"folder_name": folder_name,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"worktree_exists": exists,
|
||||
"worktree_clean": worktree_state.get("clean"),
|
||||
"safe_to_remove_worktree": safe,
|
||||
"block_reasons": reasons,
|
||||
"recommended_action": (
|
||||
"remove_reviewer_scratch_worktree" if safe else "keep_reviewer_scratch_worktree"
|
||||
),
|
||||
# Never treat as author worktree cleanup.
|
||||
"author_worktree_cleanup": False,
|
||||
}
|
||||
|
||||
|
||||
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
|
||||
lock_path = (path or ISSUE_LOCK_FILE).strip()
|
||||
if not lock_path or not os.path.exists(lock_path):
|
||||
return None
|
||||
try:
|
||||
with open(lock_path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
if path:
|
||||
return issue_lock_store.read_lock_file(path.strip())
|
||||
return issue_lock_store.read_session_issue_lock()
|
||||
|
||||
|
||||
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
|
||||
lock = read_issue_lock(lock_path)
|
||||
if not lock:
|
||||
return False
|
||||
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
|
||||
if lock_path:
|
||||
lock = issue_lock_store.read_lock_file(lock_path.strip())
|
||||
if not lock:
|
||||
return False
|
||||
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
|
||||
return issue_lock_store.has_active_issue_lock(branch)
|
||||
|
||||
|
||||
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
|
||||
@@ -180,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:
|
||||
@@ -190,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}'"
|
||||
)
|
||||
@@ -202,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,
|
||||
@@ -220,6 +527,7 @@ def build_pr_cleanup_entry(
|
||||
delete_capability_allowed: bool,
|
||||
issue_lock_path: str | None = None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
target_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
pr_number = int(pr["number"])
|
||||
head_branch = pr.get("head") or ""
|
||||
@@ -228,9 +536,16 @@ def build_pr_cleanup_entry(
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
merged = bool(pr.get("merged_at"))
|
||||
worktree_path = resolve_worktree_path(project_root, head_branch)
|
||||
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(
|
||||
@@ -253,7 +568,7 @@ def build_pr_cleanup_entry(
|
||||
)
|
||||
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"),
|
||||
@@ -274,6 +589,9 @@ 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)
|
||||
entries: list[dict[str, Any]] = []
|
||||
@@ -294,19 +612,65 @@ def build_reconciliation_report(
|
||||
delete_capability_allowed=delete_capability_allowed,
|
||||
issue_lock_path=issue_lock_path,
|
||||
protected_branches=protected_branches,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
)
|
||||
|
||||
# #534: reviewer scratch worktrees are reported separately from author cleanup.
|
||||
lease_map = active_reviewer_leases or {}
|
||||
state_map = pr_states or {}
|
||||
open_pr_numbers = {
|
||||
int(pr["number"]) for pr in (open_prs or []) if pr.get("number") is not None
|
||||
}
|
||||
closed_by_number = {
|
||||
int(pr["number"]): pr for pr in (closed_prs or []) if pr.get("number") is not None
|
||||
}
|
||||
scratch_entries: list[dict[str, Any]] = []
|
||||
for scratch in discover_reviewer_scratch_worktrees(project_root):
|
||||
pr_number = int(scratch["pr_number"])
|
||||
state_info = state_map.get(pr_number) or {}
|
||||
closed_pr = closed_by_number.get(pr_number)
|
||||
if closed_pr is not None:
|
||||
pr_merged = bool(closed_pr.get("merged_at") or closed_pr.get("merged"))
|
||||
pr_closed = True
|
||||
elif pr_number in open_pr_numbers:
|
||||
pr_merged = False
|
||||
pr_closed = False
|
||||
else:
|
||||
pr_merged = bool(state_info.get("merged"))
|
||||
pr_closed = bool(state_info.get("closed") or state_info.get("merged"))
|
||||
worktree_path = scratch["worktree_path"]
|
||||
worktree_state = read_local_worktree_state(worktree_path)
|
||||
worktree_state["worktree_path"] = worktree_path
|
||||
# Prefer live branch from state (git) over porcelain branch field.
|
||||
assessment = assess_reviewer_scratch_cleanup(
|
||||
pr_number=pr_number,
|
||||
worktree_path=worktree_path,
|
||||
folder_name=scratch["folder_name"],
|
||||
pr_merged=pr_merged,
|
||||
pr_closed=pr_closed,
|
||||
worktree_state=worktree_state,
|
||||
active_reviewer_lease=bool(lease_map.get(pr_number)),
|
||||
)
|
||||
scratch_entries.append(assessment)
|
||||
|
||||
return {
|
||||
"project_root": os.path.realpath(project_root),
|
||||
"merged_pr_count": len(entries),
|
||||
"entries": entries,
|
||||
"reviewer_scratch_entries": scratch_entries,
|
||||
"reviewer_scratch_count": len(scratch_entries),
|
||||
"dry_run": True,
|
||||
"executed": False,
|
||||
}
|
||||
|
||||
|
||||
def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
|
||||
worktree_path = 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,
|
||||
@@ -330,4 +694,65 @@ def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
|
||||
"performed": True,
|
||||
"message": f"removed worktree {worktree_path}",
|
||||
"worktree_path": worktree_path,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def remove_reviewer_scratch_worktree(
|
||||
project_root: str,
|
||||
worktree_path: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Remove a reviewer scratch worktree by absolute path (#534).
|
||||
|
||||
Fail closed unless the path is a direct ``branches/review-pr*`` child of
|
||||
*project_root*. Never routes through author branch-name derivation.
|
||||
"""
|
||||
root = os.path.realpath(project_root)
|
||||
branches_root = os.path.realpath(os.path.join(root, "branches"))
|
||||
real = os.path.realpath((worktree_path or "").strip())
|
||||
folder = os.path.basename(real)
|
||||
if os.path.dirname(real) != branches_root:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"message": (
|
||||
"reviewer scratch path must be a direct child of "
|
||||
f"{branches_root}; got {real}"
|
||||
),
|
||||
}
|
||||
if parse_reviewer_scratch_folder(folder) is None:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"message": f"path is not a reviewer scratch folder: {folder}",
|
||||
}
|
||||
if not os.path.isdir(real):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"message": f"worktree not found: {real}",
|
||||
}
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "worktree", "remove", real],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"message": (res.stderr or res.stdout or "worktree remove failed").strip(),
|
||||
"worktree_path": real,
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"message": f"removed reviewer scratch worktree {real}",
|
||||
"worktree_path": real,
|
||||
"author_worktree_cleanup": False,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Guarded merger adoption of an existing reviewer PR lease (#536).
|
||||
|
||||
Replaces manual in-process ``_SESSION_LEASE`` seeding with an auditable,
|
||||
comment-backed adoption path for merger sessions that inherit a reviewer lease
|
||||
after formal approval at the current PR head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import reviewer_pr_lease as leases
|
||||
|
||||
ADOPTION_MARKER = "<!-- mcp-review-lease-adoption:v1 -->"
|
||||
DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
|
||||
|
||||
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||
|
||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||
SOURCE_ADOPT,
|
||||
SOURCE_ACQUIRE,
|
||||
SOURCE_HEARTBEAT,
|
||||
})
|
||||
|
||||
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
|
||||
|
||||
|
||||
def format_adoption_body(
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
issue_number: int | None,
|
||||
adopter_identity: str,
|
||||
adopter_profile: str,
|
||||
adopter_session_id: str,
|
||||
worktree: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
adopted_from_session_id: str,
|
||||
adopted_from_profile: str,
|
||||
adopted_from_reviewer_identity: str,
|
||||
adopted_from_comment_id: int | None,
|
||||
adoption_reason: str = DEFAULT_ADOPTION_REASON,
|
||||
adopted_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Render a durable merger adoption proof comment."""
|
||||
adopted_at = adopted_at or datetime.now(timezone.utc)
|
||||
adopted_text = adopted_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
lease_body = leases.format_lease_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number,
|
||||
reviewer_identity=adopter_identity,
|
||||
profile=adopter_profile,
|
||||
session_id=adopter_session_id,
|
||||
worktree=worktree,
|
||||
phase="adopted",
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
last_activity=adopted_at,
|
||||
blocker="none",
|
||||
)
|
||||
lines = [
|
||||
ADOPTION_MARKER,
|
||||
f"adopted_at: {adopted_text}",
|
||||
f"adopted_by_identity: {adopter_identity}",
|
||||
f"adopted_by_profile: {adopter_profile}",
|
||||
f"adopted_from_session_id: {adopted_from_session_id}",
|
||||
f"adopted_from_profile: {adopted_from_profile}",
|
||||
f"adopted_from_reviewer_identity: {adopted_from_reviewer_identity}",
|
||||
f"adopted_from_comment_id: {adopted_from_comment_id or 'none'}",
|
||||
f"adoption_reason: {adoption_reason}",
|
||||
lease_body,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def is_adoption_comment(body: str) -> bool:
|
||||
return ADOPTION_MARKER in (body or "")
|
||||
|
||||
|
||||
def build_lease_provenance(
|
||||
*,
|
||||
source: str,
|
||||
comment_id: int | None = None,
|
||||
adopted_from_session_id: str | None = None,
|
||||
adopted_from_profile: str | None = None,
|
||||
adopted_from_reviewer_identity: str | None = None,
|
||||
adoption_reason: str | None = None,
|
||||
recorded_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
recorded_at = recorded_at or datetime.now(timezone.utc)
|
||||
recorded_text = recorded_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
proof = {
|
||||
"source": source,
|
||||
"recorded_at": recorded_text,
|
||||
}
|
||||
if comment_id is not None:
|
||||
proof["comment_id"] = comment_id
|
||||
if adopted_from_session_id:
|
||||
proof["adopted_from_session_id"] = adopted_from_session_id
|
||||
if adopted_from_profile:
|
||||
proof["adopted_from_profile"] = adopted_from_profile
|
||||
if adopted_from_reviewer_identity:
|
||||
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
|
||||
if adoption_reason:
|
||||
proof["adoption_reason"] = adoption_reason
|
||||
return proof
|
||||
|
||||
|
||||
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
||||
if not session:
|
||||
return False
|
||||
provenance = session.get("lease_provenance") or {}
|
||||
source = (provenance.get("source") or "").strip()
|
||||
if source not in SANCTIONED_PROVENANCE_SOURCES:
|
||||
return False
|
||||
if source == SOURCE_ADOPT:
|
||||
return bool(provenance.get("comment_id")) and bool(
|
||||
provenance.get("adopted_from_session_id")
|
||||
)
|
||||
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
|
||||
return bool(session.get("comment_id") or provenance.get("comment_id"))
|
||||
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"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
||||
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_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],
|
||||
*,
|
||||
pr_number: int,
|
||||
adopter_identity: str,
|
||||
adopter_profile: str,
|
||||
adopter_session_id: str,
|
||||
repo: str,
|
||||
issue_number: int | None,
|
||||
worktree: str,
|
||||
expected_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
approval_at_head: bool,
|
||||
pr_open: bool = True,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether a merger may adopt the active reviewer lease (#536)."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
pinned = leases._normalize_sha(expected_head_sha)
|
||||
live = leases._normalize_sha(live_head_sha)
|
||||
|
||||
if not pr_open:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is not open; merger lease adoption is only for open PRs"
|
||||
)
|
||||
if not approval_at_head:
|
||||
reasons.append(
|
||||
"formal APPROVED review at the current PR head is required before "
|
||||
"merger lease adoption (fail closed)"
|
||||
)
|
||||
if not pinned:
|
||||
reasons.append("expected_head_sha is required for merger lease adoption")
|
||||
if not live:
|
||||
reasons.append("live PR head SHA unavailable (fail closed)")
|
||||
elif pinned and live and pinned != live:
|
||||
reasons.append(
|
||||
"expected_head_sha does not match live PR head; refresh approval before adoption"
|
||||
)
|
||||
|
||||
active = leases.find_active_reviewer_lease(
|
||||
comments, pr_number=pr_number, now=now
|
||||
)
|
||||
if not active:
|
||||
reasons.append(
|
||||
f"no active reviewer lease on PR #{pr_number} to adopt; reviewer "
|
||||
"must acquire first"
|
||||
)
|
||||
else:
|
||||
owner_session = (active.get("session_id") or "").strip()
|
||||
freshness = active.get("freshness") or leases.classify_lease_freshness(
|
||||
active, now=now
|
||||
)
|
||||
if freshness not in _MERGER_ADOPTABLE_FRESHNESS:
|
||||
reasons.append(
|
||||
f"active reviewer lease freshness is '{freshness}'; explicit "
|
||||
"reclaim is not implemented (fail closed)"
|
||||
)
|
||||
lease_head = active.get("candidate_head")
|
||||
if lease_head and live and lease_head != live:
|
||||
reasons.append(
|
||||
"active reviewer lease candidate_head differs from live PR head"
|
||||
)
|
||||
if owner_session and owner_session == adopter_session_id:
|
||||
# Same session already holds the thread lease — allow idempotent adopt
|
||||
# only when the newest entry is not yet an adoption by this session.
|
||||
entries = leases._lease_entries(comments, pr_number=pr_number)
|
||||
newest = entries[-1] if entries else None
|
||||
if newest and (newest.get("phase") or "") == "adopted":
|
||||
reasons.append(
|
||||
"merger session already recorded an adoption lease on this PR"
|
||||
)
|
||||
elif owner_session and owner_session != adopter_session_id:
|
||||
# Cross-session merger handoff: adopt the foreign reviewer lease.
|
||||
pass
|
||||
|
||||
if not (adopter_identity or "").strip():
|
||||
reasons.append("adopter identity required")
|
||||
if not (adopter_session_id or "").strip():
|
||||
reasons.append("adopter session_id required")
|
||||
if not (worktree or "").strip():
|
||||
reasons.append("merger worktree path required")
|
||||
if "merger" not in (adopter_profile or "").lower():
|
||||
reasons.append(
|
||||
f"profile '{adopter_profile}' is not a merger profile; adoption is "
|
||||
"merger-only (fail closed)"
|
||||
)
|
||||
|
||||
adopt_allowed = not reasons
|
||||
adoption_body = None
|
||||
if adopt_allowed and active:
|
||||
adoption_body = format_adoption_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number or active.get("issue_number"),
|
||||
adopter_identity=adopter_identity,
|
||||
adopter_profile=adopter_profile,
|
||||
adopter_session_id=adopter_session_id,
|
||||
worktree=worktree,
|
||||
candidate_head=live,
|
||||
target_branch=active.get("target_branch") or "master",
|
||||
target_branch_sha=active.get("target_branch_sha"),
|
||||
adopted_from_session_id=active.get("session_id") or "",
|
||||
adopted_from_profile=active.get("profile") or "unknown",
|
||||
adopted_from_reviewer_identity=active.get("reviewer_identity") or "",
|
||||
adopted_from_comment_id=active.get("comment_id"),
|
||||
adopted_at=now,
|
||||
)
|
||||
|
||||
return {
|
||||
"adopt_allowed": adopt_allowed,
|
||||
"reasons": reasons,
|
||||
"active_lease": active,
|
||||
"adoption_body": adoption_body,
|
||||
"adopter_session_id": adopter_session_id,
|
||||
"expected_head_sha": pinned,
|
||||
"live_head_sha": live,
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Namespace-scoped MCP workspace binding (#510).
|
||||
|
||||
Each role namespace (author, reviewer, merger, reconciler) resolves its own
|
||||
active task workspace. Foreign role worktree environment variables must not
|
||||
poison workspace purity checks in another namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import author_mutation_worktree as amw
|
||||
|
||||
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
||||
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
|
||||
REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
|
||||
MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
|
||||
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
||||
|
||||
ROLE_WORKTREE_ENVS: dict[str, str] = {
|
||||
"author": AUTHOR_WORKTREE_ENV,
|
||||
"reviewer": REVIEWER_WORKTREE_ENV,
|
||||
"merger": MERGER_WORKTREE_ENV,
|
||||
"reconciler": RECONCILER_WORKTREE_ENV,
|
||||
}
|
||||
|
||||
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"})
|
||||
|
||||
|
||||
def normalize_role_kind(
|
||||
role_kind: str | None,
|
||||
*,
|
||||
profile_name: str | None = None,
|
||||
) -> str:
|
||||
"""Map profile/task role to a workspace namespace key."""
|
||||
role = (role_kind or "author").strip().lower()
|
||||
profile = (profile_name or "").strip().lower()
|
||||
if role == "reviewer" and "merger" in profile:
|
||||
return "merger"
|
||||
if role in ROLE_WORKTREE_ENVS:
|
||||
return role
|
||||
return "author"
|
||||
|
||||
|
||||
def _env_value(env: dict[str, str] | os._Environ, key: str) -> str | None:
|
||||
text = (env.get(key) or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def resolve_namespace_workspace(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None = None,
|
||||
worktree: str | None = None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
|
||||
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"),
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree"),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text)), source
|
||||
|
||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||
|
||||
|
||||
def resolve_namespace_mutation_context(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=workspace,
|
||||
binding_source=binding_source,
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
|
||||
|
||||
def assess_foreign_role_worktree_pollution(
|
||||
*,
|
||||
role_kind: str,
|
||||
resolved_workspace: str,
|
||||
binding_source: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Detect when a foreign role env would have hijacked workspace binding."""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
if role == "author":
|
||||
return {"would_pollute": False, "ignored_bindings": []}
|
||||
|
||||
ignored: list[str] = []
|
||||
author_path = _env_value(env_map, AUTHOR_WORKTREE_ENV)
|
||||
if author_path:
|
||||
author_real = os.path.realpath(os.path.abspath(author_path))
|
||||
resolved_real = os.path.realpath(resolved_workspace)
|
||||
if author_real != resolved_real and binding_source != f"{AUTHOR_WORKTREE_ENV} environment variable":
|
||||
ignored.append(
|
||||
f"{AUTHOR_WORKTREE_ENV}={author_real} (ignored for {role} namespace)"
|
||||
)
|
||||
return {
|
||||
"would_pollute": bool(ignored),
|
||||
"ignored_bindings": ignored,
|
||||
}
|
||||
|
||||
|
||||
def assess_metadata_only_worktree_binding(
|
||||
*,
|
||||
role_kind: str,
|
||||
declared_worktree_path: str | None,
|
||||
mutation_workspace: str,
|
||||
process_project_root: str,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when declared worktree_path would not redirect mutations."""
|
||||
declared = (declared_worktree_path or "").strip()
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
mutation_root = os.path.realpath(mutation_workspace)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
if not declared:
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
declared_root = os.path.realpath(os.path.abspath(declared))
|
||||
if declared_root == mutation_root:
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
if declared_root != process_root and mutation_root == process_root:
|
||||
return {
|
||||
"block": True,
|
||||
"metadata_only": True,
|
||||
"reasons": [
|
||||
f"worktree_path is metadata-only for {role} mutations: preflight "
|
||||
f"inspected '{declared_root}' but mutation tools would still "
|
||||
f"validate MCP server process root '{process_root}'"
|
||||
],
|
||||
"declared_worktree_path": declared_root,
|
||||
"mutation_workspace": mutation_root,
|
||||
"process_project_root": process_root,
|
||||
}
|
||||
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
|
||||
def format_namespace_workspace_binding_error(
|
||||
*,
|
||||
role_kind: str,
|
||||
workspace_path: str,
|
||||
binding_source: str,
|
||||
reasons: list[str] | None = None,
|
||||
ignored_bindings: list[str] | None = None,
|
||||
dirty_files: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||
role = normalize_role_kind(role_kind)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
parts = [
|
||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||
f"resolved workspace '{workspace}' via {binding_source}."
|
||||
]
|
||||
if ignored_bindings:
|
||||
parts.append(
|
||||
"Foreign role bindings ignored: " + "; ".join(ignored_bindings) + "."
|
||||
)
|
||||
if dirty_files:
|
||||
parts.append(
|
||||
"Dirty tracked files in active task workspace: "
|
||||
+ ", ".join(dirty_files)
|
||||
+ "."
|
||||
)
|
||||
if reasons:
|
||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def assess_namespace_mutation_workspace(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None,
|
||||
worktree: str | None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||
ctx = resolve_namespace_mutation_context(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
mutation_workspace = ctx["workspace_path"]
|
||||
binding_source = ctx["workspace_binding_source"]
|
||||
role = ctx["workspace_role_kind"]
|
||||
process_root = ctx["process_project_root"]
|
||||
|
||||
metadata = assess_metadata_only_worktree_binding(
|
||||
role_kind=role,
|
||||
declared_worktree_path=worktree_path,
|
||||
mutation_workspace=mutation_workspace,
|
||||
process_project_root=process_root,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=mutation_workspace,
|
||||
binding_source=binding_source,
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
|
||||
reasons = list(metadata.get("reasons") or [])
|
||||
if role == "author":
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
elif (
|
||||
role == "reviewer"
|
||||
and mutation_workspace == process_root
|
||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
||||
):
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace is the stable control checkout; "
|
||||
f"create or reconnect to a session-owned worktree under branches/ "
|
||||
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}"
|
||||
)
|
||||
elif (
|
||||
role in {"reviewer", "merger"}
|
||||
and mutation_workspace != process_root
|
||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
||||
):
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
|
||||
f"'{ctx['canonical_repo_root']}/branches/'"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"mutation_workspace": mutation_workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": ctx["canonical_repo_root"],
|
||||
"metadata_only": metadata.get("metadata_only", False),
|
||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
}
|
||||
+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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
|
||||
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
|
||||
|
||||
_CLEANUP_SECTION_HINT = re.compile(
|
||||
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
|
||||
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
|
||||
_CLEANUP_BLOCKER_RE = re.compile(
|
||||
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REMOTE_DELETE_CLAIM_RE = re.compile(
|
||||
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
|
||||
r"deleted remote branch|delete_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
|
||||
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
|
||||
r"worktree cleanup performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
|
||||
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
|
||||
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_TASK_RE = re.compile(
|
||||
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"merge result\s*:\s*(?:merged|success|performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_HEAD_BRANCH_RE = re.compile(
|
||||
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCH_NOT_PROTECTED_RE = re.compile(
|
||||
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OPEN_PR_INVENTORY_RE = re.compile(
|
||||
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
|
||||
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
|
||||
r"no other open pr references branch",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACTIVE_CLAIM_LEASE_RE = re.compile(
|
||||
r"(?:no active (?:heartbeat|claim|lease)|"
|
||||
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SESSION_OWNED_WORKTREE_RE = re.compile(
|
||||
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
|
||||
_CLEAN_TRACKED_RE = re.compile(
|
||||
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEAN_UNTRACKED_RE = re.compile(
|
||||
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_LIST_AFTER_RE = re.compile(
|
||||
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WRONG_BRANCH_RE = re.compile(
|
||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _claims_remote_delete(text: str) -> bool:
|
||||
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _claims_worktree_remove(text: str) -> bool:
|
||||
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _branch_safety_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
missing.append("delete-branch capability resolved (gitea.branch.delete)")
|
||||
if not _DELETE_TASK_RE.search(text):
|
||||
missing.append("delete-branch task named (delete_branch or cleanup)")
|
||||
if not _MERGE_RESULT_RE.search(text):
|
||||
missing.append("merge result: merged")
|
||||
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||
missing.append("merge commit SHA")
|
||||
if not _PR_HEAD_BRANCH_RE.search(text):
|
||||
missing.append("merged PR head branch / deleted branch name")
|
||||
if not _BRANCH_NOT_PROTECTED_RE.search(text):
|
||||
missing.append("branch not protected proof")
|
||||
if not _OPEN_PR_INVENTORY_RE.search(text):
|
||||
missing.append("open PR inventory proof (no other PR references branch)")
|
||||
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
|
||||
missing.append("no active heartbeat/claim/lease proof")
|
||||
return missing
|
||||
|
||||
|
||||
def _worktree_cleanup_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
match = _SESSION_OWNED_WORKTREE_RE.search(text)
|
||||
path = match.group(1).strip() if match else ""
|
||||
if not path:
|
||||
missing.append("session-owned worktree path")
|
||||
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
|
||||
missing.append("worktree path under branches/")
|
||||
if not _CLEAN_TRACKED_RE.search(text):
|
||||
missing.append("pre-removal tracked state: clean")
|
||||
if not _CLEAN_UNTRACKED_RE.search(text):
|
||||
missing.append("pre-removal untracked state: clean")
|
||||
if not _WORKTREE_LIST_AFTER_RE.search(text):
|
||||
missing.append("git worktree list after removal")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_post_merge_cleanup_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
cleanup_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
|
||||
text = report_text or ""
|
||||
session = dict(cleanup_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
|
||||
blocker = (session.get("blocker") or "").strip()
|
||||
if not blocker:
|
||||
match = _CLEANUP_BLOCKER_RE.search(text)
|
||||
blocker = match.group(1).strip() if match else ""
|
||||
if blocker.upper() == CLEANUP_SKIPPED:
|
||||
blocker = ""
|
||||
if not blocker:
|
||||
reasons.append(
|
||||
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"proven": not reasons,
|
||||
"outcome": CLEANUP_SKIPPED,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
|
||||
return {
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"outcome": None,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
remote_delete = bool(
|
||||
session.get("remote_delete_claimed") or _claims_remote_delete(text)
|
||||
)
|
||||
worktree_remove = bool(
|
||||
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
|
||||
)
|
||||
|
||||
if _WRONG_BRANCH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup report claims deleted branch that is not the merged PR head branch"
|
||||
)
|
||||
|
||||
if remote_delete:
|
||||
reasons.extend(
|
||||
f"remote branch deletion missing {field}"
|
||||
for field in _branch_safety_fields_present(text)
|
||||
)
|
||||
|
||||
if worktree_remove:
|
||||
reasons.extend(
|
||||
f"worktree removal missing {field}"
|
||||
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,
|
||||
)
|
||||
if cleanup_mutations:
|
||||
reasons.append(
|
||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||
)
|
||||
|
||||
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
|
||||
if remote_delete or worktree_remove:
|
||||
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"proven": not block,
|
||||
"outcome": outcome,
|
||||
"remote_delete_claimed": remote_delete,
|
||||
"worktree_remove_claimed": worktree_remove,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
|
||||
"checklist before claiming remote delete or worktree removal"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -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"
|
||||
),
|
||||
}
|
||||
+15
-1
@@ -132,6 +132,13 @@ def check_cleanup_task_allowed(task: str) -> tuple[bool, list[str]]:
|
||||
_SELECTED_PR_RE = re.compile(
|
||||
r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_SELECTED_PR_NONE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*selected pr\s*:\s*(?:none|n/a)\b", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_EMPTY_QUEUE_RE = re.compile(
|
||||
r"\b0 open pr|\bno open pr|\bno eligible pr|\bempty (?:review )?queue|\binventory empty|\btrusted_empty\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NEXT_SUGGESTED_RE = re.compile(
|
||||
r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
@@ -176,7 +183,11 @@ def assess_pr_queue_cleanup_report(report_text: str) -> dict:
|
||||
|
||||
selected = _SELECTED_PR_RE.findall(text)
|
||||
if len(selected) == 0:
|
||||
reasons.append("report must name exactly one Selected PR")
|
||||
if _SELECTED_PR_NONE_RE.search(text):
|
||||
if not _EMPTY_QUEUE_RE.search(text):
|
||||
reasons.append("Selected PR is 'none' but no valid empty-queue claim found")
|
||||
else:
|
||||
reasons.append("report must name exactly one Selected PR")
|
||||
elif len(set(selected)) > 1:
|
||||
reasons.append(
|
||||
"cleanup run selected multiple PRs "
|
||||
@@ -184,6 +195,9 @@ def assess_pr_queue_cleanup_report(report_text: str) -> dict:
|
||||
"PR per run"
|
||||
)
|
||||
|
||||
if len(selected) > 0 and _SELECTED_PR_NONE_RE.search(text):
|
||||
reasons.append("report contains both a selected PR and a claim of none selected")
|
||||
|
||||
terminal = _TERMINAL_MUTATION_RE.findall(text)
|
||||
if len(terminal) > 1:
|
||||
reasons.append(
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
"""Conflict-fix and reviewer PR work leases (#399, #407 reader).
|
||||
|
||||
Structured PR/issue comments prove exclusive phases so author conflict-fix
|
||||
pushes cannot race reviewer validation/approval/merge on the same head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
REVIEWER_LEASE_MARKER = "<!-- mcp-review-lease:v1 -->"
|
||||
CONFLICT_FIX_LEASE_MARKER = "<!-- mcp-conflict-fix-lease:v1 -->"
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"})
|
||||
_ACTIVE_REVIEWER_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
"approved",
|
||||
"request-changes",
|
||||
"merging",
|
||||
})
|
||||
_TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
|
||||
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
||||
|
||||
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
||||
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 _parse_pr_ref(value: str | None) -> int | None:
|
||||
digits = re.sub(r"[^\d]", "", value or "")
|
||||
return int(digits) if digits.isdigit() else None
|
||||
|
||||
|
||||
def _parse_marker_comment(body: str, marker: str) -> dict[str, str] | None:
|
||||
text = body or ""
|
||||
if marker not in text:
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
return fields or None
|
||||
|
||||
|
||||
def parse_reviewer_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
fields = _parse_marker_comment(body, REVIEWER_LEASE_MARKER)
|
||||
if not fields:
|
||||
return None
|
||||
return {
|
||||
"lease_kind": "reviewer",
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"issue_number": _parse_pr_ref(fields.get("issue")),
|
||||
"reviewer_identity": fields.get("reviewer_identity"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"candidate_head": _normalize_sha(fields.get("candidate_head")),
|
||||
"target_branch": fields.get("target_branch"),
|
||||
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
|
||||
"last_activity": fields.get("last_activity"),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"blocker": fields.get("blocker"),
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def parse_conflict_fix_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
fields = _parse_marker_comment(body, CONFLICT_FIX_LEASE_MARKER)
|
||||
if not fields:
|
||||
return None
|
||||
ff = (fields.get("fast_forward") or "").strip().lower()
|
||||
reviewer_active = (fields.get("reviewer_active") or "").strip().lower()
|
||||
return {
|
||||
"lease_kind": "conflict_fix",
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"branch": fields.get("branch"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"head_before": _normalize_sha(fields.get("head_before")),
|
||||
"head_after": _normalize_sha(fields.get("head_after")),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"reviewer_active": reviewer_active in {"yes", "true", "1"},
|
||||
"fast_forward": ff in {"yes", "true", "1"},
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def _comment_entries(comments: list[dict], *, pr_number: int | None) -> list[dict]:
|
||||
entries: list[dict] = []
|
||||
for comment in comments or []:
|
||||
body = comment.get("body") or ""
|
||||
for parser in (parse_reviewer_lease_comment, parse_conflict_fix_lease_comment):
|
||||
parsed = parser(body)
|
||||
if not parsed:
|
||||
continue
|
||||
if pr_number is not None and parsed.get("pr_number") not in (None, pr_number):
|
||||
continue
|
||||
entries.append({
|
||||
**parsed,
|
||||
"comment_id": comment.get("id"),
|
||||
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"updated_at": comment.get("updated_at"),
|
||||
})
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def _lease_expired(lease: dict, *, now: datetime) -> bool:
|
||||
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||
return bool(expires_at and expires_at <= now)
|
||||
|
||||
|
||||
def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool:
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_REVIEWER_PHASES or phase in _TERMINAL_CONFLICT_FIX_PHASES:
|
||||
return False
|
||||
return phase in active_phases or bool(phase and phase not in (
|
||||
_TERMINAL_REVIEWER_PHASES | _TERMINAL_CONFLICT_FIX_PHASES
|
||||
))
|
||||
|
||||
|
||||
def find_active_reviewer_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired reviewer lease for *pr_number*, if any."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "reviewer"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_REVIEWER_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_REVIEWER_PHASES or phase:
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def find_active_conflict_fix_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired conflict-fix lease for *pr_number*, if any."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "conflict_fix"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_CONFLICT_FIX_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_CONFLICT_FIX_PHASES or phase:
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def format_conflict_fix_lease_body(
|
||||
*,
|
||||
pr_number: int,
|
||||
branch: str,
|
||||
worktree: str,
|
||||
profile: str,
|
||||
head_before: str,
|
||||
phase: str = "claimed",
|
||||
session_id: str = "unknown",
|
||||
expires_at: datetime | None = None,
|
||||
reviewer_active: bool = False,
|
||||
) -> str:
|
||||
expires = expires_at or (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=DEFAULT_CONFLICT_FIX_TTL_MINUTES)
|
||||
)
|
||||
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
lines = [
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
f"pr: #{pr_number}",
|
||||
f"branch: {branch}",
|
||||
f"worktree: {worktree}",
|
||||
f"profile: {profile}",
|
||||
f"session_id: {session_id}",
|
||||
f"phase: {phase}",
|
||||
f"head_before: {head_before}",
|
||||
f"expires_at: {expires_text}",
|
||||
f"reviewer_active: {'yes' if reviewer_active else 'no'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def assess_head_sha_equality(
|
||||
reviewed_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when reviewed and live PR heads differ."""
|
||||
reviewed = _normalize_sha(reviewed_head_sha)
|
||||
live = _normalize_sha(live_head_sha)
|
||||
reasons: list[str] = []
|
||||
if not reviewed or not live:
|
||||
reasons.append(
|
||||
"reviewed/live head SHA missing or not full 40-hex; fail closed"
|
||||
)
|
||||
elif reviewed != live:
|
||||
reasons.append(
|
||||
"PR head changed after validation; re-pin and re-validate before "
|
||||
"approval or merge"
|
||||
)
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"reviewed_head_sha": reviewed,
|
||||
"live_head_sha": live,
|
||||
"head_changed": bool(reviewed and live and reviewed != live),
|
||||
}
|
||||
|
||||
|
||||
def assess_conflict_fix_push(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
branch_head_before: str | None,
|
||||
branch_head_after: str | None,
|
||||
worktree_path: str | None,
|
||||
push_cwd: str | None,
|
||||
is_fast_forward: bool | None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Author pre-push gate: block when a reviewer holds an active lease."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
reviewer_lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
|
||||
|
||||
if reviewer_lease:
|
||||
reasons.append(
|
||||
f"active reviewer lease on PR #{pr_number} "
|
||||
f"(phase={reviewer_lease.get('phase')}); author push blocked"
|
||||
)
|
||||
|
||||
head_before = _normalize_sha(branch_head_before)
|
||||
head_after = _normalize_sha(branch_head_after)
|
||||
if not head_before:
|
||||
reasons.append("branch head before push missing or invalid SHA")
|
||||
if head_after and head_before and head_before == head_after:
|
||||
reasons.append("branch head unchanged; no push to perform")
|
||||
|
||||
worktree = (worktree_path or "").strip()
|
||||
cwd = (push_cwd or "").strip()
|
||||
if not worktree:
|
||||
reasons.append("worktree path required for conflict-fix push proof")
|
||||
elif cwd and worktree and not cwd.rstrip("/").endswith(worktree.rstrip("/").split("/")[-1]):
|
||||
if worktree not in cwd:
|
||||
reasons.append(
|
||||
f"push cwd '{cwd}' does not match session worktree '{worktree}'"
|
||||
)
|
||||
|
||||
if is_fast_forward is False:
|
||||
reasons.append("non-fast-forward push rejected for conflict-fix (fail closed)")
|
||||
|
||||
if conflict_lease and conflict_lease.get("phase") == "pushing":
|
||||
owner = conflict_lease.get("worktree")
|
||||
if owner and worktree and owner != worktree:
|
||||
reasons.append(
|
||||
f"sibling conflict-fix lease active from worktree '{owner}'"
|
||||
)
|
||||
|
||||
push_allowed = not reasons
|
||||
return {
|
||||
"push_allowed": push_allowed,
|
||||
"block": not push_allowed,
|
||||
"reasons": reasons,
|
||||
"active_reviewer_lease": reviewer_lease,
|
||||
"active_conflict_fix_lease": conflict_lease,
|
||||
"branch_head_before": head_before,
|
||||
"branch_head_after": head_after,
|
||||
"reviewer_was_active": bool(reviewer_lease),
|
||||
"fast_forward": is_fast_forward,
|
||||
}
|
||||
|
||||
|
||||
def assess_reviewer_mutation_blocked(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
reviewed_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
mutation: str,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reviewer gate: block when conflict-fix lease active or head moved."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
|
||||
if conflict_lease and (conflict_lease.get("phase") or "") in _ACTIVE_CONFLICT_FIX_PHASES:
|
||||
reasons.append(
|
||||
f"active conflict-fix lease on PR #{pr_number} "
|
||||
f"(phase={conflict_lease.get('phase')}); reviewer {mutation} blocked"
|
||||
)
|
||||
|
||||
head_check = assess_head_sha_equality(reviewed_head_sha, live_head_sha)
|
||||
if head_check["block"]:
|
||||
reasons.extend(head_check["reasons"])
|
||||
|
||||
if not _normalize_sha(reviewed_head_sha):
|
||||
reasons.append(
|
||||
f"reviewed head SHA required before reviewer {mutation} (fail closed)"
|
||||
)
|
||||
|
||||
allowed = not reasons
|
||||
return {
|
||||
"mutation_allowed": allowed,
|
||||
"block": not allowed,
|
||||
"reasons": reasons,
|
||||
"active_conflict_fix_lease": conflict_lease,
|
||||
"head_check": head_check,
|
||||
"reviewed_head_sha": head_check.get("reviewed_head_sha"),
|
||||
"live_head_sha": head_check.get("live_head_sha"),
|
||||
"push_during_validation": bool(
|
||||
conflict_lease and conflict_lease.get("phase") in {"pushing", "pushed"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_REVIEWED_HEAD_RE = re.compile(
|
||||
r"reviewed head sha\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_HEAD_BEFORE_APPROVAL_RE = re.compile(
|
||||
r"(?:live head sha before approval|final live head sha before approval)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_HEAD_BEFORE_MERGE_RE = re.compile(
|
||||
r"(?:live head sha before merge|final live head sha before merge)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PUSH_DURING_VALIDATION_RE = re.compile(
|
||||
r"push(?:es)? occurred during validation\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFLICT_HEAD_BEFORE_RE = re.compile(
|
||||
r"branch head before push\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFLICT_HEAD_AFTER_RE = re.compile(
|
||||
r"branch head after push\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWER_LEASE_STATUS_RE = re.compile(
|
||||
r"active reviewer lease status\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_FAST_FORWARD_RE = re.compile(
|
||||
r"whether push was fast-forward\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWER_ACTIVE_RE = re.compile(
|
||||
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
|
||||
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)."""
|
||||
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)
|
||||
live_approval = _normalize_sha(
|
||||
_LIVE_HEAD_BEFORE_APPROVAL_RE.search(text).group(1)
|
||||
if _LIVE_HEAD_BEFORE_APPROVAL_RE.search(text)
|
||||
else None
|
||||
)
|
||||
live_merge = _normalize_sha(
|
||||
_LIVE_HEAD_BEFORE_MERGE_RE.search(text).group(1)
|
||||
if _LIVE_HEAD_BEFORE_MERGE_RE.search(text)
|
||||
else None
|
||||
)
|
||||
push_during = _PUSH_DURING_VALIDATION_RE.search(text)
|
||||
|
||||
if not reviewed:
|
||||
reasons.append("reviewed head SHA not stated in final report")
|
||||
if not live_approval:
|
||||
reasons.append("final live head SHA before approval not stated")
|
||||
if not live_merge:
|
||||
reasons.append("final live head SHA before merge not stated")
|
||||
if not push_during:
|
||||
reasons.append("whether push occurred during validation not stated")
|
||||
elif 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")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"reviewed_head_sha": reviewed,
|
||||
"live_head_sha_before_approval": live_approval,
|
||||
"live_head_sha_before_merge": live_merge,
|
||||
"push_during_validation": (push_during.group(1).lower() if push_during else None),
|
||||
}
|
||||
|
||||
|
||||
def assess_conflict_fix_final_report(report_text: str) -> dict[str, Any]:
|
||||
"""Final-report proof for conflict-fix push sessions (#399 AC 7)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
head_before = _normalize_sha(
|
||||
_CONFLICT_HEAD_BEFORE_RE.search(text).group(1)
|
||||
if _CONFLICT_HEAD_BEFORE_RE.search(text)
|
||||
else None
|
||||
)
|
||||
head_after = _normalize_sha(
|
||||
_CONFLICT_HEAD_AFTER_RE.search(text).group(1)
|
||||
if _CONFLICT_HEAD_AFTER_RE.search(text)
|
||||
else None
|
||||
)
|
||||
if not head_before:
|
||||
reasons.append("branch head before push not stated")
|
||||
if not head_after:
|
||||
reasons.append("branch head after push not stated")
|
||||
if not _REVIEWER_LEASE_STATUS_RE.search(text):
|
||||
reasons.append("active reviewer lease status not stated")
|
||||
if not _FAST_FORWARD_RE.search(text):
|
||||
reasons.append("whether push was fast-forward not stated")
|
||||
if not _REVIEWER_ACTIVE_RE.search(text):
|
||||
reasons.append("whether any reviewer was active not stated")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"branch_head_before": head_before,
|
||||
"branch_head_after": head_after,
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Pre-merge baseline proof verifier for non-zero validation exits (#533).
|
||||
|
||||
A controller/reviewer may reproduce a failing test on *current* master and
|
||||
describe it as proof of a "known baseline failure". Reproducing a failure on
|
||||
post-merge master only proves the failure exists on current master — it does
|
||||
NOT prove the failure pre-existed the PR under review. A PR can introduce or
|
||||
preserve a regression, then once merged the failure appears on master and gets
|
||||
mislabeled "baseline", weakening validation gates.
|
||||
|
||||
This module distinguishes four canonical validation outcomes:
|
||||
|
||||
- ``CLEAN_PASS`` — the validation command exited zero.
|
||||
- ``CURRENT_MASTER_FAILURE_REPRODUCED`` — the same failure reproduces on
|
||||
current (post-merge) master. Not sufficient as baseline proof.
|
||||
- ``PREMERGE_BASELINE_PROVEN_FAILURE`` — the failure is proven on the PR's
|
||||
pre-merge base commit, or by a documented known-failure record that predates
|
||||
the PR.
|
||||
- ``UNRESOLVED_REGRESSION_RISK`` — a non-zero exit that is neither a clean pass
|
||||
nor proven pre-existing.
|
||||
|
||||
A report may only call a non-zero validation exit a "baseline"/"pre-existing"
|
||||
failure when it carries pre-merge proof.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEAN_PASS = "clean pass"
|
||||
CURRENT_MASTER_FAILURE_REPRODUCED = "current-master failure reproduced"
|
||||
PREMERGE_BASELINE_PROVEN_FAILURE = "pre-merge baseline-proven failure"
|
||||
UNRESOLVED_REGRESSION_RISK = "unresolved regression risk"
|
||||
|
||||
# A non-zero validation exit is claimed somewhere in the report.
|
||||
_NONZERO_EXIT_RE = re.compile(
|
||||
r"(?:exit(?:\s*(?:status|code))?\s*[:=]?\s*(?:[1-9]\d*)"
|
||||
r"|\b\d+\s+failed\b"
|
||||
r"|\bnon[- ]zero\s+(?:exit|validation))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# The report claims the failure is pre-existing / a baseline failure.
|
||||
_BASELINE_CLAIM_RE = re.compile(
|
||||
r"(?:pre[- ]?existing(?:\s+(?:baseline|failure))?"
|
||||
r"|baseline(?:[- ]proven)?\s+failure"
|
||||
r"|known[- ]baseline"
|
||||
r"|failure\s+is\s+baseline"
|
||||
r"|already\s+fail(?:s|ing)\s+on\s+master)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Only-current-master reproduction language (insufficient on its own).
|
||||
_CURRENT_MASTER_RE = re.compile(
|
||||
r"(?:current[- ]master\s+(?:reproduc|failure)"
|
||||
r"|reproduc\w*\s+on\s+(?:current\s+)?master"
|
||||
r"|post[- ]merge\s+master"
|
||||
r"|fails\s+on\s+(?:current\s+)?master\s+(?:now|too|as\s+well))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Pre-merge base proof: a base commit tested before the PR landed.
|
||||
_PREMERGE_BASE_RE = re.compile(
|
||||
r"(?:pre[- ]merge\s+base(?:\s+commit)?"
|
||||
r"|pr\s+base\s+commit"
|
||||
r"|merge[- ]base(?:\s+commit)?"
|
||||
r"|base\s+commit\s+before\s+(?:the\s+)?pr)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Documented known-failure record predating the PR.
|
||||
_KNOWN_FAILURE_RECORD_RE = re.compile(
|
||||
r"known[- ]failure\s+(?:record|reference|ledger)\b.{0,80}?"
|
||||
r"(?:predat|before\s+(?:the\s+)?pr|pre[- ]existing|prior\s+to\s+(?:the\s+)?pr)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
# Required proof fields for a pre-merge baseline claim.
|
||||
_FIELD_PATTERNS: dict[str, re.Pattern[str]] = {
|
||||
"base commit": re.compile(
|
||||
r"(?:pre[- ]merge\s+)?base\s+commit\s*[:=]\s*([0-9a-f]{7,40})", re.IGNORECASE
|
||||
),
|
||||
"tested commit": re.compile(
|
||||
r"tested\s+commit\s*[:=]\s*([0-9a-f]{7,40})", re.IGNORECASE
|
||||
),
|
||||
"command": re.compile(r"command\s*[:=]\s*\S", re.IGNORECASE),
|
||||
"exit status": re.compile(r"exit\s*(?:status|code)?\s*[:=]\s*\d+", re.IGNORECASE),
|
||||
"failure signature": re.compile(
|
||||
r"failure\s+signature\s*[:=]\s*\S", re.IGNORECASE
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_premerge_baseline_proof(report_text: str) -> dict[str, Any]:
|
||||
"""Assess whether a claimed baseline failure carries pre-merge proof.
|
||||
|
||||
Returns a dict with ``proven``, ``block``, ``label``, ``reasons``,
|
||||
``skipped`` and ``safe_next_action``. Only blocks when the report claims a
|
||||
non-zero validation exit is baseline/pre-existing without valid pre-merge
|
||||
proof.
|
||||
"""
|
||||
text = report_text or ""
|
||||
|
||||
has_failure = bool(_NONZERO_EXIT_RE.search(text))
|
||||
claims_baseline = bool(_BASELINE_CLAIM_RE.search(text))
|
||||
|
||||
# No failure claimed, or no baseline assertion — nothing to enforce here.
|
||||
if not has_failure and not claims_baseline:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"label": CLEAN_PASS,
|
||||
"reasons": [],
|
||||
"skipped": True,
|
||||
"safe_next_action": "",
|
||||
}
|
||||
|
||||
if not claims_baseline:
|
||||
# A non-zero exit not asserted as baseline — surface as regression risk,
|
||||
# but do not block (the report never claimed a clean/baseline pass).
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"label": UNRESOLVED_REGRESSION_RISK,
|
||||
"reasons": [],
|
||||
"skipped": False,
|
||||
"safe_next_action": "",
|
||||
}
|
||||
|
||||
has_premerge_base = bool(_PREMERGE_BASE_RE.search(text))
|
||||
has_known_record = bool(_KNOWN_FAILURE_RECORD_RE.search(text))
|
||||
only_current_master = bool(_CURRENT_MASTER_RE.search(text))
|
||||
|
||||
reasons: list[str] = []
|
||||
|
||||
if not (has_premerge_base or has_known_record):
|
||||
if only_current_master:
|
||||
reasons.append(
|
||||
"baseline failure claimed from current-master reproduction only; "
|
||||
"that proves the failure on current master, not that it pre-existed "
|
||||
"the PR — provide pre-merge base proof or a known-failure record"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
"baseline/pre-existing failure claimed without pre-merge proof "
|
||||
"(no pre-merge base commit and no documented known-failure record "
|
||||
"predating the PR)"
|
||||
)
|
||||
|
||||
# Require the concrete proof fields regardless of proof source.
|
||||
missing = [name for name, pat in _FIELD_PATTERNS.items() if not pat.search(text)]
|
||||
if missing:
|
||||
reasons.append(
|
||||
"pre-merge baseline claim missing required proof field(s): "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"label": UNRESOLVED_REGRESSION_RISK,
|
||||
"reasons": reasons,
|
||||
"skipped": False,
|
||||
"safe_next_action": (
|
||||
"prove the failure on the PR pre-merge base commit (or cite a "
|
||||
"known-failure record predating the PR) and state base commit, "
|
||||
"tested commit, command, exit status, and failure signature; "
|
||||
"current-master reproduction alone is not baseline proof"
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"label": PREMERGE_BASELINE_PROVEN_FAILURE,
|
||||
"reasons": [],
|
||||
"skipped": False,
|
||||
"safe_next_action": "",
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Remote/repo mismatch guard (#530).
|
||||
|
||||
Bare ``remote`` names resolve to a default ``org``/``repo`` via the ``REMOTES``
|
||||
table in :mod:`gitea_auth`. For the ``prgs`` instance the hardcoded default repo
|
||||
is ``Timesheet``, but the tools in this project operate on
|
||||
``Scaled-Tech-Consulting/Gitea-Tools``. When a session runs inside a Gitea-Tools
|
||||
worktree and calls a tool with a bare ``remote=prgs`` (no explicit ``org``/``repo``),
|
||||
the resolved target silently points at the wrong repository, producing false 404s
|
||||
and risking mutation of a different repo.
|
||||
|
||||
This module provides a pure assessment that compares the MCP-resolved ``org/repo``
|
||||
against the local git remote URL and fails closed on a genuine mismatch, unless the
|
||||
caller supplied explicit ``org``/``repo`` (in which case their intent is authoritative)
|
||||
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 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(
|
||||
*,
|
||||
remote: str,
|
||||
resolved_org: str,
|
||||
resolved_repo: str,
|
||||
local_remote_url: str | None,
|
||||
org_explicit: bool,
|
||||
repo_explicit: bool,
|
||||
) -> dict:
|
||||
"""Fail closed when the resolved org/repo disagrees with the local git remote.
|
||||
|
||||
The guard is intentionally conservative:
|
||||
|
||||
* When the caller passed both ``org`` and ``repo`` explicitly, their intent is
|
||||
authoritative and the guard never blocks.
|
||||
* When the local git remote URL is unavailable (``None``/empty), corroboration
|
||||
is impossible, so the guard does not block (best-effort only).
|
||||
* Otherwise, the resolved ``org/repo`` slug must appear in the local remote URL
|
||||
(case-insensitive); if it does not, the guard blocks.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
|
||||
if org_explicit and repo_explicit:
|
||||
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
url = (local_remote_url or "").strip()
|
||||
if not url:
|
||||
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
expected_slug = f"{resolved_org}/{resolved_repo}".lower()
|
||||
if expected_slug in url.lower():
|
||||
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
reasons.append(
|
||||
f"MCP-resolved repository '{resolved_org}/{resolved_repo}' for remote "
|
||||
f"'{remote}' does not match the local git remote URL '{url}'"
|
||||
)
|
||||
return _assessment(False, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
|
||||
def format_remote_repo_guard_error(assessment: dict) -> str:
|
||||
"""Single RuntimeError message for the MCP resolver gate."""
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons") or ["remote/repo resolution mismatch"]
|
||||
)
|
||||
resolved = (
|
||||
f"{assessment.get('resolved_org')}/{assessment.get('resolved_repo')}"
|
||||
)
|
||||
local = assessment.get("local_remote_url") or "(unknown)"
|
||||
return (
|
||||
f"Remote/repo guard (#530): {reasons}. "
|
||||
f"Resolved target: {resolved}; local git remote: {local}. "
|
||||
f"{REMEDIATION}"
|
||||
)
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
remote: str,
|
||||
resolved_org: str,
|
||||
resolved_repo: str,
|
||||
local_remote_url: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"remote": remote,
|
||||
"resolved_org": resolved_org,
|
||||
"resolved_repo": resolved_repo,
|
||||
"local_remote_url": local_remote_url,
|
||||
"remediation": REMEDIATION,
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
+216
-3
@@ -2243,6 +2243,18 @@ HANDOFF_ROLE_FIELDS = {
|
||||
("Linked issue status", ("linked issue status", "linked issue")),
|
||||
("Cleanup status", ("cleanup status", "cleanup")),
|
||||
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
||||
"merger": (
|
||||
("Selected PR", ("selected pr",)),
|
||||
("Pinned reviewed head", ("pinned reviewed head", "pinned head")),
|
||||
("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",)),
|
||||
("Issue lock proof", ("issue lock proof", "lock before diff")),
|
||||
@@ -2417,8 +2429,8 @@ 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.
|
||||
required = [
|
||||
@@ -2431,7 +2443,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 +4842,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 +5004,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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -5115,6 +5294,15 @@ def assess_pr_queue_cleanup_report(report_text: str | None) -> dict:
|
||||
return _assess(report_text or "")
|
||||
|
||||
|
||||
def assess_audit_reconciliation_report(report_text: str | None) -> dict:
|
||||
"""#419: validate audit vs cleanup reconciliation report boundaries."""
|
||||
from audit_reconciliation_mode import (
|
||||
assess_audit_reconciliation_report as _assess,
|
||||
)
|
||||
|
||||
return _assess(report_text or "")
|
||||
|
||||
|
||||
_GATE_PASSED_VALUE = re.compile(r"\bpassed\b", re.I)
|
||||
|
||||
_NOT_APPLICABLE_VALUE = re.compile(
|
||||
@@ -5515,6 +5703,13 @@ def assess_validation_failure_history_report(report_text, **kwargs):
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
|
||||
def assess_validation_cwd_proof_report(report_text, **kwargs):
|
||||
"""#398: validation commands require explicit worktree cwd and HEAD proof."""
|
||||
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report as _assess
|
||||
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
|
||||
def assess_already_landed_classification_report(report_text, **kwargs):
|
||||
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
||||
from reviewer_already_landed_classification import (
|
||||
@@ -5524,6 +5719,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
|
||||
@@ -5601,3 +5805,12 @@ def assess_proof_backed_handoff_report(report_text, **kwargs):
|
||||
from reviewer_proof_backed_handoff import assess_proof_backed_handoff_report as _assess
|
||||
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
|
||||
def assess_mutation_capability_proof(report_text, **kwargs):
|
||||
"""#405: exact per-mutation capability proof in reviewer final reports."""
|
||||
from reviewer_mutation_capability_proof import (
|
||||
assess_mutation_capability_proof as _assess,
|
||||
)
|
||||
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Reviewer session boundary tracking for workflow-load gate (#403).
|
||||
|
||||
Pre-review commands executed before ``gitea_load_review_workflow`` must be
|
||||
classified. Boundary violations block downstream reviewer mutations even when
|
||||
workflow hash proof is present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLASSIFICATION_READ_ONLY_INVENTORY = "read_only_inventory"
|
||||
CLASSIFICATION_DIAGNOSTIC = "diagnostic"
|
||||
CLASSIFICATION_BOUNDARY_VIOLATION = "boundary_violation"
|
||||
CLASSIFICATION_UNCLASSIFIED = "unclassified"
|
||||
|
||||
ALLOWED_CLASSIFICATIONS = frozenset({
|
||||
CLASSIFICATION_READ_ONLY_INVENTORY,
|
||||
CLASSIFICATION_DIAGNOSTIC,
|
||||
CLASSIFICATION_BOUNDARY_VIOLATION,
|
||||
CLASSIFICATION_UNCLASSIFIED,
|
||||
})
|
||||
|
||||
_PRE_REVIEW_COMMANDS: list[dict[str, Any]] = []
|
||||
|
||||
_READ_ONLY_INVENTORY_PATTERNS = (
|
||||
re.compile(
|
||||
r"\bgitea[_-](?:list|view|whoami|get[-_]|resolve[-_]task|check[-_]pr|route[-_]task)",
|
||||
re.I,
|
||||
),
|
||||
re.compile(r"\bgit\s+(?:fetch|remote\s+update|branch\s+-a|log|show|rev-parse)\b", re.I),
|
||||
re.compile(r"\bgit\s+status\b", re.I),
|
||||
re.compile(r"\bgit\s+worktree\s+list\b", re.I),
|
||||
)
|
||||
|
||||
_DIAGNOSTIC_PATTERNS = (
|
||||
re.compile(r"\bgit\s+diff(?:\s+--stat)?\b", re.I),
|
||||
re.compile(r"\bwhich\s+pytest\b", re.I),
|
||||
re.compile(r"\bpytest\s+--version\b", re.I),
|
||||
)
|
||||
|
||||
_BOUNDARY_VIOLATION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"\b(?:pytest|python\s+-m\s+pytest|python\s+-m\s+unittest)\b", re.I),
|
||||
"validation command before workflow load"),
|
||||
(re.compile(r"\bprofiles\.json\b", re.I), "local profile config inspection"),
|
||||
(re.compile(r"\bgitea-mcp(?:\.v2-contexts)?\.json\b", re.I),
|
||||
"local Gitea MCP config inspection"),
|
||||
(re.compile(r"\b\.env(?:\.|$|\b)", re.I), "credential file inspection"),
|
||||
(re.compile(r"\bkeychain\b", re.I), "credential store inspection"),
|
||||
(re.compile(r"\bpkill\b", re.I), "MCP repair activity"),
|
||||
(re.compile(r"\b(?:edit|write|modify).{0,40}\bmcp\b", re.I),
|
||||
"MCP config exploration"),
|
||||
(re.compile(r"\bgit\s+(?:add|commit|reset|clean|checkout|merge|rebase|push)\b", re.I),
|
||||
"git mutation before workflow load"),
|
||||
)
|
||||
|
||||
|
||||
def clear_pre_review_commands() -> None:
|
||||
"""Test helper and session reset."""
|
||||
global _PRE_REVIEW_COMMANDS
|
||||
_PRE_REVIEW_COMMANDS = []
|
||||
|
||||
|
||||
def pre_review_commands() -> list[dict[str, Any]]:
|
||||
"""Return a shallow copy of recorded pre-review commands."""
|
||||
return [dict(entry) for entry in _PRE_REVIEW_COMMANDS]
|
||||
|
||||
|
||||
def _normalize_path(path: str | None) -> str:
|
||||
return os.path.realpath(os.path.abspath((path or "").strip() or os.getcwd()))
|
||||
|
||||
|
||||
def is_main_checkout_path(cwd: str | None, project_root: str | None) -> bool:
|
||||
"""True when *cwd* is the stable control checkout (not under branches/)."""
|
||||
if not project_root:
|
||||
return False
|
||||
root = _normalize_path(project_root)
|
||||
path = _normalize_path(cwd)
|
||||
if path != root:
|
||||
return False
|
||||
marker = f"{os.sep}branches{os.sep}"
|
||||
return marker not in path
|
||||
|
||||
|
||||
def classify_pre_review_command(
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
project_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify a command executed before workflow load."""
|
||||
text = (command or "").strip()
|
||||
path = _normalize_path(cwd)
|
||||
root = _normalize_path(project_root) if project_root else None
|
||||
reasons: list[str] = []
|
||||
|
||||
for pattern, label in _BOUNDARY_VIOLATION_PATTERNS:
|
||||
if pattern.search(text):
|
||||
if label.startswith("validation") and root and not is_main_checkout_path(path, root):
|
||||
continue
|
||||
if label.startswith("git mutation") and root and not is_main_checkout_path(path, root):
|
||||
continue
|
||||
reasons.append(label)
|
||||
return {
|
||||
"command": text,
|
||||
"cwd": path,
|
||||
"classification": CLASSIFICATION_BOUNDARY_VIOLATION,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
for pattern in _READ_ONLY_INVENTORY_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return {
|
||||
"command": text,
|
||||
"cwd": path,
|
||||
"classification": CLASSIFICATION_READ_ONLY_INVENTORY,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
for pattern in _DIAGNOSTIC_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return {
|
||||
"command": text,
|
||||
"cwd": path,
|
||||
"classification": CLASSIFICATION_DIAGNOSTIC,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
if root and is_main_checkout_path(path, root):
|
||||
if re.search(r"\b(?:cat|head|less|read)\b", text, re.I):
|
||||
if re.search(r"workflow|skill|runbook", text, re.I):
|
||||
return {
|
||||
"command": text,
|
||||
"cwd": path,
|
||||
"classification": CLASSIFICATION_BOUNDARY_VIOLATION,
|
||||
"reasons": [
|
||||
"canonical workflow viewed as local file without "
|
||||
"gitea_load_review_workflow (narrative load is not proof)"
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"command": text,
|
||||
"cwd": path,
|
||||
"classification": CLASSIFICATION_UNCLASSIFIED,
|
||||
"reasons": [
|
||||
"pre-review command not classified; record via "
|
||||
"gitea_record_pre_review_command before workflow load"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def record_pre_review_command(
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
project_root: str | None = None,
|
||||
classification: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record and classify a pre-review command for the current session."""
|
||||
assessed = classify_pre_review_command(
|
||||
command, cwd=cwd, project_root=project_root)
|
||||
if classification:
|
||||
if classification not in ALLOWED_CLASSIFICATIONS:
|
||||
assessed["classification"] = CLASSIFICATION_UNCLASSIFIED
|
||||
assessed["reasons"] = [
|
||||
f"unknown classification '{classification}'; fail closed"
|
||||
]
|
||||
else:
|
||||
assessed["classification"] = classification
|
||||
assessed["reasons"] = []
|
||||
entry = {
|
||||
**assessed,
|
||||
"session_pid": os.getpid(),
|
||||
}
|
||||
_PRE_REVIEW_COMMANDS.append(entry)
|
||||
return dict(entry)
|
||||
|
||||
|
||||
def assess_boundary_status(project_root: str | None = None) -> dict[str, Any]:
|
||||
"""Summarize pre-review boundary state for session proof and reports."""
|
||||
violations = [
|
||||
entry for entry in _PRE_REVIEW_COMMANDS
|
||||
if entry.get("classification") == CLASSIFICATION_BOUNDARY_VIOLATION
|
||||
]
|
||||
unclassified = [
|
||||
entry for entry in _PRE_REVIEW_COMMANDS
|
||||
if entry.get("classification") == CLASSIFICATION_UNCLASSIFIED
|
||||
]
|
||||
reasons: list[str] = []
|
||||
for entry in violations:
|
||||
reasons.extend(entry.get("reasons") or [
|
||||
f"boundary violation: {entry.get('command', '')[:80]}"
|
||||
])
|
||||
for entry in unclassified:
|
||||
reasons.extend(entry.get("reasons") or [
|
||||
"unclassified pre-review command blocks reviewer mutations"
|
||||
])
|
||||
|
||||
clean = not reasons
|
||||
return {
|
||||
"boundary_status": "clean" if clean else "violation",
|
||||
"boundary_clean": clean,
|
||||
"pre_review_command_count": len(_PRE_REVIEW_COMMANDS),
|
||||
"boundary_violation_count": len(violations),
|
||||
"unclassified_command_count": len(unclassified),
|
||||
"violations": [
|
||||
{
|
||||
"command": v.get("command"),
|
||||
"cwd": v.get("cwd"),
|
||||
"reasons": list(v.get("reasons") or []),
|
||||
}
|
||||
for v in violations
|
||||
],
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def boundary_blockers(project_root: str | None = None) -> list[str]:
|
||||
"""Reasons reviewer mutations must fail closed due to boundary state."""
|
||||
status = assess_boundary_status(project_root)
|
||||
if status.get("boundary_clean"):
|
||||
return []
|
||||
return list(status.get("reasons") or [
|
||||
"reviewer session boundary violation before workflow load"
|
||||
])
|
||||
|
||||
|
||||
def workflow_load_helper_result(
|
||||
load: dict | None,
|
||||
project_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Structured helper result for final reports (#403)."""
|
||||
boundary = assess_boundary_status(project_root)
|
||||
if load is None:
|
||||
return {
|
||||
"workflow_load_proof_present": False,
|
||||
"workflow_source": None,
|
||||
"workflow_hash": None,
|
||||
"final_report_schema_hash": None,
|
||||
"boundary_status": boundary.get("boundary_status"),
|
||||
"boundary_clean": False,
|
||||
"reasons": [
|
||||
"gitea_load_review_workflow helper result missing from report"
|
||||
],
|
||||
}
|
||||
return {
|
||||
"workflow_load_proof_present": True,
|
||||
"workflow_source": load.get("workflow_source"),
|
||||
"workflow_hash": load.get("workflow_hash"),
|
||||
"final_report_schema_path": load.get("final_report_schema_path"),
|
||||
"final_report_schema_hash": load.get("final_report_schema_hash"),
|
||||
"boundary_status": load.get("boundary_status", boundary.get("boundary_status")),
|
||||
"boundary_clean": bool(load.get("boundary_clean", boundary.get("boundary_clean"))),
|
||||
"pre_review_command_count": boundary.get("pre_review_command_count"),
|
||||
"reasons": [],
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403, #559)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import mcp_session_state
|
||||
import review_workflow_boundary as boundary
|
||||
|
||||
WORKFLOW_REL_PATH = (
|
||||
"skills/llm-project-workflow/workflows/review-merge-pr.md"
|
||||
)
|
||||
SCHEMA_REL_PATH = (
|
||||
"skills/llm-project-workflow/schemas/review-merge-final-report.md"
|
||||
)
|
||||
TASK_MODE = "review-merge-pr"
|
||||
LOAD_TOOL_NAME = "gitea_load_review_workflow"
|
||||
|
||||
_REVIEW_WORKFLOW_LOAD: dict | None = None
|
||||
|
||||
|
||||
def compute_content_hash(text: str) -> str:
|
||||
"""Short deterministic hash for workflow/schema version proof."""
|
||||
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _canonical_paths(project_root: str) -> tuple[Path, Path]:
|
||||
root = Path(project_root)
|
||||
workflow = root / WORKFLOW_REL_PATH
|
||||
schema = root / SCHEMA_REL_PATH
|
||||
if not workflow.is_file():
|
||||
raise FileNotFoundError(f"canonical workflow missing: {workflow}")
|
||||
if not schema.is_file():
|
||||
raise FileNotFoundError(f"final report schema missing: {schema}")
|
||||
return workflow, schema
|
||||
|
||||
|
||||
def build_canonical_workflow_metadata(
|
||||
project_root: str,
|
||||
*,
|
||||
prompt_text: str | None = None,
|
||||
) -> dict:
|
||||
"""Load workflow + schema from disk and compute proof metadata."""
|
||||
workflow_path, schema_path = _canonical_paths(project_root)
|
||||
workflow_text = _read_text(workflow_path)
|
||||
schema_text = _read_text(schema_path)
|
||||
workflow_hash = compute_content_hash(workflow_text)
|
||||
schema_hash = compute_content_hash(schema_text)
|
||||
conflict, conflict_reasons = assess_prompt_conflict(prompt_text)
|
||||
return {
|
||||
"workflow_source": WORKFLOW_REL_PATH,
|
||||
"workflow_path": str(workflow_path),
|
||||
"task_mode": TASK_MODE,
|
||||
"workflow_hash": workflow_hash,
|
||||
"workflow_version": workflow_hash,
|
||||
"final_report_schema_path": SCHEMA_REL_PATH,
|
||||
"final_report_schema_hash": schema_hash,
|
||||
"prompt_conflicts_with_workflow": conflict,
|
||||
"prompt_conflict_reasons": conflict_reasons,
|
||||
"load_tool": LOAD_TOOL_NAME,
|
||||
}
|
||||
|
||||
|
||||
def assess_prompt_conflict(prompt_text: str | None) -> tuple[bool, list[str]]:
|
||||
"""Detect obvious task-mode conflicts between prompt and review workflow."""
|
||||
if not (prompt_text or "").strip():
|
||||
return False, []
|
||||
text = prompt_text.lower()
|
||||
reasons: list[str] = []
|
||||
conflicting = (
|
||||
(r"\bwork[- ]issue\b", "work-issue author mode"),
|
||||
(r"\bcreate[- ]issue\b", "create-issue mode"),
|
||||
(r"\bauthor/coder\b", "author/coder mode"),
|
||||
(r"\breconcile[- ]landed\b", "reconcile-landed mode"),
|
||||
)
|
||||
for pattern, label in conflicting:
|
||||
if re.search(pattern, text):
|
||||
reasons.append(
|
||||
f"active prompt appears to request {label} while loading "
|
||||
f"{TASK_MODE} workflow"
|
||||
)
|
||||
return bool(reasons), reasons
|
||||
|
||||
|
||||
def _session_binding_fields() -> dict:
|
||||
"""Capture profile identity used to share state across daemon processes."""
|
||||
env_lock = (os.environ.get(mcp_session_state.SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
profile_name = (os.environ.get("GITEA_MCP_PROFILE") or "").strip()
|
||||
remote = (os.environ.get("GITEA_MCP_REMOTE") or "").strip() or None
|
||||
identity = mcp_session_state.current_profile_identity(
|
||||
profile_name=profile_name,
|
||||
session_profile_lock=env_lock,
|
||||
)
|
||||
return {
|
||||
"session_profile": profile_name or identity,
|
||||
"session_profile_lock": env_lock or identity,
|
||||
"profile_identity": identity,
|
||||
"remote": remote,
|
||||
}
|
||||
|
||||
|
||||
def _persist_workflow_load(record: dict | None) -> dict | None:
|
||||
"""Write durable workflow-load proof (or clear it)."""
|
||||
binding = _session_binding_fields()
|
||||
if record is None:
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
remote=binding.get("remote"),
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
return None
|
||||
payload = dict(record)
|
||||
payload.update({
|
||||
k: v for k, v in binding.items() if v is not None
|
||||
})
|
||||
return mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
payload=payload,
|
||||
remote=payload.get("remote"),
|
||||
org=payload.get("org"),
|
||||
repo=payload.get("repo"),
|
||||
profile_identity=payload.get("profile_identity"),
|
||||
)
|
||||
|
||||
|
||||
def _load_durable_workflow_load() -> dict | None:
|
||||
binding = _session_binding_fields()
|
||||
return mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
remote=binding.get("remote"),
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
|
||||
|
||||
def _active_workflow_load() -> dict | None:
|
||||
"""Prefer in-process cache; fall back to durable shared state (#559)."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
if _REVIEW_WORKFLOW_LOAD is not None:
|
||||
return _REVIEW_WORKFLOW_LOAD
|
||||
durable = _load_durable_workflow_load()
|
||||
if durable is not None:
|
||||
_REVIEW_WORKFLOW_LOAD = dict(durable)
|
||||
return _REVIEW_WORKFLOW_LOAD
|
||||
|
||||
|
||||
def record_review_workflow_load(
|
||||
project_root: str,
|
||||
*,
|
||||
prompt_text: str | None = None,
|
||||
) -> dict:
|
||||
"""Record workflow load proof for the current MCP session (durable + memory)."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
meta = build_canonical_workflow_metadata(
|
||||
project_root, prompt_text=prompt_text)
|
||||
boundary_state = boundary.assess_boundary_status(project_root)
|
||||
binding = _session_binding_fields()
|
||||
record = {
|
||||
**meta,
|
||||
"session_pid": os.getpid(),
|
||||
"loaded": True,
|
||||
"boundary_status": boundary_state.get("boundary_status"),
|
||||
"boundary_clean": boundary_state.get("boundary_clean"),
|
||||
"pre_review_command_count": boundary_state.get("pre_review_command_count"),
|
||||
"boundary_violation_count": boundary_state.get("boundary_violation_count"),
|
||||
"boundary_reasons": list(boundary_state.get("reasons") or []),
|
||||
**{k: v for k, v in binding.items() if v is not None},
|
||||
}
|
||||
persisted = _persist_workflow_load(record)
|
||||
_REVIEW_WORKFLOW_LOAD = dict(persisted or record)
|
||||
return dict(_REVIEW_WORKFLOW_LOAD)
|
||||
|
||||
|
||||
def clear_review_workflow_load() -> None:
|
||||
"""Test helper and review_pr session reset."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
_REVIEW_WORKFLOW_LOAD = None
|
||||
_persist_workflow_load(None)
|
||||
boundary.clear_pre_review_commands()
|
||||
|
||||
|
||||
def workflow_load_status(project_root: str | None = None) -> dict:
|
||||
"""Non-throwing status for capability/runtime reports."""
|
||||
load = _active_workflow_load()
|
||||
if load is None:
|
||||
return {
|
||||
"workflow_load_proof_present": False,
|
||||
"workflow_load_valid": False,
|
||||
"workflow_source": None,
|
||||
"workflow_hash": None,
|
||||
"final_report_schema_path": SCHEMA_REL_PATH,
|
||||
"reasons": [
|
||||
f"{LOAD_TOOL_NAME} has not been called in this session "
|
||||
"(fail closed for reviewer mutations)"
|
||||
],
|
||||
}
|
||||
reasons = _session_validation_reasons(load, project_root)
|
||||
boundary_reasons = boundary.boundary_blockers(project_root)
|
||||
if boundary_reasons:
|
||||
reasons = list(reasons) + boundary_reasons
|
||||
return {
|
||||
"workflow_load_proof_present": True,
|
||||
"workflow_load_valid": not reasons,
|
||||
"workflow_source": load.get("workflow_source"),
|
||||
"workflow_hash": load.get("workflow_hash"),
|
||||
"task_mode": load.get("task_mode"),
|
||||
"final_report_schema_path": load.get("final_report_schema_path"),
|
||||
"final_report_schema_hash": load.get("final_report_schema_hash"),
|
||||
"prompt_conflicts_with_workflow": load.get(
|
||||
"prompt_conflicts_with_workflow"),
|
||||
"session_pid": load.get("session_pid"),
|
||||
"writer_pid": load.get("writer_pid"),
|
||||
"profile_identity": load.get("profile_identity"),
|
||||
"boundary_status": load.get("boundary_status"),
|
||||
"boundary_clean": load.get("boundary_clean"),
|
||||
"workflow_load_helper_result": boundary.workflow_load_helper_result(
|
||||
load, project_root),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def _session_validation_reasons(
|
||||
load: dict,
|
||||
project_root: str | None,
|
||||
) -> list[str]:
|
||||
"""Validate durable/in-memory load proof for this session identity (#559)."""
|
||||
reasons: list[str] = []
|
||||
# Cross-process daemon pools are allowed when profile identity matches.
|
||||
# Reject only when the stored profile identity conflicts with this process.
|
||||
binding = _session_binding_fields()
|
||||
stored_identity = (
|
||||
load.get("session_profile_lock")
|
||||
or load.get("profile_identity")
|
||||
or ""
|
||||
).strip()
|
||||
active_identity = (binding.get("profile_identity") or "").strip()
|
||||
if (
|
||||
stored_identity
|
||||
and active_identity
|
||||
and stored_identity != active_identity
|
||||
and active_identity != "unknown-profile"
|
||||
and stored_identity != "unknown-profile"
|
||||
):
|
||||
reasons.append(
|
||||
"workflow load proof profile identity mismatch "
|
||||
f"(stored={stored_identity!r}, active={active_identity!r}; fail closed)"
|
||||
)
|
||||
return reasons
|
||||
|
||||
# Expired durable records are treated as absent.
|
||||
identity_reasons = mcp_session_state.identity_match_reasons(
|
||||
load,
|
||||
remote=binding.get("remote") or load.get("remote"),
|
||||
org=load.get("org"),
|
||||
repo=load.get("repo"),
|
||||
profile_identity=active_identity or stored_identity,
|
||||
)
|
||||
# Filter out remote-mismatch noise when remote was not bound at load time.
|
||||
for reason in identity_reasons:
|
||||
if "missing recorded_at" in reason or "expired" in reason or "future" in reason:
|
||||
reasons.append(reason)
|
||||
elif "profile identity mismatch" in reason:
|
||||
reasons.append(reason)
|
||||
if reasons:
|
||||
return reasons
|
||||
|
||||
if load.get("prompt_conflicts_with_workflow"):
|
||||
reasons.extend(load.get("prompt_conflict_reasons") or [
|
||||
"active prompt conflicts with loaded review-merge workflow"
|
||||
])
|
||||
if project_root:
|
||||
try:
|
||||
current = build_canonical_workflow_metadata(project_root)
|
||||
except OSError as exc:
|
||||
reasons.append(f"cannot re-verify workflow hash: {exc}")
|
||||
return reasons
|
||||
if current["workflow_hash"] != load.get("workflow_hash"):
|
||||
reasons.append(
|
||||
"stored workflow hash is stale; reload via "
|
||||
f"{LOAD_TOOL_NAME} (fail closed)"
|
||||
)
|
||||
if current["final_report_schema_hash"] != load.get(
|
||||
"final_report_schema_hash"):
|
||||
reasons.append(
|
||||
"stored final-report schema hash is stale; reload via "
|
||||
f"{LOAD_TOOL_NAME} (fail closed)"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def review_workflow_load_blockers(
|
||||
project_root: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Reasons reviewer mutations must fail closed."""
|
||||
boundary_reasons = boundary.boundary_blockers(project_root)
|
||||
load = _active_workflow_load()
|
||||
if boundary_reasons and load is None:
|
||||
return boundary_reasons
|
||||
status = workflow_load_status(project_root)
|
||||
if not status.get("workflow_load_proof_present"):
|
||||
return list(status.get("reasons") or []) + boundary_reasons
|
||||
if not status.get("workflow_load_valid"):
|
||||
return list(status.get("reasons") or [])
|
||||
return []
|
||||
|
||||
|
||||
def recovery_handoff_without_replay() -> list[str]:
|
||||
"""Safe next-step lines that must not include approve/merge replay."""
|
||||
return [
|
||||
"Reload the canonical workflow via gitea_load_review_workflow, then "
|
||||
"rerun the full review-merge workflow from inventory.",
|
||||
"Do not call gitea_submit_pr_review, gitea_mark_final_review_decision, "
|
||||
"or gitea_merge_pr until workflow-load proof is present.",
|
||||
"Do not include approve/merge replay commands in the recovery 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,
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Exact per-mutation capability proof verifier for reviewer reports (#405).
|
||||
|
||||
A reviewer final report may prove ``review_pr`` capability and then also merge
|
||||
a PR or delete a remote branch. Merge and branch deletion are separate
|
||||
mutations that require their own exact capability proof — a nearby capability
|
||||
must never authorize a different operation. This verifier requires a
|
||||
mutation-capability table pairing every performed mutation with the exact
|
||||
task/permission resolved *before* that mutation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Mutations this verifier tracks, with the exact capability tokens that
|
||||
# authorize each. A row for the mutation must cite one of its own tokens;
|
||||
# tokens from a different mutation (a "nearby capability") never count.
|
||||
_REVIEW_TOKENS = ("review_pr", "gitea.pr.review", "gitea.pr.approve",
|
||||
"gitea.pr.request_changes", "request_changes_pr", "approve_pr")
|
||||
_MERGE_TOKENS = ("merge_pr", "gitea.pr.merge")
|
||||
_DELETE_TOKENS = ("delete_branch", "gitea.branch.delete")
|
||||
|
||||
# Detect that a mutation was actually performed (not merely mentioned as a
|
||||
# non-goal or skipped).
|
||||
_MERGE_PERFORMED = re.compile(
|
||||
r"(?:gitea_merge_pr\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|"
|
||||
r"^\s*[-*]?\s*merge result\s*:\s*merged\b|"
|
||||
r"\bpr merged\b|\bmerge commit\s*(?:sha)?\s*[:=]?\s*[0-9a-f]{7,})",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_DELETE_PERFORMED = re.compile(
|
||||
r"(?:gitea_delete_branch\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|"
|
||||
r"^\s*[-*]?\s*(?:remote )?branch deleted\s*:|"
|
||||
r"\bdeleted (?:the )?(?:remote )?branch\b|"
|
||||
r"^\s*[-*]?\s*branch deletion\s*:\s*(?!skipped|none|not)\S)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_REVIEW_PERFORMED = re.compile(
|
||||
r"(?:gitea_submit_pr_review\b|gitea_mark_final_review_decision\b|"
|
||||
r"^\s*[-*]?\s*review (?:decision|verdict|mutation)\s*:\s*"
|
||||
r"(?:approved|request[_ ]changes)\b|\breview submitted\b)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Post-hoc proof: capability resolved *after* the mutation is never valid.
|
||||
_POST_HOC = re.compile(
|
||||
r"capabilit(?:y|ies)\s+(?:resolved|proven|checked)\s+(?:after|post[- ])\s*"
|
||||
r"(?:the\s+)?(?:merge|deletion|delete|mutation|review)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# The report must carry an explicit mutation-capability table.
|
||||
_TABLE_MARKER = re.compile(
|
||||
r"mutation[- ]capability(?:\s+table)?|capability[- ]per[- ]mutation",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _tokens_present(text: str, tokens: tuple[str, ...]) -> bool:
|
||||
low = text.lower()
|
||||
return any(tok.lower() in low for tok in tokens)
|
||||
|
||||
|
||||
def assess_mutation_capability_proof(report_text: str) -> dict:
|
||||
"""Validate exact per-mutation capability proof in a reviewer report.
|
||||
|
||||
Returns ``{proven, block, reasons, safe_next_action}``. A report that
|
||||
performs no mutation beyond an ordinary review passes only when its
|
||||
review capability is cited; merge/delete each demand their own exact
|
||||
capability row. Fail closed on nearby-capability substitution, a
|
||||
missing table, missing rows, or post-hoc proof.
|
||||
"""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
merged = bool(_MERGE_PERFORMED.search(text))
|
||||
deleted = bool(_DELETE_PERFORMED.search(text))
|
||||
reviewed = bool(_REVIEW_PERFORMED.search(text))
|
||||
|
||||
extra_mutation = merged or deleted
|
||||
|
||||
if _POST_HOC.search(text):
|
||||
reasons.append(
|
||||
"capability proof recorded after the mutation; exact capability "
|
||||
"must be resolved before each mutation"
|
||||
)
|
||||
|
||||
# A review-only report needs its review capability cited; no table required.
|
||||
if reviewed and not _tokens_present(text, _REVIEW_TOKENS):
|
||||
reasons.append(
|
||||
"review mutation performed without exact review capability proof "
|
||||
"(review_pr / gitea.pr.review)"
|
||||
)
|
||||
|
||||
if extra_mutation and not _TABLE_MARKER.search(text):
|
||||
reasons.append(
|
||||
"mutation beyond review performed without a mutation-capability "
|
||||
"table (mutation, exact task/capability, result, order-before)"
|
||||
)
|
||||
|
||||
if merged:
|
||||
if not _tokens_present(text, _MERGE_TOKENS):
|
||||
reasons.append(
|
||||
"merge performed without exact merge capability proof "
|
||||
"(merge_pr / gitea.pr.merge); nearby review_pr does not "
|
||||
"authorize merge"
|
||||
)
|
||||
|
||||
if deleted:
|
||||
if not _tokens_present(text, _DELETE_TOKENS):
|
||||
reasons.append(
|
||||
"branch deletion performed without exact delete capability "
|
||||
"proof (delete_branch / gitea.branch.delete); nearby "
|
||||
"merge_pr does not authorize branch deletion"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"proceed"
|
||||
if proven
|
||||
else "add a mutation-capability table with the exact resolved "
|
||||
"task/permission and pre-mutation order for every mutation; "
|
||||
"skip any mutation whose exact capability is unproven"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
"""Per-PR reviewer leases for safe parallel review sessions (#407)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
MARKER = "<!-- mcp-review-lease:v1 -->"
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_TERMINAL_PHASES = frozenset({"done", "released", "blocked"})
|
||||
_ACTIVE_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
"approved",
|
||||
"request-changes",
|
||||
"merging",
|
||||
"adopted",
|
||||
})
|
||||
|
||||
DEFAULT_LEASE_TTL_MINUTES = 120
|
||||
STALE_WARNING_MINUTES = 30
|
||||
RECLAIMABLE_MINUTES = 60
|
||||
|
||||
_SESSION_LEASE: dict[str, Any] | None = None
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _normalize_sha(value: str | None) -> str | None:
|
||||
text = (value or "").strip().lower()
|
||||
return text if text and _FULL_SHA.match(text) else None
|
||||
|
||||
|
||||
def _parse_pr_ref(value: str | None) -> int | None:
|
||||
digits = re.sub(r"[^\d]", "", value or "")
|
||||
return int(digits) if digits.isdigit() else None
|
||||
|
||||
|
||||
def new_session_id() -> str:
|
||||
return f"{os.getpid()}-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def format_lease_body(
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
issue_number: int | None,
|
||||
reviewer_identity: str,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
worktree: str,
|
||||
phase: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
last_activity: datetime | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
blocker: str = "none",
|
||||
) -> str:
|
||||
now = last_activity or datetime.now(timezone.utc)
|
||||
expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES))
|
||||
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
issue_text = f"#{issue_number}" if issue_number else "none"
|
||||
lines = [
|
||||
MARKER,
|
||||
f"repo: {repo}",
|
||||
f"pr: #{pr_number}",
|
||||
f"issue: {issue_text}",
|
||||
f"reviewer_identity: {reviewer_identity}",
|
||||
f"profile: {profile}",
|
||||
f"session_id: {session_id}",
|
||||
f"worktree: {worktree}",
|
||||
f"phase: {phase}",
|
||||
f"candidate_head: {candidate_head or 'none'}",
|
||||
f"target_branch: {target_branch}",
|
||||
f"target_branch_sha: {target_branch_sha or 'none'}",
|
||||
f"last_activity: {last_text}",
|
||||
f"expires_at: {expires_text}",
|
||||
f"blocker: {blocker}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
text = body or ""
|
||||
if MARKER not in text:
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
if not fields:
|
||||
return None
|
||||
return {
|
||||
"repo": fields.get("repo"),
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"issue_number": _parse_pr_ref(fields.get("issue")),
|
||||
"reviewer_identity": fields.get("reviewer_identity"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"candidate_head": _normalize_sha(fields.get("candidate_head")),
|
||||
"target_branch": fields.get("target_branch"),
|
||||
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
|
||||
"last_activity": fields.get("last_activity"),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"blocker": fields.get("blocker"),
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def _lease_entries(comments: list[dict], *, pr_number: int) -> list[dict]:
|
||||
entries: list[dict] = []
|
||||
for comment in comments or []:
|
||||
parsed = parse_lease_comment(comment.get("body") or "")
|
||||
if not parsed:
|
||||
continue
|
||||
if parsed.get("pr_number") not in (None, pr_number):
|
||||
continue
|
||||
entries.append({
|
||||
**parsed,
|
||||
"comment_id": comment.get("id"),
|
||||
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"updated_at": comment.get("updated_at"),
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def _lease_expired(lease: dict, *, now: datetime) -> bool:
|
||||
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||
return bool(expires_at and expires_at <= now)
|
||||
|
||||
|
||||
def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
|
||||
last = _parse_timestamp(lease.get("last_activity"))
|
||||
if not last:
|
||||
return None
|
||||
return (now - last).total_seconds() / 60.0
|
||||
|
||||
|
||||
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
|
||||
"""Return active, stale_warning, reclaimable, expired, or terminal."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_PHASES:
|
||||
return "terminal"
|
||||
if _lease_expired(lease, now=now):
|
||||
return "expired"
|
||||
minutes = _minutes_since_activity(lease, now=now)
|
||||
if minutes is None:
|
||||
return "active"
|
||||
if minutes >= RECLAIMABLE_MINUTES:
|
||||
return "reclaimable"
|
||||
if minutes >= STALE_WARNING_MINUTES:
|
||||
return "stale_warning"
|
||||
return "active"
|
||||
|
||||
|
||||
def find_active_reviewer_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired non-terminal lease for *pr_number*.
|
||||
|
||||
Append-only lease markers form a ledger: the **newest** marker is
|
||||
authoritative. A later terminal phase (``released`` / ``done`` /
|
||||
``blocked``) ends the lease even when older ``claimed`` markers remain
|
||||
on the thread (#577). Skipping only terminal markers and walking older
|
||||
claims incorrectly re-arms a lease after a successful release.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
entries = list(reversed(_lease_entries(comments, pr_number=pr_number)))
|
||||
if not entries:
|
||||
return None
|
||||
newest = entries[0]
|
||||
phase = (newest.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_PHASES:
|
||||
return None
|
||||
if _lease_expired(newest, now=now):
|
||||
return None
|
||||
if phase in _ACTIVE_PHASES or phase:
|
||||
lease = dict(newest)
|
||||
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def assess_acquire_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
reviewer_identity: str,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
repo: str,
|
||||
issue_number: int | None,
|
||||
worktree: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
pr_merged_or_closed: bool = False,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when another session holds an active lease.
|
||||
|
||||
When *pr_merged_or_closed* is true the PR has already merged/closed, so any
|
||||
reviewer-lease acquisition or adoption for merge work is moot: fail closed
|
||||
with a ``post_merge_moot`` reason and never mint a lease body (#515).
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
existing = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
post_merge_moot = bool(pr_merged_or_closed)
|
||||
if post_merge_moot:
|
||||
reasons.append(
|
||||
f"post_merge_moot: PR #{pr_number} is already merged/closed; reviewer "
|
||||
"lease adoption for merge is moot (fail closed)"
|
||||
)
|
||||
if existing:
|
||||
owner_session = (existing.get("session_id") or "").strip()
|
||||
freshness = existing.get("freshness") or classify_lease_freshness(existing, now=now)
|
||||
if owner_session and owner_session != session_id and freshness in {
|
||||
"active", "stale_warning"
|
||||
}:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} already has active reviewer lease "
|
||||
f"(session_id={owner_session}, phase={existing.get('phase')})"
|
||||
)
|
||||
elif owner_session and owner_session != session_id and freshness == "reclaimable":
|
||||
reasons.append(
|
||||
f"PR #{pr_number} lease is reclaimable but still held by "
|
||||
f"session_id={owner_session}; explicit reclaim not implemented "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
if not (reviewer_identity or "").strip():
|
||||
reasons.append("reviewer identity required for lease acquisition")
|
||||
if not (session_id or "").strip():
|
||||
reasons.append("session_id required for lease acquisition")
|
||||
if not (worktree or "").strip():
|
||||
reasons.append("worktree path required for lease acquisition")
|
||||
|
||||
allowed = not reasons
|
||||
body = None
|
||||
if allowed:
|
||||
body = format_lease_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number,
|
||||
reviewer_identity=reviewer_identity,
|
||||
profile=profile,
|
||||
session_id=session_id,
|
||||
worktree=worktree,
|
||||
phase="claimed",
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
last_activity=now,
|
||||
)
|
||||
return {
|
||||
"acquire_allowed": allowed,
|
||||
"reasons": reasons,
|
||||
"existing_lease": existing,
|
||||
"lease_body": body,
|
||||
"session_id": session_id,
|
||||
"post_merge_moot": post_merge_moot,
|
||||
}
|
||||
|
||||
|
||||
def assess_post_merge_moot_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
pr_merged: bool = False,
|
||||
pr_state: str | None = None,
|
||||
merge_commit_sha: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess a reviewer lease left lingering on an already-merged/closed PR (#515).
|
||||
|
||||
Read-first and fail-safe:
|
||||
|
||||
- Only treats a lease as moot when the live PR state is merged/closed.
|
||||
- Never proposes touching an *active* lease while the PR is still open
|
||||
(``cleanup_allowed`` stays false and a refusal reason is returned).
|
||||
- When the PR is merged/closed and a lease is still active, ``cleanup_allowed``
|
||||
is true and a terminal ``phase: released`` lease body (``blocker:
|
||||
post-merge-moot``) is provided so the moot lease can be neutralised by an
|
||||
append-only comment — never by deleting a foreign session's comment, and
|
||||
never by adopting or merging.
|
||||
|
||||
Posting the released body makes that lease terminal, so a subsequent call
|
||||
finds no active lease and reports nothing left to clean (idempotent).
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
merged_or_closed = bool(pr_merged) or (
|
||||
str(pr_state or "").strip().lower() == "closed"
|
||||
)
|
||||
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
# The newest lease comment is authoritative: once a terminal marker
|
||||
# (released/done/blocked) is the latest entry, the lease is resolved even if
|
||||
# an earlier non-terminal comment from the same session still lingers. This
|
||||
# keeps post-merge cleanup idempotent.
|
||||
entries = _lease_entries(comments, pr_number=pr_number)
|
||||
newest = entries[-1] if entries else None
|
||||
newest_terminal = bool(newest) and (
|
||||
(newest.get("phase") or "").strip().lower() in _TERMINAL_PHASES
|
||||
)
|
||||
reasons: list[str] = []
|
||||
cleanup_allowed = False
|
||||
release_body: str | None = None
|
||||
is_moot = bool(active) and merged_or_closed and not newest_terminal
|
||||
|
||||
if not merged_or_closed:
|
||||
if active:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is still open; refusing to touch active reviewer "
|
||||
"lease (fail closed)"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is still open; no post-merge lease cleanup applicable"
|
||||
)
|
||||
elif newest_terminal:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} reviewer lease already released/terminal; nothing to clean"
|
||||
)
|
||||
elif active:
|
||||
cleanup_allowed = True
|
||||
release_body = format_lease_body(
|
||||
repo=active.get("repo") or "",
|
||||
pr_number=pr_number,
|
||||
issue_number=active.get("issue_number"),
|
||||
reviewer_identity=active.get("reviewer_identity") or "",
|
||||
profile=active.get("profile") or "unknown",
|
||||
session_id=active.get("session_id") or "",
|
||||
worktree=active.get("worktree") or "",
|
||||
phase="released",
|
||||
candidate_head=active.get("candidate_head"),
|
||||
target_branch=active.get("target_branch") or "master",
|
||||
target_branch_sha=active.get("target_branch_sha"),
|
||||
last_activity=now,
|
||||
blocker="post-merge-moot",
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is merged/closed but no active reviewer lease remains; "
|
||||
"nothing to clean"
|
||||
)
|
||||
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"pr_state": pr_state,
|
||||
"pr_merged_or_closed": merged_or_closed,
|
||||
"merge_commit_sha": merge_commit_sha,
|
||||
"active_lease": active,
|
||||
"is_moot": is_moot,
|
||||
"cleanup_allowed": cleanup_allowed,
|
||||
"release_body": release_body,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def record_session_lease(
|
||||
lease: dict[str, Any],
|
||||
*,
|
||||
lease_provenance: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record the in-session lease mirror for mutation gates.
|
||||
|
||||
*lease_provenance* must be supplied by sanctioned MCP tools (#536). Bare
|
||||
manual seeding without provenance cannot satisfy merger/reviewer mutation
|
||||
gates.
|
||||
"""
|
||||
global _SESSION_LEASE
|
||||
stored = dict(lease)
|
||||
if lease_provenance:
|
||||
stored["lease_provenance"] = dict(lease_provenance)
|
||||
_SESSION_LEASE = stored
|
||||
return dict(_SESSION_LEASE)
|
||||
|
||||
|
||||
def clear_session_lease() -> None:
|
||||
global _SESSION_LEASE
|
||||
_SESSION_LEASE = None
|
||||
|
||||
|
||||
def get_session_lease() -> dict[str, Any] | None:
|
||||
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
|
||||
|
||||
|
||||
def assess_mutation_lease_gate(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
reviewer_identity: str,
|
||||
session_id: str | None,
|
||||
mutation: str,
|
||||
live_head_sha: str | None,
|
||||
pinned_head_sha: str | None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reviewer mutations require an owned, current PR lease."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
session = get_session_lease()
|
||||
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
|
||||
if not session:
|
||||
reasons.append(
|
||||
f"no in-session reviewer lease recorded; acquire via "
|
||||
f"gitea_acquire_reviewer_pr_lease or adopt via "
|
||||
f"gitea_adopt_merger_pr_lease before {mutation}"
|
||||
)
|
||||
else:
|
||||
import merger_lease_adoption as mla
|
||||
|
||||
if not mla.is_sanctioned_session_lease(session):
|
||||
reasons.append(
|
||||
"in-session lease lacks sanctioned provenance; manual "
|
||||
"_SESSION_LEASE seeding is not canonical proof — use "
|
||||
"gitea_acquire_reviewer_pr_lease or gitea_adopt_merger_pr_lease"
|
||||
)
|
||||
if session and session.get("pr_number") != pr_number:
|
||||
reasons.append(
|
||||
f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}"
|
||||
)
|
||||
elif session and (session.get("session_id") or "") != (
|
||||
session_id or session.get("session_id")
|
||||
):
|
||||
reasons.append("session lease session_id mismatch (fail closed)")
|
||||
|
||||
if active:
|
||||
owner = (active.get("session_id") or "").strip()
|
||||
if owner and session_id and owner != session_id:
|
||||
reasons.append(
|
||||
f"active PR lease owned by session_id={owner}; current session "
|
||||
f"cannot {mutation}"
|
||||
)
|
||||
pinned = _normalize_sha(pinned_head_sha)
|
||||
live = _normalize_sha(live_head_sha)
|
||||
lease_head = active.get("candidate_head")
|
||||
if pinned and live and pinned != live:
|
||||
reasons.append(
|
||||
"PR head changed during lease; stop and re-validate before "
|
||||
f"reviewer {mutation}"
|
||||
)
|
||||
if lease_head and live and lease_head != live:
|
||||
reasons.append(
|
||||
"live PR head differs from lease candidate_head; refresh lease "
|
||||
f"before {mutation}"
|
||||
)
|
||||
freshness = active.get("freshness") or classify_lease_freshness(active, now=now)
|
||||
if freshness in {"expired", "reclaimable"}:
|
||||
reasons.append(f"reviewer lease freshness is '{freshness}' (fail closed)")
|
||||
else:
|
||||
reasons.append(f"no active reviewer lease found on PR #{pr_number}")
|
||||
|
||||
allowed = not reasons
|
||||
return {
|
||||
"mutation_allowed": allowed,
|
||||
"block": not allowed,
|
||||
"reasons": reasons,
|
||||
"active_lease": active,
|
||||
"session_lease": session,
|
||||
}
|
||||
|
||||
|
||||
def assess_lease_inventory(
|
||||
comments_by_pr: dict[int, list[dict]],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize lease states across PR comment threads."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
active: list[dict] = []
|
||||
stale: list[dict] = []
|
||||
reclaimable: list[dict] = []
|
||||
for pr_number, comments in (comments_by_pr or {}).items():
|
||||
lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
if not lease:
|
||||
continue
|
||||
freshness = lease.get("freshness") or classify_lease_freshness(lease, now=now)
|
||||
entry = {"pr_number": pr_number, "session_id": lease.get("session_id"), "freshness": freshness}
|
||||
if freshness == "stale_warning":
|
||||
stale.append(entry)
|
||||
elif freshness == "reclaimable":
|
||||
reclaimable.append(entry)
|
||||
else:
|
||||
active.append(entry)
|
||||
return {
|
||||
"active_review_leases": active,
|
||||
"stale_review_leases": stale,
|
||||
"reclaimable_review_leases": reclaimable,
|
||||
}
|
||||
|
||||
# Canonical next-action vocabulary for reviewer lease handoff (#599).
|
||||
NEXT_ACTION_ACQUIRE = "acquire"
|
||||
NEXT_ACTION_WAIT = "wait"
|
||||
NEXT_ACTION_RESUME_EXACT_OWNER_SESSION = "resume_exact_owner_session"
|
||||
NEXT_ACTION_RELEASE_EXPIRED_LEASE = "release_expired_lease"
|
||||
NEXT_ACTION_OPERATOR_AUTHORIZED_CLEANUP = "operator_authorized_cleanup"
|
||||
NEXT_ACTION_REPAIR_WORKTREE_BINDING = "repair_worktree_binding"
|
||||
|
||||
_HANDOFF_CLASSIFICATIONS = frozenset({
|
||||
"no_lease",
|
||||
"own_active",
|
||||
"own_expired",
|
||||
"foreign_active",
|
||||
"foreign_reclaimable",
|
||||
"foreign_expired",
|
||||
"instructed_lease_missing_with_replacement",
|
||||
"worktree_binding_mismatch",
|
||||
})
|
||||
|
||||
|
||||
def _norm_path(value: str | None) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return os.path.normpath(text.rstrip("/"))
|
||||
|
||||
|
||||
def diagnose_reviewer_pr_lease_handoff(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
current_session_id: str | None,
|
||||
current_reviewer_identity: str | None,
|
||||
proposed_worktree: str | None = None,
|
||||
env_bound_worktree: str | None = None,
|
||||
instructed_session_id: str | None = None,
|
||||
instructed_comment_id: int | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify open-PR reviewer lease handoff and emit a canonical next action (#599).
|
||||
|
||||
Read-only diagnosis. Never steals, releases, or adopts a foreign lease.
|
||||
Fail-closed acquisition rules for active foreign leases remain intact.
|
||||
|
||||
Returns a structured diagnosis with:
|
||||
- classification
|
||||
- next_action (one of wait / resume_exact_owner_session /
|
||||
release_expired_lease / operator_authorized_cleanup /
|
||||
repair_worktree_binding / acquire)
|
||||
- active_lease identity fields when present
|
||||
- worktree_binding match result
|
||||
- instructed-lease mismatch flags
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
session_id = (current_session_id or "").strip()
|
||||
identity = (current_reviewer_identity or "").strip()
|
||||
instructed_sid = (instructed_session_id or "").strip()
|
||||
reasons: list[str] = []
|
||||
|
||||
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
session_lease = get_session_lease()
|
||||
|
||||
# Worktree binding: env-bound vs proposed vs active lease worktree.
|
||||
env_wt = _norm_path(env_bound_worktree)
|
||||
prop_wt = _norm_path(proposed_worktree)
|
||||
lease_wt = _norm_path((active or {}).get("worktree") if active else None)
|
||||
binding_mismatch = False
|
||||
binding_details: dict[str, Any] = {
|
||||
"env_bound_worktree": env_bound_worktree or None,
|
||||
"proposed_worktree": proposed_worktree or None,
|
||||
"lease_worktree": (active or {}).get("worktree") if active else None,
|
||||
"match": True,
|
||||
}
|
||||
paths = [p for p in (env_wt, prop_wt, lease_wt) if p]
|
||||
if len(paths) >= 2 and len(set(paths)) > 1:
|
||||
binding_mismatch = True
|
||||
binding_details["match"] = False
|
||||
reasons.append(
|
||||
"worktree binding mismatch: env/proposed/lease worktree paths disagree"
|
||||
)
|
||||
|
||||
# Instructed lease gone while a different lease is active (PR #592-style).
|
||||
instructed_missing_with_replacement = False
|
||||
if instructed_sid or instructed_comment_id is not None:
|
||||
if not active:
|
||||
reasons.append(
|
||||
"instructed lease is gone and no active replacement lease remains"
|
||||
)
|
||||
else:
|
||||
owner = (active.get("session_id") or "").strip()
|
||||
cid = active.get("comment_id")
|
||||
sid_mismatch = bool(instructed_sid and owner and owner != instructed_sid)
|
||||
cid_mismatch = (
|
||||
instructed_comment_id is not None
|
||||
and cid is not None
|
||||
and int(cid) != int(instructed_comment_id)
|
||||
)
|
||||
if sid_mismatch or cid_mismatch:
|
||||
instructed_missing_with_replacement = True
|
||||
reasons.append(
|
||||
"instructed lease is gone; a different active lease replaced it "
|
||||
f"(active session_id={owner}, comment_id={cid})"
|
||||
)
|
||||
|
||||
# Classification + next_action.
|
||||
classification = "no_lease"
|
||||
next_action = NEXT_ACTION_ACQUIRE
|
||||
|
||||
if active:
|
||||
owner = (active.get("session_id") or "").strip()
|
||||
freshness = active.get("freshness") or classify_lease_freshness(
|
||||
active, now=now
|
||||
)
|
||||
owner_identity = (active.get("reviewer_identity") or "").strip()
|
||||
is_own = bool(session_id and owner and owner == session_id)
|
||||
# Same identity alone is NOT ownership for resume; session_id must match.
|
||||
same_identity = bool(
|
||||
identity and owner_identity and identity == owner_identity
|
||||
)
|
||||
|
||||
if is_own and freshness in {"active", "stale_warning"}:
|
||||
classification = "own_active"
|
||||
next_action = NEXT_ACTION_RESUME_EXACT_OWNER_SESSION
|
||||
elif is_own and freshness in {"reclaimable", "expired"}:
|
||||
classification = "own_expired"
|
||||
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||
reasons.append(
|
||||
f"own lease freshness is '{freshness}'; release via "
|
||||
"gitea_release_reviewer_pr_lease then re-acquire"
|
||||
)
|
||||
elif not is_own and freshness in {"active", "stale_warning"}:
|
||||
classification = "foreign_active"
|
||||
next_action = NEXT_ACTION_WAIT
|
||||
reasons.append(
|
||||
f"foreign active reviewer lease (session_id={owner}, "
|
||||
f"phase={active.get('phase')}, freshness={freshness}); "
|
||||
"do not submit; do not steal"
|
||||
)
|
||||
if same_identity:
|
||||
reasons.append(
|
||||
"lease identity matches current reviewer but session_id differs; "
|
||||
"resume only from the exact owner session_id or wait"
|
||||
)
|
||||
elif not is_own and freshness == "reclaimable":
|
||||
classification = "foreign_reclaimable"
|
||||
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||
reasons.append(
|
||||
f"foreign reclaimable lease (session_id={owner}); clear only via "
|
||||
"sanctioned gitea_release_reviewer_pr_lease when reclaimable"
|
||||
)
|
||||
elif not is_own and freshness == "expired":
|
||||
classification = "foreign_expired"
|
||||
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||
reasons.append(
|
||||
f"foreign expired lease (session_id={owner}); use sanctioned release"
|
||||
)
|
||||
else:
|
||||
classification = "foreign_active"
|
||||
next_action = NEXT_ACTION_WAIT
|
||||
reasons.append(
|
||||
f"unclassified active lease state (session_id={owner}, "
|
||||
f"freshness={freshness}); wait fail-closed"
|
||||
)
|
||||
|
||||
if instructed_missing_with_replacement and classification.startswith(
|
||||
"foreign"
|
||||
):
|
||||
classification = "instructed_lease_missing_with_replacement"
|
||||
# Foreign active still means wait; reclaimable still release.
|
||||
if next_action == NEXT_ACTION_WAIT:
|
||||
reasons.append(
|
||||
"replacement foreign lease is active — wait; "
|
||||
"operator_authorized_cleanup only with explicit operator authority"
|
||||
)
|
||||
else:
|
||||
classification = "no_lease"
|
||||
next_action = NEXT_ACTION_ACQUIRE
|
||||
reasons.append("no active reviewer lease; acquire via gitea_acquire_reviewer_pr_lease")
|
||||
|
||||
# Binding mismatch is a first-class blocker before submit, but does not
|
||||
# erase foreign-lease wait/release guidance. Override next_action only when
|
||||
# the session would otherwise be free to acquire or resume (submit path).
|
||||
if binding_mismatch:
|
||||
if next_action in {
|
||||
NEXT_ACTION_ACQUIRE,
|
||||
NEXT_ACTION_RESUME_EXACT_OWNER_SESSION,
|
||||
}:
|
||||
classification = "worktree_binding_mismatch"
|
||||
next_action = NEXT_ACTION_REPAIR_WORKTREE_BINDING
|
||||
else:
|
||||
reasons.append(
|
||||
"also repair worktree binding before submit "
|
||||
f"(next_action remains {next_action})"
|
||||
)
|
||||
|
||||
lease_summary = None
|
||||
if active:
|
||||
lease_summary = {
|
||||
"comment_id": active.get("comment_id"),
|
||||
"session_id": active.get("session_id"),
|
||||
"phase": active.get("phase"),
|
||||
"candidate_head": active.get("candidate_head"),
|
||||
"expires_at": active.get("expires_at"),
|
||||
"last_activity": active.get("last_activity"),
|
||||
"freshness": active.get("freshness")
|
||||
or classify_lease_freshness(active, now=now),
|
||||
"reviewer_identity": active.get("reviewer_identity"),
|
||||
"profile": active.get("profile"),
|
||||
"worktree": active.get("worktree"),
|
||||
"blocker": active.get("blocker"),
|
||||
}
|
||||
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"classification": classification,
|
||||
"next_action": next_action,
|
||||
"active_lease": lease_summary,
|
||||
"session_lease": session_lease,
|
||||
"worktree_binding": binding_details,
|
||||
"instructed_session_id": instructed_session_id,
|
||||
"instructed_comment_id": instructed_comment_id,
|
||||
"instructed_lease_missing_with_replacement": instructed_missing_with_replacement,
|
||||
"mutation_allowed": (
|
||||
next_action == NEXT_ACTION_RESUME_EXACT_OWNER_SESSION
|
||||
and not binding_mismatch
|
||||
and bool(session_lease)
|
||||
),
|
||||
"reasons": reasons,
|
||||
"forbidden": [
|
||||
"manual lock deletion",
|
||||
"raw API bypass",
|
||||
"mtime manipulation",
|
||||
"direct _SESSION_LEASE seeding",
|
||||
"silent foreign lease steal",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Explicit worktree and cwd proof for PR review validation (#398)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_PWD_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:pwd|working\s+directory|cwd)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_HEAD_RE = re.compile(
|
||||
r"(?:git\s+rev-parse\s+head|observed\s+head\s+sha)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_EXPECTED_HEAD_RE = re.compile(
|
||||
r"(?:expected\s+(?:pr\s+)?head\s+sha|candidate\s+head\s+sha|pinned\s+head)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_STATUS_RE = re.compile(
|
||||
r"git\s+status\s+(?:--short\s+--branch|--short|-sb)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VALIDATION_CMD_RE = re.compile(
|
||||
r"validation\s+command\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GIT_C_CMD_RE = re.compile(r"git\s+-C\s+\S+", re.IGNORECASE)
|
||||
_CD_CMD_RE = re.compile(r"(?:^|&&\s*)cd\s+\S+", re.IGNORECASE)
|
||||
_BASELINE_CWD_RE = re.compile(
|
||||
r"baseline\s+(?:worktree|working\s+directory|cwd)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BASELINE_SHA_RE = re.compile(
|
||||
r"baseline\s+(?:target\s+)?sha\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BASELINE_CMD_RE = re.compile(
|
||||
r"baseline\s+validation\s+command\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def _path_under_branches(path: str, project_root: str | None = None) -> bool:
|
||||
normalized = _normalize_path(path)
|
||||
if not normalized:
|
||||
return False
|
||||
if "/branches/" in f"{normalized}/":
|
||||
return True
|
||||
if normalized.endswith("/branches"):
|
||||
return True
|
||||
if project_root:
|
||||
root = _normalize_path(project_root)
|
||||
if normalized.startswith(f"{root}/"):
|
||||
rel = normalized[len(root) + 1 :]
|
||||
return rel == "branches" or rel.startswith("branches/")
|
||||
return False
|
||||
|
||||
|
||||
def _expand_sha(sha: str) -> str:
|
||||
return (sha or "").strip().lower()
|
||||
|
||||
|
||||
def _sha_matches(expected: str, observed: str) -> bool:
|
||||
exp = _expand_sha(expected)
|
||||
obs = _expand_sha(observed)
|
||||
if not exp or not obs:
|
||||
return False
|
||||
if len(exp) == 40 and len(obs) == 40:
|
||||
return exp == obs
|
||||
return obs.startswith(exp) or exp.startswith(obs)
|
||||
|
||||
|
||||
def _command_has_explicit_cwd(command: str, cwd: str) -> bool:
|
||||
text = (command or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if _GIT_C_CMD_RE.search(text):
|
||||
return True
|
||||
if _CD_CMD_RE.search(text):
|
||||
return True
|
||||
if cwd and cwd in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assess_validation_cwd_proof_report(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
project_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Require cwd/HEAD proof before reviewer validation claims (#398)."""
|
||||
text = report_text or ""
|
||||
session = dict(validation_session or {})
|
||||
reasons: list[str] = []
|
||||
violations: list[str] = []
|
||||
|
||||
claims_validation = bool(
|
||||
session.get("validation_ran")
|
||||
or _VALIDATION_CMD_RE.search(text)
|
||||
or session.get("command")
|
||||
)
|
||||
if not claims_validation:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"claims_validation": False,
|
||||
"reasons": [],
|
||||
"violations": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
expected_head = (
|
||||
session.get("expected_head_sha")
|
||||
or session.get("candidate_head_sha")
|
||||
or ""
|
||||
).strip()
|
||||
if not expected_head:
|
||||
match = _EXPECTED_HEAD_RE.search(text)
|
||||
expected_head = (match.group(1) if match else "").strip()
|
||||
|
||||
observed_head = (session.get("observed_head_sha") or "").strip()
|
||||
if not observed_head:
|
||||
match = _HEAD_RE.search(text)
|
||||
observed_head = (match.group(1) if match else "").strip()
|
||||
|
||||
cwd = (
|
||||
session.get("working_directory")
|
||||
or session.get("cwd")
|
||||
or session.get("pwd")
|
||||
or ""
|
||||
).strip()
|
||||
if not cwd:
|
||||
match = _PWD_RE.search(text)
|
||||
cwd = (match.group(1) if match else "").strip().rstrip(",.;")
|
||||
|
||||
command = (session.get("command") or "").strip()
|
||||
if not command:
|
||||
match = _VALIDATION_CMD_RE.search(text)
|
||||
command = (match.group(1) if match else "").strip().rstrip(".;")
|
||||
|
||||
if not cwd:
|
||||
reasons.append(
|
||||
"validation claimed without pwd/working-directory proof (#398)"
|
||||
)
|
||||
elif not _path_under_branches(cwd, project_root):
|
||||
violations.append(
|
||||
f"validation cwd {cwd!r} is not under branches/ (#398)"
|
||||
)
|
||||
reasons.append(
|
||||
"reviewer validation must run from a branches/ worktree, "
|
||||
"not the main checkout (#398)"
|
||||
)
|
||||
|
||||
if not observed_head:
|
||||
reasons.append(
|
||||
"validation claimed without git rev-parse HEAD / observed HEAD SHA "
|
||||
"proof (#398)"
|
||||
)
|
||||
elif expected_head and not _sha_matches(expected_head, observed_head):
|
||||
violations.append(
|
||||
f"observed HEAD {observed_head} does not match expected "
|
||||
f"PR head {expected_head} (#398)"
|
||||
)
|
||||
reasons.append("validation HEAD SHA must match pinned PR head (#398)")
|
||||
|
||||
if not _STATUS_RE.search(text) and session.get("git_status") is None:
|
||||
reasons.append(
|
||||
"validation claimed without git status --short --branch proof (#398)"
|
||||
)
|
||||
|
||||
if command and cwd and not _command_has_explicit_cwd(command, cwd):
|
||||
if session.get("tool_working_directory") is not True:
|
||||
reasons.append(
|
||||
"validation command must use git -C <worktree>, "
|
||||
"cd <worktree> && ..., or tool-provided cwd metadata (#398)"
|
||||
)
|
||||
|
||||
baseline_ran = bool(
|
||||
session.get("baseline_validation_ran")
|
||||
or _BASELINE_CMD_RE.search(text)
|
||||
)
|
||||
if baseline_ran:
|
||||
baseline_cwd = (session.get("baseline_worktree_path") or "").strip()
|
||||
if not baseline_cwd:
|
||||
match = _BASELINE_CWD_RE.search(text)
|
||||
baseline_cwd = (match.group(1) if match else "").strip().rstrip(",.;")
|
||||
if not baseline_cwd or not _path_under_branches(baseline_cwd, project_root):
|
||||
reasons.append(
|
||||
"baseline validation claimed without baseline worktree cwd "
|
||||
"under branches/ (#398)"
|
||||
)
|
||||
baseline_sha = (session.get("baseline_target_sha") or "").strip()
|
||||
if not baseline_sha:
|
||||
match = _BASELINE_SHA_RE.search(text)
|
||||
baseline_sha = (match.group(1) if match else "").strip()
|
||||
if not baseline_sha:
|
||||
reasons.append(
|
||||
"baseline validation claimed without baseline target SHA (#398)"
|
||||
)
|
||||
baseline_cmd = (session.get("baseline_command") or "").strip()
|
||||
if not baseline_cmd:
|
||||
match = _BASELINE_CMD_RE.search(text)
|
||||
baseline_cmd = (match.group(1) if match else "").strip()
|
||||
if not baseline_cmd:
|
||||
reasons.append(
|
||||
"baseline validation claimed without exact baseline command (#398)"
|
||||
)
|
||||
|
||||
proven = not reasons and not violations
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": bool(violations) or not proven,
|
||||
"claims_validation": True,
|
||||
"expected_head_sha": expected_head or None,
|
||||
"observed_head_sha": observed_head or None,
|
||||
"working_directory": cwd or None,
|
||||
"reasons": reasons,
|
||||
"violations": violations,
|
||||
"safe_next_action": (
|
||||
"before validation record pwd, git rev-parse HEAD, git status, "
|
||||
"expected PR head SHA; run commands with git -C or cd in the same line"
|
||||
if not proven
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
+34
-3
@@ -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",
|
||||
@@ -80,6 +83,7 @@ AUTHOR_TASKS = frozenset({
|
||||
})
|
||||
|
||||
RECONCILER_TASKS = frozenset({
|
||||
"cleanup_merged_pr_branch",
|
||||
"reconcile_already_landed_pr",
|
||||
"reconcile_already_landed",
|
||||
"reconcile-landed-pr",
|
||||
@@ -97,7 +101,7 @@ TASK_REQUIRED_ROLE = {
|
||||
"address_pr_change_requests": "author",
|
||||
"delete_branch": "author",
|
||||
"review_pr": "reviewer",
|
||||
"merge_pr": "reviewer",
|
||||
"merge_pr": "merger",
|
||||
"blind_pr_queue_review": "reviewer",
|
||||
"pr_queue_cleanup": "reviewer",
|
||||
"pr-queue-cleanup": "reviewer",
|
||||
@@ -109,9 +113,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 +131,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 +250,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 +437,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"]
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Root checkout guard (#475).
|
||||
|
||||
The project root checkout is the stable control checkout on master/prgs/master.
|
||||
Author/reviewer/merge flows must fail closed when the control checkout is
|
||||
contaminated (wrong branch, detached HEAD, dirty, or HEAD behind/ahead of
|
||||
prgs/master). Isolated ``branches/...`` worktrees remain allowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from author_mutation_worktree import is_path_under_branches
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
REMEDIATION = (
|
||||
"Root checkout is not on master. Preserve state, switch root back to master, "
|
||||
"and use scripts/worktree-review or the sanctioned issue worktree flow."
|
||||
)
|
||||
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
REMOTE_MASTER_REFS = ("prgs/master", "refs/remotes/prgs/master")
|
||||
|
||||
|
||||
def resolve_remote_master_sha(
|
||||
canonical_repo_root: str,
|
||||
*,
|
||||
remote_refs: tuple[str, ...] | None = None,
|
||||
) -> str | None:
|
||||
"""Return the commit SHA for the tracking master ref when available."""
|
||||
root = (canonical_repo_root or "").strip()
|
||||
if not root:
|
||||
return None
|
||||
for ref in remote_refs or REMOTE_MASTER_REFS:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "rev-parse", "--verify", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
sha = (res.stdout or "").strip()
|
||||
if sha:
|
||||
return sha
|
||||
return None
|
||||
|
||||
|
||||
resolve_tracking_master_sha = resolve_remote_master_sha
|
||||
|
||||
|
||||
def assess_root_checkout_guard(
|
||||
*,
|
||||
workspace_path: str,
|
||||
canonical_repo_root: str,
|
||||
current_branch: str | None,
|
||||
head_sha: str | None,
|
||||
porcelain_status: str,
|
||||
remote_master_sha: str | None,
|
||||
resolved_role: str | None = None,
|
||||
actual_role: str | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when the control checkout is not clean master/prgs/master.
|
||||
|
||||
``resolved_role`` is the preflight-resolved *task* role and ``actual_role``
|
||||
is the *active profile* role (#540). The reconciler exemption honours either
|
||||
signal so a ``comment_issue`` preflight (which stamps the task role as
|
||||
``author``) cannot strip a genuine reconciler of its exemption. An actual
|
||||
author profile classifies as ``author`` in both signals, so author blocking
|
||||
on a contaminated control checkout is preserved.
|
||||
|
||||
Merger *strictness* (a merger must not be auto-exempted by working from a
|
||||
``branches/`` worktree) stays keyed on the resolved task role: merge
|
||||
operations resolve their own task role, and widening the merger test with
|
||||
``actual_role`` would wrongly subject a merger operating from its clean
|
||||
workspace under a non-merge task to full control-checkout checks.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
branch = (current_branch or "").strip()
|
||||
dirty_files = parse_dirty_tracked_files(porcelain_status)
|
||||
|
||||
if resolved_role == "reconciler" or actual_role == "reconciler":
|
||||
return _assessment(True, [], root, workspace, branch, head_sha, dirty_files)
|
||||
|
||||
if resolved_role != "merger" and is_path_under_branches(workspace, root):
|
||||
return _assessment(True, [], root, workspace, branch, head_sha, dirty_files)
|
||||
|
||||
if dirty_files:
|
||||
reasons.append(
|
||||
"control checkout has tracked local edits before role work "
|
||||
f"(dirty files: {', '.join(dirty_files)})"
|
||||
)
|
||||
|
||||
if not branch:
|
||||
reasons.append("control checkout is detached HEAD; expected branch 'master'")
|
||||
elif branch not in BASE_BRANCHES:
|
||||
reasons.append(
|
||||
f"control checkout branch '{branch}' is not a stable base branch "
|
||||
f"({'/'.join(sorted(BASE_BRANCHES))})"
|
||||
)
|
||||
|
||||
if remote_master_sha and head_sha and head_sha != remote_master_sha:
|
||||
reasons.append(
|
||||
"control checkout HEAD does not match prgs/master "
|
||||
f"(HEAD {head_sha[:12]}, prgs/master {remote_master_sha[:12]})"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return _assessment(proven, reasons, root, workspace, branch or None, head_sha, dirty_files)
|
||||
|
||||
|
||||
def format_root_checkout_guard_error(assessment: dict) -> str:
|
||||
"""Single RuntimeError message for MCP preflight gates."""
|
||||
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown root checkout violation"])
|
||||
return (
|
||||
f"Root checkout guard (#475): {reasons}. "
|
||||
f"canonical repository root: {root}; workspace: {workspace}. "
|
||||
f"{REMEDIATION}"
|
||||
)
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
canonical_repo_root: str,
|
||||
workspace_path: str,
|
||||
current_branch: str | None,
|
||||
head_sha: str | None,
|
||||
dirty_files: list[str],
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"canonical_repo_root": canonical_repo_root,
|
||||
"workspace_path": workspace_path,
|
||||
"current_branch": current_branch,
|
||||
"head_sha": head_sha,
|
||||
"dirty_files": dirty_files,
|
||||
"remediation": REMEDIATION,
|
||||
}
|
||||
|
||||
|
||||
assess_root_checkout = assess_root_checkout_guard
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHON="$ROOT_DIR/venv/bin/python"
|
||||
|
||||
if [[ ! -x "$PYTHON" ]]; then
|
||||
echo "ERROR: expected virtualenv Python at $PYTHON" >&2
|
||||
echo "Create the venv first, then run: venv/bin/python -m pytest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$PYTHON" -m pytest "$@"
|
||||
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" "$@"
|
||||
+11
-5
@@ -38,13 +38,21 @@ fi
|
||||
branch="$1"
|
||||
start_ref="${2:-prgs/master}"
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
|
||||
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
|
||||
if [[ "$allow_unlinked" -eq 0 ]]; then
|
||||
if [[ ! -f "/tmp/gitea_issue_lock.json" ]]; then
|
||||
echo "Error: Issue lock file '/tmp/gitea_issue_lock.json' is missing. You must lock exactly one issue before branch creation (fail closed)." >&2
|
||||
locked_branch=$(python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, '$repo_root')
|
||||
import issue_lock_store
|
||||
print(issue_lock_store.resolve_locked_branch_for_session('$branch'))
|
||||
")
|
||||
if [[ -z "$locked_branch" ]]; then
|
||||
echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2
|
||||
exit 2
|
||||
fi
|
||||
locked_branch=$(python3 -c "import json; print(json.load(open('/tmp/gitea_issue_lock.json')).get('branch_name', ''))")
|
||||
if [[ "$branch" != "$locked_branch" ]]; then
|
||||
echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2
|
||||
exit 2
|
||||
@@ -68,8 +76,6 @@ EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
worktree_name="${branch//\//-}"
|
||||
worktree_path="$repo_root/branches/$worktree_name"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,12 +33,29 @@ workflow file.
|
||||
- A nearby capability does not count.
|
||||
- Do not self-review or self-merge.
|
||||
- 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
|
||||
- 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,
|
||||
@@ -167,6 +186,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
|
||||
|
||||
@@ -182,4 +202,28 @@ Ready-to-copy task prompts live in [`templates/`](templates/):
|
||||
|
||||
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`.
|
||||
`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
|
||||
gates with raw API, direct imports, or root checkout edits.
|
||||
|
||||
If and only if a controller posts a durable `BOOTSTRAP REVIEW AUTHORIZATION
|
||||
(#557)` record, follow:
|
||||
|
||||
`docs/bootstrap-review-path.md`
|
||||
|
||||
Otherwise stop with BLOCKED + DIAGNOSE. Bootstrap never weakens normal PR gates.
|
||||
|
||||
@@ -63,8 +63,14 @@ Do not use legacy fields: `Pinned reviewed head`, `Scratch worktree used`,
|
||||
- Current status:
|
||||
- Safe next action:
|
||||
- Safety statement:
|
||||
- Workflow-load helper result:
|
||||
```
|
||||
|
||||
The **Workflow-load helper result** field must carry structured output from
|
||||
`gitea_load_review_workflow` (workflow_hash, final_report_schema_hash,
|
||||
boundary_status). Narrative claims that workflow files were viewed locally are
|
||||
not sufficient (#403).
|
||||
|
||||
### Already-landed handoff overrides
|
||||
|
||||
When eligibility class is `ALREADY_LANDED_RECONCILE_REQUIRED`:
|
||||
|
||||
@@ -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`
|
||||
@@ -0,0 +1,68 @@
|
||||
# Controller issue-acceptance prompt
|
||||
|
||||
Use after a PR merges when auditing whether the linked issue is truly complete.
|
||||
|
||||
```text
|
||||
Audit issue #<N> against its acceptance criteria after merged PR #<PR>.
|
||||
Post a Controller Issue Acceptance comment with checked criteria, validation
|
||||
reviewed, controller decision, next actor, and paste-ready next prompt.
|
||||
Do not mark the issue accepted unless every required criterion is satisfied.
|
||||
```
|
||||
|
||||
## Comment template
|
||||
|
||||
```text
|
||||
## Controller Issue Acceptance
|
||||
|
||||
STATE:
|
||||
<accepted | more-work-required | needs-tests | needs-docs | needs-feature-enhancement | needs-follow-up-issue | blocked>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<author | reviewer | merger | reconciler | controller | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<one sentence>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next LLM>
|
||||
|
||||
ISSUE:
|
||||
#...
|
||||
|
||||
MERGED_PR:
|
||||
#...
|
||||
|
||||
MERGE_COMMIT:
|
||||
<40-character SHA>
|
||||
|
||||
ACCEPTANCE_CRITERIA_CHECKED:
|
||||
- [x] ...
|
||||
- [ ] ...
|
||||
|
||||
VALIDATION_REVIEWED:
|
||||
<tests/proofs reviewed>
|
||||
|
||||
CONTROLLER_DECISION:
|
||||
<accepted or rejected>
|
||||
|
||||
WHY:
|
||||
<reasoning>
|
||||
|
||||
MISSING_WORK:
|
||||
<none, or exact missing work>
|
||||
|
||||
FOLLOW_UP_ISSUES:
|
||||
<none, or issue list to create>
|
||||
|
||||
BLOCKERS:
|
||||
<none, or exact blockers>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
|
||||
## Rejection paths
|
||||
|
||||
When rejecting completion, `STATE` must name the gap (`needs-tests`,
|
||||
`needs-docs`, `more-work-required`, etc.), `MISSING_WORK` must be explicit,
|
||||
and `NEXT_PROMPT` must be ready for the next author session.
|
||||
@@ -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,8 +10,17 @@ Load the canonical workflow first:
|
||||
Final report schema: `schemas/review-merge-final-report.md`.
|
||||
|
||||
Rules (llm-project-workflow):
|
||||
- Only an eligible, NON-author reviewer merges. If authenticated user == PR
|
||||
- **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 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.
|
||||
@@ -41,7 +50,15 @@ Steps:
|
||||
8. 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.*
|
||||
|
||||
Then run the cleanup template (worktree-cleanup.md):
|
||||
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
|
||||
- Record merge mutations separately from cleanup mutations in the controller handoff.
|
||||
- Hand cleanup to a `prgs-reconciler` session — never raw `git branch -d`,
|
||||
`git push --delete`, curl/API comment deletion, or local scripts.
|
||||
- Reconciler cleanup must cite authorized MCP tools
|
||||
(`gitea_reconcile_merged_cleanups`, `gitea_cleanup_post_merge_moot_lease`,
|
||||
`gitea_delete_branch`, etc.) plus `gitea.branch.delete` capability proof.
|
||||
|
||||
Then run the cleanup template (worktree-cleanup.md) in a reconciler session:
|
||||
- Verify expected file/commit presence on master (post-merge file-presence verification):
|
||||
- Run: git fetch <remote> --prune; git checkout master; git pull <remote> master --ff-only
|
||||
- Verify that the expected files added/modified in the PR are present on master (or absent if deleted).
|
||||
@@ -51,10 +68,12 @@ Then run the cleanup template (worktree-cleanup.md):
|
||||
- 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.
|
||||
```
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Template: post-merge cleanup (reconciler only)
|
||||
|
||||
Copy, fill the `<...>` fields, and paste as the task prompt. Run only after merge
|
||||
is confirmed on remote master. Merger sessions must hand off here — never perform
|
||||
this cleanup inline (#517).
|
||||
|
||||
```text
|
||||
Task: MCP-native post-merge cleanup for PR #<pr> / issue #<n>.
|
||||
|
||||
Rules:
|
||||
- Active profile must be reconciler (`prgs-reconciler`) with `gitea.branch.delete`.
|
||||
- Use MCP tools only — no raw git branch delete, no API comment deletion scripts.
|
||||
- Record cleanup mutations separately from merge mutations in the handoff.
|
||||
|
||||
Steps:
|
||||
1. `gitea_resolve_task_capability(task="reconcile_merged_cleanups", remote=prgs)`
|
||||
2. Confirm PR #<pr> merged on <remote>/master.
|
||||
3. `gitea_cleanup_post_merge_moot_lease` if a reviewer lease remains (append-only).
|
||||
4. `gitea_reconcile_merged_cleanups` for branch/worktree cleanup with dry-run first.
|
||||
5. Report authorized cleanup tools used and reconciler capability proof.
|
||||
|
||||
Handoff ledger (required fields):
|
||||
- Merge mutations: (none — merger already recorded gitea_merge_pr)
|
||||
- Cleanup mutations: list exact MCP tools invoked
|
||||
- Reconciler capability: profile + gitea.branch.delete proof
|
||||
- Next actor: controller acceptance or none
|
||||
```
|
||||
@@ -16,7 +16,7 @@ Rules:
|
||||
reviewing a second PR after a terminal mutation in this run.
|
||||
- After REQUEST_CHANGES: stop. After APPROVED: merge only this PR and only if
|
||||
operator explicitly authorized merge for this PR in this run.
|
||||
- Report Next suggested PR without continuing to it.
|
||||
- Report Next suggested PR without continuing to it. If the PR queue is empty, look at approvals, or issues next.
|
||||
|
||||
Operator PR list (optional): <pr numbers or "oldest eligible from inventory">
|
||||
Merge authorized for selected PR in this run: <true|false>
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
@@ -28,13 +28,22 @@ Repo name disambiguation (Gitea-Tools blind review hardening):
|
||||
`pr_inventory_trust_gate.status`, trust-gate reasons, corroboration,
|
||||
remote/owner/repo/state filter, and the inventory MCP profile. A recent merge
|
||||
commit is not valid corroboration. Author-bound sessions must not present
|
||||
reviewer queue inventory as a reviewer decision.
|
||||
reviewer queue inventory as a reviewer decision. If the PR queue is empty,
|
||||
look at approvals, or issues next.
|
||||
|
||||
Load the canonical workflow first:
|
||||
`skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr).
|
||||
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
|
||||
and is blocked when it disagrees with the local git remote URL.
|
||||
- Review in a SEPARATE detached review worktree, never the author's folder.
|
||||
- Worktree safety (#233): before checkout, diff, validation, review, or merge,
|
||||
report the starting worktree path and whether it was dirty. If unrelated
|
||||
@@ -105,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:
|
||||
@@ -122,4 +132,16 @@ including the review/merge role fields: Selected PR, Reviewer eligibility,
|
||||
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
|
||||
documented known-failure record predating the PR. Reproducing on current
|
||||
(post-merge) master is "current-master failure reproduced", NOT baseline proof.
|
||||
State: base commit, tested commit, command, exit status, failure signature.
|
||||
Use one label: clean pass / current-master failure reproduced / pre-merge
|
||||
baseline-proven failure / unresolved regression risk
|
||||
(final_report_validator: reviewer.premerge_baseline_proof).
|
||||
```
|
||||
|
||||
@@ -10,11 +10,19 @@ 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
|
||||
is orchestration/status only.
|
||||
- Do not self-review or self-merge.
|
||||
- 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.
|
||||
|
||||
Steps:
|
||||
0. Work Selection Rule — before any claim, branch, or file edits, acquire or
|
||||
|
||||
@@ -26,3 +26,43 @@ Steps:
|
||||
|
||||
Handoff: merge confirmed, issue closed, branch+worktree removed, checkout clean.
|
||||
```
|
||||
|
||||
## Branches cleanup audit integrity (#404)
|
||||
|
||||
Any bulk or multi-path cleanup under `branches/` must capture auditable before/after
|
||||
identity for every initial directory and registered worktree. Use
|
||||
`worktree_cleanup_audit.capture_cleanup_snapshot` before and after cleanup, record
|
||||
every intentional removal in a removal log (path, method, order, timestamp,
|
||||
pre-removal proof), then run `reconcile_cleanup_audit` and
|
||||
`assess_cleanup_audit_integrity`.
|
||||
|
||||
The cleanup report must include a reconciliation table:
|
||||
|
||||
* initial count
|
||||
* removed count
|
||||
* preserved count
|
||||
* missing-unexplained count
|
||||
* final count
|
||||
|
||||
Fail closed when:
|
||||
|
||||
* a preserved (active PR, dirty, claim/lease, or unsafe) worktree disappears without
|
||||
a removal log entry or explicit explanation
|
||||
* the removal log omits a removed clean-stale path
|
||||
* final counts do not reconcile with initial minus removed
|
||||
|
||||
If another session removes or mutates a worktree during cleanup, record the path
|
||||
under explained missing entries — never treat silent disappearance as success.
|
||||
|
||||
## Bulk `branches/` cleanup audit (#404)
|
||||
|
||||
Before removing multiple session-owned worktrees:
|
||||
|
||||
1. Call `gitea_capture_branches_worktree_snapshot` and record the before snapshot.
|
||||
2. Remove only paths classified as `clean_stale_removable` with explicit per-path proof.
|
||||
3. Log every removal with path, method, and timestamp/order.
|
||||
4. Capture an after snapshot with the same tool.
|
||||
5. Call `gitea_assess_worktree_cleanup_integrity` with before, after, and the removal log.
|
||||
6. Fail closed when any protected path (active PR, dirty, claim/lease) disappears
|
||||
without an explained state transition.
|
||||
7. Final report must include the reconciliation table and `git worktree list` proof.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -55,6 +55,10 @@ claim. Select exactly one PR according to project queue ordering rules
|
||||
PRs only with live per-PR proof. No multi-PR validation and no batch report
|
||||
may substitute for per-PR proof.
|
||||
|
||||
If the open PR queue is empty:
|
||||
* First, look at **Approvals** next: check if there are open PRs with pending/completed approvals requiring attention or merge.
|
||||
* Next, look at **Issues** next: check if there are unresolved open issues requiring action/fixes.
|
||||
|
||||
## 4. Terminal mutation chain
|
||||
|
||||
`pr_queue_cleanup.resolve_cleanup_run_state` is the authority:
|
||||
|
||||
@@ -304,6 +304,40 @@ If any required mutation capability is missing:
|
||||
* include safe next action (profile switch, human close, or dedicated reconciler
|
||||
profile)
|
||||
|
||||
## 15A. Audit vs cleanup phase (#419)
|
||||
|
||||
Reconciliation audits are **read-only** unless a separate cleanup phase is
|
||||
explicitly authorized.
|
||||
|
||||
**Audit phase forbids** (``audit_reconciliation_mode.check_audit_mutation_allowed``
|
||||
fails closed):
|
||||
|
||||
* ``gitea_delete_branch``
|
||||
* ``git branch -D``
|
||||
* ``git worktree remove``
|
||||
* pushes
|
||||
* issue/PR mutations
|
||||
* file edits
|
||||
|
||||
Dry-run merged-cleanup reconciliation (``gitea_reconcile_merged_cleanups`` with
|
||||
``dry_run=True``) stays in audit phase. Execution requires:
|
||||
|
||||
1. Operator approval or workflow authorization
|
||||
2. Exact ``delete_branch`` capability proof (``gitea.branch.delete``)
|
||||
3. Proof branch/worktree is safe to remove
|
||||
4. Before/after state snapshot
|
||||
|
||||
Call ``gitea_authorize_reconciliation_cleanup_phase`` before any cleanup
|
||||
mutation. Final reports must not claim ``no mutations`` if cleanup occurred.
|
||||
Classify cleanup mutations as:
|
||||
|
||||
* remote branch deletion → **External-state mutations**
|
||||
* local branch deletion → **Git ref mutations**
|
||||
* worktree removal → **Cleanup mutations**
|
||||
|
||||
``audit_reconciliation_mode.assess_audit_reconciliation_report`` validates
|
||||
these boundaries in final reports.
|
||||
|
||||
## 16. Mutation classification
|
||||
|
||||
Use precise mutation categories in the final report:
|
||||
@@ -341,6 +375,24 @@ Include:
|
||||
* confirmation that no normal review, approval, request-changes, or merge was
|
||||
performed
|
||||
|
||||
## 18A. Reconciler close proof is enforced (#306)
|
||||
|
||||
When a reconciler run closes a PR, the final-report validator
|
||||
(`final_report_validator` rule `reconcile.close_proof_fields`) **blocks** the
|
||||
handoff unless it carries all four close proofs. The prompt is guidance; the
|
||||
MCP validator is the authority.
|
||||
|
||||
A close is detected from `PRs closed: #<n>` (or a session close lock). Once a
|
||||
close is reported, the handoff must include:
|
||||
|
||||
* `Capabilities proven:` naming `gitea.pr.close` — the exact close capability
|
||||
* `Ancestor proof:` — the landed/ancestor proof for the closed PR
|
||||
* `PRs closed:` — the PR close result (the closed PR number)
|
||||
* `Linked issue live status:` (or `Issues closed:`) — the linked-issue result
|
||||
|
||||
Comment-only and blocked reconciliations (no PR close) are unaffected: the rule
|
||||
returns no finding when nothing was closed.
|
||||
|
||||
## 19. Local artifact and report consistency rule
|
||||
|
||||
Do not create local walkthrough, notes, markdown, JSON, or report artifacts
|
||||
|
||||
@@ -12,6 +12,8 @@ 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.
|
||||
|
||||
**Default task prompt:**
|
||||
|
||||
> Review the next eligible open PR in this project. Merge it only if every
|
||||
@@ -26,6 +28,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:
|
||||
@@ -36,6 +44,44 @@ If available, load it first and report:
|
||||
|
||||
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
|
||||
|
||||
## 0A. Workflow-load and session boundary anchor (#403)
|
||||
|
||||
The MCP gate is the authority — not local file viewing.
|
||||
|
||||
Before any reviewer mutation:
|
||||
|
||||
1. Record pre-review commands with `gitea_record_pre_review_command` when they
|
||||
are not automatically classified (inventory/diagnostic commands may be
|
||||
recorded explicitly for proof).
|
||||
2. Call `gitea_load_review_workflow` to establish workflow hash proof **and**
|
||||
session boundary state in the same in-process session proof.
|
||||
3. Do not claim the workflow was loaded by reading
|
||||
`skills/llm-project-workflow/workflows/review-merge-pr.md` as a local file;
|
||||
that narrative does not satisfy the validator.
|
||||
|
||||
Allowed before workflow load (classify as `read_only_inventory` or
|
||||
`diagnostic`):
|
||||
|
||||
* `gitea_whoami`, `gitea_resolve_task_capability`, `gitea_list_prs`,
|
||||
`gitea_view_pr`, `gitea_get_runtime_context`
|
||||
* `git fetch` / `git remote update` for inventory
|
||||
* `git status`, `git worktree list` (read-only)
|
||||
|
||||
Boundary violations (block downstream reviewer mutations even after load):
|
||||
|
||||
* validation commands (`pytest`, `python -m unittest`) in the main checkout
|
||||
* local profile/credential/config inspection (`profiles.json`, `gitea-mcp.json`,
|
||||
`.env`, keychain dumps)
|
||||
* MCP repair (`pkill`, MCP config edits)
|
||||
* git mutations before workflow load
|
||||
|
||||
Final reports must include a structured **Workflow-load helper result** block
|
||||
copied from `gitea_load_review_workflow`, including at minimum:
|
||||
|
||||
* `workflow_hash`
|
||||
* `final_report_schema_hash`
|
||||
* `boundary_status` (`clean` or `violation`)
|
||||
|
||||
## 1. Start with live identity, profile, runtime, and capability checks
|
||||
|
||||
Prove:
|
||||
@@ -234,6 +280,10 @@ If queue ordering cannot be proven, stop and produce a recovery handoff.
|
||||
|
||||
## 10. Select the next actionable PR using project rules
|
||||
|
||||
If the open PR queue is empty:
|
||||
* First, look at approvals next to see if there are pending approvals or approved PRs that need attention/merge.
|
||||
* Next, look at issues next to see if there are open issues requiring action/fixes.
|
||||
|
||||
Do not review your own PR.
|
||||
|
||||
Do not review stale, draft, blocked, duplicate, already-owned, dependency-blocked, already-landed, already-requested-changes work unless the rules explicitly allow it.
|
||||
@@ -568,6 +618,28 @@ If the cause is unknown, do not erase the earlier failure with plain
|
||||
`gitea_validate_review_final_report` rejects reports that omit known earlier
|
||||
validation failures when `validation_session.observed_failures` is supplied.
|
||||
|
||||
## 21B. Validation status taxonomy (#406)
|
||||
|
||||
When the final report summarizes how validation concluded, use one of these
|
||||
**validation status** labels (distinct from per-command pass/fail entries):
|
||||
|
||||
* `passed` — raw PR-head validation passed on the unmodified head.
|
||||
* `failed` — raw PR-head validation failed and no allowed resolution path
|
||||
was proven.
|
||||
* `baseline-equivalent failure accepted` — only when a clean baseline
|
||||
worktree under `branches/` proves matching failure signatures on the target
|
||||
branch (baseline path, target SHA, exact commands, failure lists, and
|
||||
`failure signatures match: true`).
|
||||
* `raw-head failure resolved by merge simulation` — raw PR-head validation
|
||||
failed, but merge simulation into the current target passed cleanly; report
|
||||
merge simulation under `Worktree/index mutations` with full #317 proof.
|
||||
* `passed after transient failure investigation` — a later run passed after an
|
||||
earlier failure in the same session; document the failure history (#396).
|
||||
|
||||
Do not use `baseline-equivalent failure accepted` when only merge simulation
|
||||
resolved the failure. Do not use bare `passed` when raw PR-head validation
|
||||
failed unless one of the resolution statuses above applies.
|
||||
|
||||
## 22. Baseline validation rule
|
||||
|
||||
Do not run tests in the main checkout.
|
||||
@@ -604,6 +676,28 @@ Do not claim “full-suite failures are pre-existing” unless baseline proof is
|
||||
|
||||
## 23. Validation command proof rule
|
||||
|
||||
Before any diff, test, or compile validation, record in the same command
|
||||
transcript or final report:
|
||||
|
||||
* `pwd` or explicit working directory
|
||||
* `git rev-parse HEAD`
|
||||
* `git status --short --branch`
|
||||
* expected PR head SHA (candidate head SHA)
|
||||
|
||||
Validation commands must use one of:
|
||||
|
||||
* `git -C <review_worktree> ...`
|
||||
* `cd <review_worktree> && ...` in the same command
|
||||
* tool-provided explicit working-directory metadata
|
||||
|
||||
Do not rely on inferred shell cwd from a prior command in a different block.
|
||||
|
||||
`gitea_validate_review_final_report` rejects validation claims without
|
||||
cwd/HEAD proof when `validation_session` is supplied.
|
||||
|
||||
Baseline validation must document baseline worktree path, baseline target SHA,
|
||||
cwd proof, exact baseline command, and baseline result using the same rules.
|
||||
|
||||
Report the exact validation command as executed.
|
||||
|
||||
Report the working directory where validation ran.
|
||||
@@ -732,6 +826,128 @@ 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,
|
||||
live PR lease.
|
||||
|
||||
Before validation or review mutation on a selected PR:
|
||||
|
||||
1. Call `gitea_acquire_reviewer_pr_lease` with worktree path, candidate head SHA,
|
||||
and target branch SHA.
|
||||
2. Post heartbeats via `gitea_heartbeat_reviewer_pr_lease` before validation,
|
||||
after validation, before review mutation, and before merge.
|
||||
3. Do not approve, request changes, or merge unless the in-session lease
|
||||
matches the selected PR.
|
||||
|
||||
If PR head or target branch advances during the lease, stop and refresh
|
||||
inventory before continuing.
|
||||
|
||||
Final reports must include lease session id, acquisition proof, heartbeat
|
||||
status, and release/blocked status.
|
||||
|
||||
## 26B-1. Merger lease adoption (#536)
|
||||
|
||||
When review and merge are **different sessions**, the merger must adopt the
|
||||
reviewer's lease — never manually seed `reviewer_pr_lease._SESSION_LEASE`.
|
||||
|
||||
Before merge in a merger-only session:
|
||||
|
||||
1. Confirm `approval_at_current_head` via `gitea_get_pr_review_feedback`.
|
||||
2. Call `gitea_adopt_merger_pr_lease` from a clean merger worktree under
|
||||
`branches/`, passing `expected_head_sha` pinned to the approved head.
|
||||
3. Quote the adoption comment id and `adopted_from_session_id` in the handoff.
|
||||
4. Proceed to `gitea_merge_pr` only after adoption succeeds.
|
||||
|
||||
Manual in-process lease seeding is rejected by mutation gates (fail closed).
|
||||
|
||||
## 26C. Conflict-fix lease and stale-head protection (#399)
|
||||
|
||||
Before validating, approving, or merging a PR:
|
||||
|
||||
1. Check for an active conflict-fix lease on the PR; stop if one is active.
|
||||
2. Pin `expected_head_sha` before validation and pass it to
|
||||
`gitea_mark_final_review_decision`, `gitea_submit_pr_review`, and
|
||||
`gitea_merge_pr`.
|
||||
3. Re-fetch live PR head immediately before approval and merge; refuse when
|
||||
live head differs from the reviewed SHA.
|
||||
|
||||
Final reports must state:
|
||||
|
||||
* reviewed head SHA
|
||||
* final live head SHA before approval
|
||||
* final live head SHA before merge
|
||||
* whether any push occurred during validation
|
||||
|
||||
## 27. Merge rules
|
||||
|
||||
Before merge, rerun fresh live checks:
|
||||
@@ -742,6 +958,7 @@ Before merge, rerun fresh live checks:
|
||||
* author safety
|
||||
* PR re-fetch
|
||||
* reviewed head SHA unchanged
|
||||
* visible APPROVED review applies to the **current live PR head SHA** (`approval_at_current_head`); if the head moved after approval, re-review at the new head before merge (#471)
|
||||
* target branch freshly fetched
|
||||
* PR still open
|
||||
* PR still mergeable
|
||||
@@ -758,6 +975,7 @@ Do not merge if:
|
||||
* capability state is stale
|
||||
* worktree is dirty
|
||||
* PR head changed
|
||||
* approval is stale (approved SHA ≠ current live head SHA)
|
||||
* validation failed
|
||||
* inventory was incomplete
|
||||
* PR is already landed
|
||||
@@ -787,6 +1005,24 @@ 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
|
||||
`gitea_audit_worktree_cleanup` (read-only) to classify `branches/` entries; only
|
||||
`clean_stale_removable` and `detached_review_leftover` may be removed, one-by-one,
|
||||
after `git worktree list` proof plus per-worktree proof of path, branch/HEAD,
|
||||
clean/dirty status, no active PR/lease, and the removal result. Dirty,
|
||||
active-PR, active-issue, and leased worktrees are never deleted automatically; a
|
||||
failed removal must be reported with the leftover path and reason.
|
||||
|
||||
Do not delete or mutate unrelated branches/worktrees.
|
||||
|
||||
Do not touch the main checkout except to update the stable branch after merge if explicitly allowed by the workflow.
|
||||
@@ -797,6 +1033,61 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc
|
||||
|
||||
If any local artifact is created after final cleanup, run and report a new final status check.
|
||||
|
||||
## 28A. Post-merge cleanup proof checklist (#402)
|
||||
|
||||
Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed.
|
||||
|
||||
### Remote branch deletion checklist
|
||||
|
||||
When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report:
|
||||
|
||||
* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call
|
||||
* Merge result: merged
|
||||
* Merge commit SHA: full 40-character SHA
|
||||
* Merged PR head branch / deleted branch: exact branch name (must match)
|
||||
* Branch protection: none / branch is not protected
|
||||
* Open PR inventory proof: no other open PR references the branch
|
||||
* Active heartbeat/claim/lease: none
|
||||
|
||||
### Local worktree removal checklist
|
||||
|
||||
When removing session-owned review/simulation worktrees under `branches/`, report:
|
||||
|
||||
* Session-owned worktree path: exact path under `branches/`
|
||||
* Pre-removal tracked state: clean
|
||||
* Pre-removal untracked state: clean
|
||||
* Git worktree list after removal: command output or equivalent proof
|
||||
|
||||
### Skipped cleanup
|
||||
|
||||
If any gate fails, report:
|
||||
|
||||
* Cleanup outcome: `CLEANUP_SKIPPED`
|
||||
* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`)
|
||||
|
||||
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
|
||||
|
||||
## 28B. MCP-native cleanup only (#517)
|
||||
|
||||
Post-merge cleanup of leases, comments, branches, and worktrees must go through
|
||||
explicit MCP tools — never raw git, curl/API scripts, or ad hoc helper scripts.
|
||||
|
||||
Merger and reviewer sessions must **not** perform cleanup inline. Record:
|
||||
|
||||
* **Merge mutations** — only `gitea_merge_pr` (or review mutations for review-only runs)
|
||||
* **Cleanup mutations** — only authorized reconciler MCP tools, cited by exact tool name
|
||||
|
||||
Authorized cleanup tools include `gitea_reconcile_merged_cleanups`,
|
||||
`gitea_cleanup_post_merge_moot_lease`, `gitea_delete_branch`, and
|
||||
`gitea_cleanup_merged_pr_branch` (when available). Lease cleanup uses
|
||||
append-only release comments — never delete another session's lease comment.
|
||||
|
||||
Hand cleanup to a `prgs-reconciler` session with `gitea.branch.delete` capability
|
||||
proof. Raw `git branch -d`, `git push --delete`, and comment-deletion API calls
|
||||
are blocked in final-report validation.
|
||||
|
||||
Template: `templates/post-merge-cleanup-handoff.md`.
|
||||
|
||||
## 29. Recovery handoff rules
|
||||
|
||||
If blocked, produce a recovery handoff with:
|
||||
@@ -821,6 +1112,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:
|
||||
@@ -895,6 +1188,26 @@ Use precise wording:
|
||||
|
||||
Do not collapse review, merge, cleanup, or external-state mutations into vague wording.
|
||||
|
||||
## 31B. Mutation-capability table (#405)
|
||||
|
||||
Every performed mutation requires exact capability proof resolved **before** that
|
||||
mutation executes. Nearby capabilities never authorize a different operation —
|
||||
`review_pr` does not authorize `merge_pr`, and `merge_pr` does not authorize
|
||||
`delete_branch` / `gitea.branch.delete`.
|
||||
|
||||
When any mutation beyond a bare review occurs (merge, branch delete, issue
|
||||
close/comment, etc.), the final report must include a **mutation-capability table**
|
||||
with one row per performed mutation:
|
||||
|
||||
* mutation (tool/action name)
|
||||
* exact task/capability resolved (for example `merge_pr` / `gitea.pr.merge`)
|
||||
* result
|
||||
* order/timestamp proof that capability was resolved before the mutation
|
||||
|
||||
If exact capability proof is missing, skip the mutation or stop the workflow —
|
||||
never claim a performed mutation without its row. Post-hoc capability proof after
|
||||
the mutation fails validation.
|
||||
|
||||
## 31A. Local artifact and report consistency rule
|
||||
|
||||
Do not create local walkthrough, notes, markdown, JSON, or report artifacts during reviewer runs unless the canonical workflow or operator explicitly requires it.
|
||||
@@ -1127,6 +1440,7 @@ Controller Handoff:
|
||||
* Files reviewed:
|
||||
* Validation:
|
||||
* Validation failure history:
|
||||
* Validation cwd/HEAD proof:
|
||||
* Official validation integrity status:
|
||||
* Terminal review mutation:
|
||||
* Review decision:
|
||||
|
||||
@@ -12,6 +12,8 @@ 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.
|
||||
|
||||
**Default task prompt:**
|
||||
|
||||
> Find the next eligible issue in this project, work on it only if all gates
|
||||
@@ -26,6 +28,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:
|
||||
@@ -144,6 +152,14 @@ If the main checkout is dirty before selection, stop and produce a recovery hand
|
||||
|
||||
If the main checkout becomes dirty during the run, stop and produce a recovery handoff unless the change is explicitly allowed by the canonical workflow.
|
||||
|
||||
### Stacked PRs (explicit exception, #484)
|
||||
|
||||
Normal author work stays base-equivalent to `master`/`main`/`dev`. A **stacked PR** — deliberately based on another unmerged PR's branch — is the only sanctioned non-master base, and only when the operator/controller explicitly chooses it:
|
||||
|
||||
- Branch the `branches/` worktree from the dependency's branch, then lock with `gitea_lock_issue(..., stacked_base_branch=<dep-branch>, stacked_base_pr=<open-PR#>)`. The lock fails closed unless that open PR owns the branch; arbitrary or stale branches are rejected.
|
||||
- Open the PR with `gitea_create_pr(base=<dep-branch>)`. The body must state: `Stacked on PR #<X> / issue #<Y>`, `Base branch: <dep-branch>`, `Head branch: <this-branch>`, `Do not merge before PR #<X>`, and note retarget/rebase to `master` after the dependency lands if required.
|
||||
- This does not relax the main-checkout rule or bypass the issue lock — work still happens under `branches/`, and the approved base is recorded on the lock.
|
||||
|
||||
## 5. No raw MCP repair during normal issue work
|
||||
|
||||
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
|
||||
@@ -607,6 +623,63 @@ After push, report:
|
||||
|
||||
If push fails, stop and produce a recovery handoff.
|
||||
|
||||
## 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
|
||||
* branch head after push (local)
|
||||
* session worktree path
|
||||
* push cwd
|
||||
* whether the push is fast-forward
|
||||
* explicit `remote`, `org`, and `repo` when not using defaults
|
||||
3. If assessment returns `assessment_failed: true` or `pr_lookup: failed`, stop
|
||||
and produce a recovery handoff with the structured `reasons` and
|
||||
`resolved_repo` fields — do not treat an MCP HTTP 500 as proof the push was
|
||||
unsafe or safe (#519).
|
||||
4. Do not push when a reviewer holds an active lease on the same PR.
|
||||
5. Do not force-push.
|
||||
6. Do not push from the main checkout or wrong cwd.
|
||||
|
||||
Conflict-fix final reports must state:
|
||||
|
||||
* branch head before push
|
||||
* branch head after push
|
||||
* active reviewer lease status
|
||||
* whether push was fast-forward
|
||||
* whether any reviewer was active
|
||||
|
||||
## 21. PR creation rules
|
||||
|
||||
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
|
||||
@@ -668,6 +741,39 @@ Do not update the main checkout unless the canonical workflow explicitly allows
|
||||
|
||||
Any cleanup is a mutation and must be reported.
|
||||
|
||||
### 22A. Session-owned worktree cleanup and TTL (#401)
|
||||
|
||||
Every session-owned worktree created under `branches/` has ownership metadata:
|
||||
path, workflow type, issue number, PR number, branch/head SHA, creator
|
||||
identity/profile, created timestamp, last-used timestamp, and cleanup
|
||||
eligibility.
|
||||
|
||||
Cleanup is classification-driven. `gitea_audit_worktree_cleanup` (read-only)
|
||||
classifies every `branches/` entry as exactly one of:
|
||||
|
||||
* `active_open_pr` — branch has an open PR; never auto-removed.
|
||||
* `active_issue_work` — active claim/lease or fresh issue worktree; never
|
||||
auto-removed.
|
||||
* `dirty_local_worktree` — uncommitted changes; never auto-removed.
|
||||
* `clean_stale_removable` — clean, no PR, no lease; removable.
|
||||
* `detached_review_leftover` — clean detached review/baseline/merge-simulation
|
||||
worktree; removable.
|
||||
* `unsafe_unknown` — protected base checkout or unknown workflow type; never
|
||||
auto-removed.
|
||||
|
||||
Only `clean_stale_removable` and `detached_review_leftover` may be removed, and
|
||||
only one-by-one after `git worktree list` proof plus per-worktree proof of:
|
||||
worktree path, branch/HEAD, clean/dirty status, no active PR/lease, and the
|
||||
removal result. Review, baseline, and merge-simulation worktrees are removed
|
||||
automatically at successful workflow completion; issue/conflict-fix worktrees
|
||||
are removed only after their TTL (`GITEA_WORKTREE_TTL_HOURS`, default 24h)
|
||||
expires and no lock/lease is held.
|
||||
|
||||
Dirty, active-PR, active-issue, and leased worktrees are never deleted
|
||||
automatically. If a removal fails, the final report must list the leftover
|
||||
worktree path and the reason. Include the `git worktree list` output as final
|
||||
cleanup verification.
|
||||
|
||||
## 23. Recovery handoff rules
|
||||
|
||||
If blocked, produce a recovery handoff with:
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Stacked-PR support for author issue locks and PR creation (#484).
|
||||
|
||||
Normal author work locks a worktree that is base-equivalent to ``master``/
|
||||
``main``/``dev`` and opens a PR against one of those base branches. A *stacked*
|
||||
PR is deliberately based on another unmerged PR's branch, so its worktree is not
|
||||
master-equivalent and its PR base is not a normal base branch.
|
||||
|
||||
This module holds the pure decision logic that lets:
|
||||
|
||||
* ``gitea_lock_issue`` approve a non-master base **only** when it is explicitly
|
||||
declared and proven to correspond to an open pull request, and
|
||||
* ``gitea_create_pr`` accept that approved base while still rejecting arbitrary,
|
||||
mismatched, or stale (merged/closed) branches.
|
||||
|
||||
The normal master-based path is unchanged: when no stacked base is declared, and
|
||||
when the PR base is a normal base branch, these helpers are inert. Nothing here
|
||||
bypasses the issue lock — a stacked base is recorded *on* the lock and re-checked
|
||||
at PR time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
|
||||
# Phrases that satisfy the required merge-ordering statement in a stacked PR body.
|
||||
MERGE_ORDER_PHRASES = ("do not merge before", "do not merge until")
|
||||
|
||||
|
||||
def is_base_branch(base: str | None, base_branches: frozenset[str] | None = None) -> bool:
|
||||
"""True when ``base`` is a normal base branch (master/main/dev)."""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
return (base or "").strip() in bases
|
||||
|
||||
|
||||
def _pr_head_ref(pr: dict) -> str:
|
||||
head = pr.get("head") or {}
|
||||
if isinstance(head, dict):
|
||||
return (head.get("ref") or "").strip()
|
||||
return (str(head) if head else "").strip()
|
||||
|
||||
|
||||
def find_open_pr_for_branch(open_prs: list[dict] | None, branch: str | None) -> dict | None:
|
||||
"""Return the first OPEN PR whose head ref equals ``branch`` (else ``None``)."""
|
||||
branch = (branch or "").strip()
|
||||
if not branch:
|
||||
return None
|
||||
for pr in open_prs or []:
|
||||
if (pr.get("state") or "").strip().lower() != "open":
|
||||
continue
|
||||
if _pr_head_ref(pr) == branch:
|
||||
return pr
|
||||
return None
|
||||
|
||||
|
||||
def assess_stacked_base_declaration(
|
||||
*,
|
||||
stacked_base_branch: str | None,
|
||||
stacked_base_pr: int | None,
|
||||
open_prs: list[dict] | None,
|
||||
) -> dict:
|
||||
"""Validate an explicit stacked-base declaration at lock time.
|
||||
|
||||
Returns a dict with ``block`` (fail closed), ``reasons``, ``declared``
|
||||
(whether a stacked base was requested), and ``approved`` (the metadata to
|
||||
persist on the lock when valid, else ``None``).
|
||||
"""
|
||||
branch = (stacked_base_branch or "").strip()
|
||||
if not branch:
|
||||
# No stacked base requested — normal master-based lock path.
|
||||
return {"block": False, "reasons": [], "approved": None, "declared": False}
|
||||
|
||||
if branch in BASE_BRANCHES:
|
||||
return {
|
||||
"block": True,
|
||||
"declared": True,
|
||||
"approved": None,
|
||||
"reasons": [
|
||||
f"stacked base '{branch}' is already a normal base branch; do not "
|
||||
"declare a base branch as a stacked base"
|
||||
],
|
||||
}
|
||||
|
||||
if stacked_base_pr is None:
|
||||
return {
|
||||
"block": True,
|
||||
"declared": True,
|
||||
"approved": None,
|
||||
"reasons": [
|
||||
"stacked base branch declared without stacked_base_pr; a stacked PR "
|
||||
"must cite the open PR that owns the base branch"
|
||||
],
|
||||
}
|
||||
|
||||
pr = find_open_pr_for_branch(open_prs, branch)
|
||||
if pr is None:
|
||||
return {
|
||||
"block": True,
|
||||
"declared": True,
|
||||
"approved": None,
|
||||
"reasons": [
|
||||
f"stacked base branch '{branch}' does not correspond to any OPEN pull "
|
||||
"request; arbitrary or stale branches are not allowed as stacked bases"
|
||||
],
|
||||
}
|
||||
|
||||
if int(pr.get("number")) != int(stacked_base_pr):
|
||||
return {
|
||||
"block": True,
|
||||
"declared": True,
|
||||
"approved": None,
|
||||
"reasons": [
|
||||
f"declared stacked_base_pr #{stacked_base_pr} does not match the open "
|
||||
f"PR #{pr.get('number')} that owns base branch '{branch}'"
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"block": False,
|
||||
"declared": True,
|
||||
"reasons": [],
|
||||
"approved": {
|
||||
"branch": branch,
|
||||
"pr_number": int(pr.get("number")),
|
||||
"verified_open": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def assess_stacked_pr_body(
|
||||
body: str | None, *, base_branch: str | None, pr_number: int | None
|
||||
) -> list[str]:
|
||||
"""Return the list of missing stacked-PR documentation fields (empty = ok)."""
|
||||
text = body or ""
|
||||
low = text.lower()
|
||||
missing: list[str] = []
|
||||
if base_branch and base_branch not in text:
|
||||
missing.append(f"base branch '{base_branch}'")
|
||||
if pr_number is not None and f"#{pr_number}" not in text:
|
||||
missing.append(f"stacked-on PR reference '#{pr_number}'")
|
||||
if not any(phrase in low for phrase in MERGE_ORDER_PHRASES):
|
||||
missing.append("merge-ordering statement (e.g. 'Do not merge before PR #<n>')")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_create_pr_base(
|
||||
*,
|
||||
base: str | None,
|
||||
approved_stacked_base: dict | None,
|
||||
body: str | None,
|
||||
open_prs: list[dict] | None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Validate the PR base at create time.
|
||||
|
||||
Normal base branches pass through unchanged (``stacked`` False). A non-base
|
||||
branch is allowed only when it matches the lock's approved stacked base, that
|
||||
base still has an open PR, and the body documents the stack.
|
||||
"""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
base = (base or "").strip()
|
||||
if base in bases:
|
||||
return {"block": False, "reasons": [], "stacked": False}
|
||||
|
||||
approved = approved_stacked_base or {}
|
||||
approved_branch = (approved.get("branch") or "").strip()
|
||||
if not approved_branch:
|
||||
return {
|
||||
"block": True,
|
||||
"stacked": True,
|
||||
"reasons": [
|
||||
f"PR base '{base}' is not one of {'/'.join(sorted(bases))} and the "
|
||||
"issue lock has no approved stacked base; re-lock with an explicit, "
|
||||
"proof-backed stacked base to open a stacked PR"
|
||||
],
|
||||
}
|
||||
|
||||
if base != approved_branch:
|
||||
return {
|
||||
"block": True,
|
||||
"stacked": True,
|
||||
"reasons": [
|
||||
f"PR base '{base}' does not match the issue lock's approved stacked "
|
||||
f"base '{approved_branch}'"
|
||||
],
|
||||
}
|
||||
|
||||
pr = find_open_pr_for_branch(open_prs, base)
|
||||
if pr is None:
|
||||
return {
|
||||
"block": True,
|
||||
"stacked": True,
|
||||
"reasons": [
|
||||
f"approved stacked base '{base}' no longer corresponds to an OPEN pull "
|
||||
"request (dependency merged, closed, or stale); retarget/rebase onto "
|
||||
"master or re-lock against a live base"
|
||||
],
|
||||
}
|
||||
|
||||
pr_number = approved.get("pr_number") or pr.get("number")
|
||||
missing = assess_stacked_pr_body(body, base_branch=base, pr_number=pr_number)
|
||||
if missing:
|
||||
return {
|
||||
"block": True,
|
||||
"stacked": True,
|
||||
"reasons": [
|
||||
"stacked PR body must document the stack; missing: "
|
||||
+ ", ".join(missing)
|
||||
],
|
||||
}
|
||||
|
||||
return {"block": False, "reasons": [], "stacked": True, "stacked_base_pr": pr_number}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user