Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00efda0cfb | ||
|
|
ce61e424f6 | ||
|
|
2e4ebc6434 | ||
|
|
f89949cea9 | ||
|
|
b3edd53185 | ||
|
|
9ada4762c5 | ||
|
|
3bce0a55fb | ||
|
|
afb4ba563c | ||
|
|
48cf3a334b | ||
|
|
974463163d | ||
|
|
a8bcbdbcf2 | ||
|
|
2d4ab4e54e | ||
|
|
514eae84f9 | ||
|
|
0cbd1fc801 | ||
|
|
28f0f84de8 | ||
|
|
08202f7eaa | ||
|
|
7af73e1539 | ||
|
|
ae0478bf54 | ||
|
|
cc5686b896 | ||
|
|
d4dc9aa854 | ||
|
|
3496fc3952 | ||
|
|
89582a0f2a | ||
|
|
a1de4ab8f9 | ||
|
|
3c41d2af5c | ||
|
|
d306c0a5ec | ||
|
|
85532059e2 | ||
|
|
54a75153a9 | ||
|
|
5dfc00319f | ||
|
|
f51dc95153 | ||
|
|
55b3658d93 | ||
|
|
c7b462a034 | ||
|
|
e956040b6b | ||
|
|
43a17a7e8e | ||
|
|
d042a9ca24 | ||
|
|
f061fee18d | ||
|
|
3e4b721d60 | ||
|
|
1830360850 | ||
|
|
06c2069234 | ||
|
|
cc4741a4ce | ||
|
|
d95ea323d7 | ||
|
|
e4adccd82a | ||
|
|
89a7d4dbfc | ||
|
|
795f544047 | ||
|
|
b33844d74a | ||
|
|
22a1c4c2df | ||
|
|
ee8e9a0247 | ||
|
|
f5370a94d3 | ||
|
|
c91e3642de | ||
|
|
3599a9e12a | ||
|
|
f845864889 | ||
|
|
6670c72e65 | ||
|
|
1529b9ff6b | ||
|
|
4a95c65f8b | ||
|
|
16dcf65825 | ||
|
|
81fcdb09fd | ||
|
|
a9265efa82 | ||
|
|
30ded9b712 | ||
|
|
a8fcf0e01c | ||
|
|
1320a55a7e | ||
|
|
0bad26230b | ||
|
|
cf057d7829 | ||
|
|
87beb44394 | ||
|
|
db032ef93c | ||
|
|
1a1e679246 | ||
|
|
9fde4f3e76 | ||
|
|
6b5d2ad080 | ||
|
|
f5654963eb | ||
|
|
af7131abf1 | ||
|
|
027a3cb92c | ||
|
|
71ccbca10f | ||
|
|
6220304f8c | ||
|
|
6a37f7c8d5 | ||
|
|
fcb9944378 | ||
|
|
f75f2327f5 | ||
|
|
9f4dd4c39b | ||
|
|
6e212c0de7 | ||
|
|
d446a31442 | ||
|
|
b11160f236 | ||
|
|
d814a9ca81 | ||
|
|
10e64f6683 | ||
|
|
5513bdf2aa | ||
|
|
273eba7fb3 | ||
|
|
cd61272837 | ||
|
|
7fbb1bf34a | ||
|
|
c1d85b621a | ||
|
|
4243b60ca3 | ||
|
|
f5953549aa | ||
|
|
ab6bb593bc | ||
|
|
4cf75bcbc9 | ||
|
|
9ff861a1f3 | ||
|
|
8d5fa06351 | ||
|
|
7db8298cdd | ||
|
|
167257b8a3 | ||
|
|
23380d27fe | ||
|
|
bb525a8f94 | ||
|
|
d5b185ce38 | ||
|
|
b5ee6567aa | ||
|
|
103364aa8d | ||
|
|
a2bd23da14 | ||
|
|
a164700ab9 | ||
|
|
91c67930ec | ||
|
|
0c18d0f54f | ||
|
|
ae566d5cd1 | ||
|
|
d66d465e31 | ||
|
|
cf94112e96 | ||
|
|
5a5bc8846f | ||
|
|
7ef4c53cd8 | ||
|
|
60040e789b | ||
|
|
769f387267 | ||
|
|
202be15764 | ||
|
|
fbfcc08640 | ||
|
|
0bb6cae95a | ||
|
|
180490c640 | ||
|
|
b604b468b5 | ||
|
|
af064dd06b | ||
|
|
6100187274 | ||
|
|
bf002aee2c | ||
|
|
b53e3de7cc | ||
|
|
47b4ee4791 | ||
|
|
1c506fa779 | ||
|
|
e7a1e183a0 | ||
|
|
72f3bc8db8 | ||
|
|
e0a1717db2 | ||
|
|
3c2dd055fa | ||
|
|
56f8f9eeb4 | ||
|
|
b6cf32c845 | ||
|
|
21ff12cef7 | ||
|
|
a17e8561d0 | ||
|
|
e2771dfe9a | ||
|
|
7635a17599 | ||
|
|
f9bf830067 | ||
|
|
85c5d24b0e | ||
|
|
dc51e9a91b | ||
|
|
f749312b45 | ||
|
|
fa922cf8e3 | ||
|
|
208dc40041 | ||
|
|
8b555043e3 | ||
|
|
faf4484657 | ||
|
|
93230457d8 | ||
|
|
b4c79fdbbc | ||
|
|
8b9a0705b6 | ||
|
|
bc8e2b7928 | ||
|
|
9bb9bad4c7 | ||
|
|
ab126da479 | ||
|
|
ba4cf2e93d | ||
|
|
4507457560 | ||
|
|
dc24fe3afe | ||
|
|
3c9841760c | ||
|
|
51d0c14009 | ||
|
|
8a6cb6ac2c | ||
|
|
ce39fb52c9 | ||
|
|
c260ef15fb | ||
|
|
e823000301 | ||
|
|
7b71113b4d | ||
|
|
c2226177b6 | ||
|
|
749e480baf | ||
|
|
b41d9d6006 | ||
|
|
2577489c2a | ||
|
|
f89019f804 | ||
|
|
e26398b7a9 | ||
|
|
9cf1b41b77 | ||
|
|
0fe8f57dda | ||
|
|
6946534f3e | ||
|
|
1487ff60b4 | ||
|
|
d0f52cbf19 | ||
|
|
17c51eaa86 | ||
|
|
2900add223 | ||
|
|
a5fb7d2472 | ||
|
|
c5ab43ed5a | ||
|
|
a237b15559 | ||
|
|
07b23950ab | ||
|
|
bbf5ee6d59 | ||
|
|
5131fc595a | ||
|
|
e017d80256 | ||
|
|
958d470f98 | ||
|
|
af806919b3 | ||
|
|
42cd0f9f4a | ||
|
|
8d32af14d6 | ||
|
|
764e636c07 | ||
|
|
c21e756a30 | ||
|
|
6743cc11b9 | ||
|
|
c20a93602d | ||
|
|
6e774e191a | ||
|
|
852ac1f56b | ||
|
|
012b8b387a | ||
|
|
6779d903f2 | ||
|
|
3764ba0566 | ||
|
|
2c65cf05ca | ||
|
|
275a362a46 | ||
|
|
1d3ee72274 | ||
|
|
be1462dc03 | ||
|
|
37dc3a38b8 | ||
|
|
876f1ee3a3 | ||
|
|
0cc3ed8868 | ||
|
|
15c9add3f4 | ||
|
|
c278e16053 | ||
|
|
77b29fe417 | ||
|
|
a0d68ef66e | ||
|
|
7c7aa1ac2d | ||
|
|
6be8f16351 | ||
|
|
b71d3aeab3 | ||
|
|
ef7c15f84d | ||
|
|
b842a7f923 | ||
|
|
ce3bd784c0 | ||
|
|
5fa5760dd0 | ||
|
|
4ea618e8b4 | ||
|
|
71b8d00897 | ||
|
|
da9c0ba54c |
@@ -0,0 +1,142 @@
|
||||
"""Already-landed open PR reconciliation gates (#310).
|
||||
|
||||
Reconciler workflows may close an open PR only when the PR head SHA is proven
|
||||
an ancestor of a freshly fetched target branch. Arbitrary PR closure is denied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from merged_cleanup_reconcile import extract_linked_issue, is_head_ancestor_of_ref
|
||||
|
||||
ELIGIBILITY_ALREADY_LANDED = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
ELIGIBILITY_NOT_LANDED = "NOT_ALREADY_LANDED"
|
||||
ELIGIBILITY_STALE_TARGET = "TARGET_BRANCH_UNVERIFIED"
|
||||
ELIGIBILITY_PR_NOT_OPEN = "PR_NOT_OPEN"
|
||||
|
||||
|
||||
def fetch_target_branch(project_root: str, remote: str, branch: str) -> dict[str, Any]:
|
||||
"""Fetch *branch* from *remote* and return the resolved SHA."""
|
||||
ref = f"{remote}/{branch}"
|
||||
fetch = subprocess.run(
|
||||
["git", "-C", project_root, "fetch", remote, branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git fetch {remote} {branch} failed: "
|
||||
f"{(fetch.stderr or fetch.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
rev = subprocess.run(
|
||||
["git", "-C", project_root, "rev-parse", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if rev.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git rev-parse {ref} failed: "
|
||||
f"{(rev.stderr or rev.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": (rev.stdout or "").strip(),
|
||||
"reasons": [],
|
||||
"git_fetch_command": f"git fetch {remote} {branch}",
|
||||
}
|
||||
|
||||
|
||||
def assess_already_landed_reconciliation(
|
||||
*,
|
||||
pr: dict[str, Any],
|
||||
project_root: str,
|
||||
remote: str,
|
||||
target_branch: str,
|
||||
target_fetch: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return eligibility and proof for reconciling an open PR."""
|
||||
pr_number = int(pr.get("number") or 0)
|
||||
pr_state = (pr.get("state") or "").strip().lower()
|
||||
head_sha = (pr.get("head") or {}).get("sha") if isinstance(pr.get("head"), dict) else None
|
||||
if not head_sha and isinstance(pr.get("head"), str):
|
||||
head_sha = None
|
||||
head_ref = (pr.get("head") or {}).get("ref") if isinstance(pr.get("head"), dict) else pr.get("head")
|
||||
base_ref = (pr.get("base") or {}).get("ref") if isinstance(pr.get("base"), dict) else pr.get("base")
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
|
||||
fetch_result = target_fetch or fetch_target_branch(project_root, remote, target_branch)
|
||||
linked_issue = extract_linked_issue(title, body)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pr_number": pr_number,
|
||||
"pr_state": pr_state,
|
||||
"candidate_head_sha": head_sha,
|
||||
"head_ref": head_ref,
|
||||
"base_ref": base_ref or target_branch,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": fetch_result.get("target_branch_sha"),
|
||||
"linked_issue": linked_issue,
|
||||
"git_ref_mutations": [],
|
||||
"reasons": [],
|
||||
}
|
||||
if fetch_result.get("git_fetch_command"):
|
||||
result["git_ref_mutations"].append(fetch_result["git_fetch_command"])
|
||||
|
||||
if pr_state != "open":
|
||||
result["eligibility_class"] = ELIGIBILITY_PR_NOT_OPEN
|
||||
result["ancestor_proof"] = None
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].append(f"PR #{pr_number} state is {pr_state!r}, not open")
|
||||
return result
|
||||
|
||||
if not fetch_result.get("success"):
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["ancestor_proof"] = None
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].extend(fetch_result.get("reasons") or [])
|
||||
return result
|
||||
|
||||
target_ref = fetch_result.get("target_ref") or f"{remote}/{target_branch}"
|
||||
ancestor = is_head_ancestor_of_ref(project_root, head_sha, target_ref)
|
||||
result["ancestor_proof"] = ancestor
|
||||
|
||||
if ancestor is None:
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"ancestor check failed for head {head_sha!r} against {target_ref}"
|
||||
)
|
||||
return result
|
||||
|
||||
if ancestor:
|
||||
result["eligibility_class"] = ELIGIBILITY_ALREADY_LANDED
|
||||
result["close_allowed"] = True
|
||||
return result
|
||||
|
||||
result["eligibility_class"] = ELIGIBILITY_NOT_LANDED
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"PR head {head_sha} is not an ancestor of {target_ref}"
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Branches-only author mutation worktree guard (#274).
|
||||
|
||||
Author/coder mutations must run from a session-owned worktree under the
|
||||
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"
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def is_path_under_branches(path: str, project_root: str | None = None) -> bool:
|
||||
"""True when *path* resolves inside ``<project_root>/branches/``."""
|
||||
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(os.path.realpath(project_root))
|
||||
real = _normalize_path(os.path.realpath(path))
|
||||
if real.startswith(f"{root}/"):
|
||||
rel = real[len(root) + 1 :]
|
||||
return rel == "branches" or rel.startswith("branches/")
|
||||
return False
|
||||
|
||||
|
||||
def resolve_mutation_workspace(
|
||||
worktree_path: str | None,
|
||||
project_root: str,
|
||||
*,
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the workspace path inspected before author mutations."""
|
||||
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text))
|
||||
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,
|
||||
project_root: str,
|
||||
current_branch: str | None = None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when author mutations are not rooted under ``branches/``."""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
reasons: list[str] = []
|
||||
root = os.path.realpath(project_root)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
branch = (current_branch or "").strip()
|
||||
|
||||
under_branches = is_path_under_branches(workspace, root)
|
||||
if not under_branches:
|
||||
if workspace == root:
|
||||
reasons.append(
|
||||
"author mutation blocked: workspace is the stable control checkout; "
|
||||
"create or switch to a session-owned worktree under branches/"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"author mutation blocked: workspace '{workspace}' is not under "
|
||||
f"'{root}/branches/'; create a branches/<task> worktree first"
|
||||
)
|
||||
|
||||
if not under_branches and workspace == root and branch and branch not in bases:
|
||||
reasons.append(
|
||||
f"control checkout drift: branch '{branch}' is not a stable base "
|
||||
f"branch ({'/'.join(sorted(bases))})"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"project_root": root,
|
||||
"workspace_path": workspace,
|
||||
"under_branches": is_path_under_branches(workspace, root),
|
||||
"current_branch": branch or None,
|
||||
}
|
||||
|
||||
|
||||
def format_author_mutation_worktree_error(assessment: dict) -> str:
|
||||
"""Single RuntimeError message for MCP preflight gates."""
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
root = assessment.get("project_root") or "(unknown)"
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown branches-only violation"])
|
||||
return (
|
||||
f"Branches-only mutation guard (#274): {reasons}. "
|
||||
f"project root: {root}; workspace: {workspace}. "
|
||||
"Create a session-owned worktree under branches/ before mutating."
|
||||
)
|
||||
@@ -13,6 +13,8 @@ REVIEWER_CAPABILITY_TASKS = frozenset({
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"request_changes_pr",
|
||||
"approve_pr",
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ launched with exactly one static execution profile:
|
||||
|-----------------------------|----------------|-------------|
|
||||
| `gitea-author` | an author profile | implement issues, push branches, open PRs, comment |
|
||||
| `gitea-reviewer` | a reviewer profile | review, approve/request changes, merge |
|
||||
| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) |
|
||||
|
||||
Properties:
|
||||
|
||||
|
||||
@@ -226,6 +226,31 @@ explicit operator-directed closure of a contaminated PR). If `close_pr` ever
|
||||
resolves as unknown, agents must fail closed rather than fall back to the
|
||||
edit path.
|
||||
|
||||
## Reconciler profile for already-landed open PRs (#304 / #310)
|
||||
|
||||
Normal author and reviewer profiles must not gain broad `gitea.pr.close`
|
||||
authority. Already-landed open PRs (head SHA is an ancestor of the target
|
||||
branch) need a dedicated reconciler profile such as `prgs-reconciler` with a
|
||||
narrow operation set:
|
||||
|
||||
- `gitea.read`
|
||||
- `gitea.pr.comment`
|
||||
- `gitea.issue.comment`
|
||||
- `gitea.issue.close`
|
||||
- `gitea.pr.close`
|
||||
|
||||
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
||||
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
||||
`gitea.repo.commit`.
|
||||
|
||||
Launch a static `gitea-reconciler` MCP namespace with
|
||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||
`gitea_reconcile_already_landed_pr` tool (#310). The resolver task is
|
||||
`reconcile_already_landed_pr`. PR close is allowed only after live PR fetch,
|
||||
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
||||
heads are not already landed cannot be closed through this path.
|
||||
|
||||
## Identity and fail-closed rules
|
||||
|
||||
Before **any** mutating action, a workflow must know both:
|
||||
|
||||
@@ -169,6 +169,25 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
|
||||
* `mcp__gitea-reviewer__*` (for reviewing PRs, approving, requesting changes, merging)
|
||||
* **Trust Model:** Separate tokens remain separate in the keychain/environment. Each instance operates under its own `GITEA_MCP_PROFILE` and enforces its own `allowed_operations`. A runtime `whoami` identity check is still performed independently, and self-review/self-merge checks remain strictly mandatory. The dual-server pattern is a operational convenience and never a security bypass.
|
||||
* **Reviewer-Identity PR Creation Deadlock:** Reviewer/merge identities must not create PRs or push branches. Doing so makes the reviewer identity the PR author in Gitea, blocking subsequent independent review and causing a review deadlock. Normally, PRs must be created by the author/work identity (`gitea-author`), leaving the reviewer identity (`gitea-reviewer`) clean and available for independent review and merge.
|
||||
* **Reconciler namespace (#310):** Register a third static instance for
|
||||
already-landed PR cleanup when review queues block on open PRs whose heads
|
||||
already landed on `master`:
|
||||
|
||||
```json
|
||||
"gitea-reconciler": {
|
||||
"command": "/path/to/Gitea-Tools/venv/bin/python3",
|
||||
"args": ["/path/to/Gitea-Tools/mcp_server.py"],
|
||||
"env": {
|
||||
"GITEA_MCP_CONFIG": "/path/to/.config/gitea-tools/profiles.json",
|
||||
"GITEA_MCP_PROFILE": "prgs-reconciler"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The reconciler profile grants `gitea.pr.close` only for
|
||||
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
||||
review or author workflows.
|
||||
|
||||
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
||||
|
||||
## Setup runbook — interactive menu
|
||||
@@ -254,7 +273,80 @@ Never create a parallel branch or PR for the same issue unless the old branch
|
||||
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. Full portable wording:
|
||||
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
|
||||
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
|
||||
shared state and manual writes can clobber another session's live lease. Use
|
||||
`sanctioned recovery` instead:
|
||||
|
||||
1. `gitea_lock_issue` on a clean `branches/` worktree (normal path).
|
||||
2. Own-branch adoption via #442 when the issue's exact branch is already pushed.
|
||||
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
|
||||
approval in one run. See also #438 (global lock redesign).
|
||||
|
||||
Remote branches matching the issue number are also treated as active work unless
|
||||
the recovery review proves the branch is abandoned or superseded. Never delete
|
||||
or clean up a branch when it has an active lease, dirty worktree, open PR, or is
|
||||
the only copy of unmerged work.
|
||||
|
||||
Full portable wording:
|
||||
[`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||
|
||||
## Global LLM Worktree Rule
|
||||
@@ -400,6 +492,25 @@ Root-level matches are listed in `.gitignore` so they never get committed.
|
||||
`gitea_get_runtime_context` and `gitea_lock_issue` surface **warnings** (not
|
||||
hard blocks) when these artifacts are still present.
|
||||
|
||||
## Capability preflight lifetime (#470)
|
||||
|
||||
After `gitea_resolve_task_capability(task=…)` proves the mutation is allowed,
|
||||
interleaved **read-only** calls preserve that proof until a gated mutation
|
||||
consumes it:
|
||||
|
||||
- Safe reads: `gitea_whoami`, `gitea_view_pr`, `gitea_view_issue`, `gitea_list_*`,
|
||||
`gitea_get_runtime_context`, `gitea_check_pr_eligibility`, and related
|
||||
read-only inventory/eligibility tools (see `preflight_contract.py`).
|
||||
- Each mutation consumes the proof once; call `gitea_resolve_task_capability`
|
||||
again immediately before the next mutation on the same task.
|
||||
- Resolving capability for a **different** task replaces the prior task binding.
|
||||
- Workspace edits before resolve, profile switches, or a dirty whoami baseline
|
||||
invalidate proof (fail closed).
|
||||
|
||||
If a mutation fails with “capability has not been resolved” or “task mismatch”,
|
||||
re-run `gitea_resolve_task_capability(task="<mutation>")` immediately before
|
||||
retrying — do not guess or skip the resolve step.
|
||||
|
||||
Implementation work and review work must use separate branch folders. For
|
||||
example, an implementation branch might live under
|
||||
`branches/fix-issue-123-example`, while a review branch for the resulting PR
|
||||
@@ -749,3 +860,22 @@ 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.
|
||||
|
||||
## PR-only queue cleanup mode (#390)
|
||||
|
||||
Use `pr-queue-cleanup` mode when the queue holds many open PRs and the goal
|
||||
is to drain reviews deterministically. One run = one PR = one canonical
|
||||
review (`skills/llm-project-workflow/workflows/pr-queue-cleanup.md`).
|
||||
|
||||
* Cleanup runs are reviewer-role only (`pr_queue_cleanup` resolver task);
|
||||
author sessions get `wrong_role_stop`.
|
||||
* Forbidden in cleanup mode: issue claiming, branch creation, implementation
|
||||
edits, new issue filing, and any second-PR review after a terminal
|
||||
mutation.
|
||||
* Terminal chain: stop after `REQUEST_CHANGES`; after `APPROVED` continue
|
||||
only to same-PR merge with explicit per-PR operator authorization and
|
||||
passing gates; stop after merge or merge blocker.
|
||||
* Every run reports the next suggested PR without continuing to it; the next
|
||||
PR requires a fresh run with fresh identity/capability/inventory proof.
|
||||
* Use full `review-merge-pr` mode instead when the operator wants a single
|
||||
targeted review, and `work-issue` mode for any authoring.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Internal web UI — local development (#426)
|
||||
|
||||
Read-only MVP skeleton for the MCP Control Plane operator console. Gitea,
|
||||
MCP capability gates, and `skills/llm-project-workflow/` remain the source of
|
||||
truth; this UI only provides route stubs and layout.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+ with project dependencies installed (`pip install -r requirements.txt`)
|
||||
- No secrets in repo, config, or client bundle
|
||||
|
||||
## Start the server
|
||||
|
||||
From the repository root (or an issue worktree):
|
||||
|
||||
```bash
|
||||
./scripts/run-webui
|
||||
```
|
||||
|
||||
Or directly:
|
||||
|
||||
```bash
|
||||
python3 -m webui
|
||||
```
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
||||
| `WEBUI_PORT` | `8765` | Listen port |
|
||||
|
||||
## Routes (MVP)
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/` | Home / operator overview |
|
||||
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
|
||||
| `/queue` | Live PR and issue queue dashboard (#429) |
|
||||
| `/api/queue` | JSON queue export with pagination metadata |
|
||||
| `/projects` | Project registry list (#427) |
|
||||
| `/projects/{id}` | Project detail + onboarding checklist |
|
||||
| `/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) |
|
||||
|
||||
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
||||
`read-only-mvp`.
|
||||
|
||||
## Project registry (#427)
|
||||
|
||||
Versioned registry file: `webui/data/projects.registry.json` (schema version `1`).
|
||||
|
||||
Override path with `WEBUI_PROJECT_REGISTRY` when operators keep a machine-local
|
||||
copy outside git. The registry stores repo identity, remotes, profile names,
|
||||
workflow/schema path references, and onboarding checklist steps — never tokens
|
||||
or credentials.
|
||||
|
||||
Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
|
||||
`prgs-reviewer`, and `prgs-reconciler` profiles.
|
||||
|
||||
## Prompt library (#428)
|
||||
|
||||
Prompts are generated at load time from canonical workflow files under
|
||||
`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from
|
||||
`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short
|
||||
copy/paste starters; canonical workflow files remain the only full policy
|
||||
source.
|
||||
|
||||
## Live queue dashboard (#429)
|
||||
|
||||
`/queue` loads open PRs and issues for the default registry project (seed:
|
||||
**Gitea-Tools** on `https://gitea.prgs.cc`) using existing `gitea_auth` read
|
||||
credentials. The UI surfaces pagination proof (returned count, pages fetched,
|
||||
`has_more`, `inventory_complete`) and classification badges (`claimed`,
|
||||
`blocked`, `in-review`, `duplicate`) when evidence exists.
|
||||
|
||||
If credentials are missing or the fetch fails, the page shows an explicit error
|
||||
instead of an empty queue (fail closed).
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -17,6 +17,10 @@
|
||||
0. Work Selection Rule — verify a work lease before any mutations (open PRs,
|
||||
issue-linked PRs, branches, worktrees, dirty worktrees, active leases/
|
||||
handoffs, merged-PR completion). Stop if another session owns the lease.
|
||||
`gitea_lock_issue` records an operation-scoped `author_issue_work` lease
|
||||
with issue, branch, worktree, claimant, created, expiry, and heartbeat
|
||||
fields; active or expired same-operation leases require recovery review
|
||||
before takeover.
|
||||
0b. Global LLM Worktree Rule — main checkout on `master`/`main`/`dev` only;
|
||||
mutate only from a `branches/` worktree after proving root, cwd, branch,
|
||||
stable main-checkout branch, and session worktree path (no exceptions).
|
||||
@@ -31,4 +35,4 @@
|
||||
Wiki publication, credential provisioning, and cross-repo mirror operations are
|
||||
operator-confirmed actions — see [Runbooks](Runbooks.md).
|
||||
|
||||
Full Gitea-specific runbooks: `docs/llm-workflow-runbooks.md` in the repository.
|
||||
Full Gitea-specific runbooks: `docs/llm-workflow-runbooks.md` in the repository.
|
||||
|
||||
+382
-7
@@ -11,6 +11,7 @@ import inspect
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
import issue_lock_provenance
|
||||
from review_proofs import (
|
||||
HANDOFF_HEADING,
|
||||
assess_controller_handoff,
|
||||
@@ -20,6 +21,7 @@ from review_proofs import (
|
||||
assess_review_mutation_final_report,
|
||||
assess_validation_report,
|
||||
)
|
||||
from validation_status_vocabulary import assess_validation_status_vocabulary
|
||||
|
||||
FINAL_REPORT_TASK_KINDS = frozenset({
|
||||
"review_pr",
|
||||
@@ -87,25 +89,72 @@ _ALREADY_LANDED_RE = re.compile(
|
||||
r"already[- ]landed|ancestor proof\s*:\s*passed|eligibility class\s*:\s*already_landed",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ALREADY_LANDED_GATE_FIRED_RE = re.compile(
|
||||
r"\b(?:fired|passed|true|yes|already_landed_reconcile_required)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ELIGIBLE_REVIEW_RE = re.compile(
|
||||
r"eligible for (?:review|merge)|ready for (?:review|merge)|next eligible pr",
|
||||
r"eligible for (?:review|merge)|ready for (?:review|merge)|"
|
||||
r"(?:next|oldest)\s+eligible\s+pr",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_APPROVED_STATE_RE = re.compile(r"\bapproved?\b", re.IGNORECASE)
|
||||
_MERGED_STATE_RE = re.compile(r"\bmerged\b", re.IGNORECASE)
|
||||
_READY_TO_MERGE_STATE_RE = re.compile(r"\bready_to_merge\b|\bready to merge\b", re.IGNORECASE)
|
||||
_NEGATED_STATE_RE = re.compile(
|
||||
r"\b(?:not|no|none|n/a|never)\b|request_changes|not attempted",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWED_LANDED_RE = re.compile(
|
||||
r"(?:reviewed|approved).{0,40}already[- ]landed|already[- ]landed.{0,40}(?:reviewed|eligible)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_TARGET_BRANCH_UP_TO_DATE_RE = re.compile(
|
||||
r"\btarget branch (?:is )?up[- ]to[- ]date\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TARGET_BRANCH_SHA_RE = re.compile(
|
||||
r"target branch sha\s*:\s*[0-9a-f]{40}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||
_RECONCILE_STALE_FIELDS = (
|
||||
"pr number opened",
|
||||
"pinned reviewed head",
|
||||
"scratch worktree used",
|
||||
"review decision",
|
||||
"merge result",
|
||||
"issue lock proof",
|
||||
"claim/comment status",
|
||||
"workspace mutations",
|
||||
)
|
||||
# Issue #307: reconciliation handoffs must carry the full canonical schema.
|
||||
_RECONCILE_REQUIRED_FIELDS = (
|
||||
"Task",
|
||||
"Repo",
|
||||
"Role/profile",
|
||||
"Identity",
|
||||
"Selected PR",
|
||||
"Eligibility class",
|
||||
"PR live state",
|
||||
"Candidate head SHA",
|
||||
"Target branch",
|
||||
"Target branch SHA",
|
||||
"Ancestor proof",
|
||||
"Linked issue",
|
||||
"Linked issue live status",
|
||||
"Eligibility class",
|
||||
"Capabilities proven",
|
||||
"Missing capabilities",
|
||||
"PR comments posted",
|
||||
"Issue comments posted",
|
||||
"PRs closed",
|
||||
"Issues closed",
|
||||
"Git ref mutations",
|
||||
"Worktree mutations",
|
||||
"MCP/Gitea mutations",
|
||||
"External-state mutations",
|
||||
"Read-only diagnostics",
|
||||
"Blocker",
|
||||
"Safe next action",
|
||||
"No review/merge confirmation",
|
||||
)
|
||||
@@ -190,6 +239,54 @@ def _handoff_fields(report_text: str) -> dict[str, str]:
|
||||
return fields
|
||||
|
||||
|
||||
def _already_landed_gate_fired(report_text: str) -> bool:
|
||||
text = report_text or ""
|
||||
fields = _handoff_fields(text)
|
||||
|
||||
gate_value = fields.get("already-landed gate", "")
|
||||
if gate_value and _ALREADY_LANDED_GATE_FIRED_RE.search(gate_value):
|
||||
return True
|
||||
|
||||
ancestor_value = fields.get("ancestor proof", "")
|
||||
if re.search(r"\bpassed\b", ancestor_value, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
eligibility_value = fields.get("eligibility class", "")
|
||||
if "already_landed" in eligibility_value.lower():
|
||||
return True
|
||||
|
||||
if re.search(
|
||||
r"\bpr\b.{0,60}\balready[- ]landed\b|\balready[- ]landed\b.{0,60}\bpr\b",
|
||||
text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
):
|
||||
return True
|
||||
|
||||
return bool(_ALREADY_LANDED_RE.search(text))
|
||||
|
||||
|
||||
def _positive_review_state_terms(fields: dict[str, str]) -> list[str]:
|
||||
terms: list[str] = []
|
||||
for key, value in fields.items():
|
||||
lowered_key = key.lower()
|
||||
lowered_value = value.lower()
|
||||
if _NEGATED_STATE_RE.search(value):
|
||||
continue
|
||||
if lowered_key == "review decision" and _APPROVED_STATE_RE.search(value):
|
||||
terms.append("APPROVED")
|
||||
elif lowered_key == "merge result" and _MERGED_STATE_RE.search(value):
|
||||
terms.append("MERGED")
|
||||
elif _READY_TO_MERGE_STATE_RE.search(value):
|
||||
terms.append("READY_TO_MERGE")
|
||||
elif lowered_value in {"approved", "approve"}:
|
||||
terms.append("APPROVED")
|
||||
elif lowered_value == "merged":
|
||||
terms.append("MERGED")
|
||||
elif lowered_value == "ready_to_merge":
|
||||
terms.append("READY_TO_MERGE")
|
||||
return sorted(set(terms))
|
||||
|
||||
|
||||
def _rule_shared_controller_handoff(
|
||||
report_text: str,
|
||||
task_kind: str,
|
||||
@@ -346,19 +443,130 @@ def _rule_reviewer_git_fetch_readonly(
|
||||
"classify git fetch under Git ref mutations, not read-only diagnostics",
|
||||
)
|
||||
]
|
||||
if not git_ref_reported and _GIT_FETCH_RE.search(text):
|
||||
if not git_ref_reported:
|
||||
# Issue #307: an observed git fetch (report text or action log)
|
||||
# must be listed under Git ref mutations.
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.git_fetch_readonly",
|
||||
"downgrade",
|
||||
"Git ref mutations",
|
||||
"git fetch mentioned without Git ref mutation classification",
|
||||
"git fetch occurred without Git ref mutation classification",
|
||||
"record git fetch under Git ref mutations",
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _rule_reviewer_validation_failure_history(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
from reviewer_validation_failure_history import (
|
||||
assess_validation_failure_history_report,
|
||||
)
|
||||
|
||||
session = validation_session or {}
|
||||
observed = session.get("observed_failures") or []
|
||||
if not observed:
|
||||
return []
|
||||
|
||||
result = assess_validation_failure_history_report(
|
||||
report_text,
|
||||
validation_session=session,
|
||||
)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
severity = "block" if result.get("violations") else "downgrade"
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.validation_failure_history",
|
||||
severity,
|
||||
"Validation failure history",
|
||||
reason,
|
||||
result.get("safe_next_action")
|
||||
or "document every observed validation failure before claiming pass",
|
||||
)
|
||||
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
|
||||
]
|
||||
|
||||
|
||||
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_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_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _BARE_PYTEST_RE.search(text):
|
||||
@@ -467,6 +675,34 @@ def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, st
|
||||
]
|
||||
|
||||
|
||||
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():
|
||||
@@ -492,7 +728,7 @@ def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]:
|
||||
|
||||
def _rule_reviewer_already_landed_eligible(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _ALREADY_LANDED_RE.search(text):
|
||||
if not _already_landed_gate_fired(text):
|
||||
return []
|
||||
if not _ELIGIBLE_REVIEW_RE.search(text):
|
||||
return []
|
||||
@@ -507,6 +743,65 @@ def _rule_reviewer_already_landed_eligible(report_text: str) -> list[dict[str, s
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_already_landed_state(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _already_landed_gate_fired(text):
|
||||
return []
|
||||
|
||||
terms = _positive_review_state_terms(_handoff_fields(text))
|
||||
if not terms:
|
||||
return []
|
||||
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.already_landed_review_state",
|
||||
"block",
|
||||
"Review decision",
|
||||
"already-landed gate fired but final report claims "
|
||||
f"{', '.join(terms)} state",
|
||||
"stop normal review/merge; classify as ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_target_branch_freshness(
|
||||
report_text: str,
|
||||
*,
|
||||
action_log: list[dict] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _TARGET_BRANCH_UP_TO_DATE_RE.search(text):
|
||||
return []
|
||||
|
||||
fields = _handoff_fields(text)
|
||||
fetch_reported = bool(_GIT_FETCH_RE.search(text)) or any(
|
||||
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
|
||||
for e in (action_log or [])
|
||||
)
|
||||
target_sha_reported = bool(_TARGET_BRANCH_SHA_RE.search(text)) or any(
|
||||
"target branch" in key and "sha" in key and _FULL_SHA_RE.search(value)
|
||||
for key, value in fields.items()
|
||||
)
|
||||
if fetch_reported and target_sha_reported:
|
||||
return []
|
||||
|
||||
missing = []
|
||||
if not fetch_reported:
|
||||
missing.append("fresh git fetch")
|
||||
if not target_sha_reported:
|
||||
missing.append("target branch SHA")
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.target_branch_freshness_proof",
|
||||
"block",
|
||||
"Target branch SHA",
|
||||
"target branch up-to-date claim lacks " + " and ".join(missing),
|
||||
"fetch the target branch and report the exact target branch SHA before "
|
||||
"claiming it is up to date",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_reconcile_controller_handoff(report_text: str) -> list[dict[str, str]]:
|
||||
from review_proofs import _handoff_section_lines
|
||||
|
||||
@@ -541,10 +836,18 @@ def _rule_reconcile_controller_handoff(report_text: str) -> list[dict[str, str]]
|
||||
return []
|
||||
|
||||
|
||||
def _rule_reconcile_stale_author_fields(report_text: str) -> list[dict[str, str]]:
|
||||
def _rule_reconcile_stale_author_fields(
|
||||
report_text: str,
|
||||
*,
|
||||
session_pr_opened: bool = False,
|
||||
) -> list[dict[str, str]]:
|
||||
text = (report_text or "").lower()
|
||||
findings = []
|
||||
for field in _RECONCILE_STALE_FIELDS:
|
||||
if field == "pr number opened" and session_pr_opened:
|
||||
# Issue #307: allowed only when a PR was actually opened
|
||||
# in this session.
|
||||
continue
|
||||
if f"{field}:" in text:
|
||||
findings.append(
|
||||
validator_finding(
|
||||
@@ -672,6 +975,54 @@ def _rule_reviewer_mutation_ledger(
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_issue_lock_external_state(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_lock_provenance.assess_issue_lock_external_state_report(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.issue_lock_external_state",
|
||||
result.get("reasons") or [],
|
||||
field="External-state mutations",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"disclose gitea_issue_lock.json read/write/delete under "
|
||||
"External-state mutations; never claim none after lock seeding"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_lock_provenance.assess_manual_lock_pr_without_override(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.manual_lock_pr_override",
|
||||
result.get("reasons") or [],
|
||||
field="External-state mutations",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"use gitea_lock_issue or #442 adoption instead of manual lock seeding; "
|
||||
"if operator override was authorized, cite override proof"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.author_reviewer_same_run",
|
||||
result.get("reasons") or [],
|
||||
field="Review mutations",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"split author PR creation and reviewer approval across separate "
|
||||
"sessions and handoffs"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_review_mutation(
|
||||
report_text: str,
|
||||
*,
|
||||
@@ -691,27 +1042,41 @@ def _rule_reviewer_review_mutation(
|
||||
)
|
||||
|
||||
|
||||
_SHARED_ISSUE_LOCK_RULES = (
|
||||
_rule_shared_issue_lock_external_state,
|
||||
_rule_shared_manual_lock_pr_override,
|
||||
_rule_shared_author_reviewer_same_run,
|
||||
)
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_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_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_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_mutation_ledger,
|
||||
_rule_reviewer_review_mutation,
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"reconcile_already_landed": [
|
||||
_rule_reconcile_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_stale_author_fields,
|
||||
_rule_reconcile_eligible_reviewed,
|
||||
_rule_reconcile_linked_issue_live,
|
||||
@@ -723,25 +1088,31 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"author_issue": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
],
|
||||
"work_issue": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
_rule_conflict_fix_push_proof,
|
||||
],
|
||||
"issue_filing": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
"inventory": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_pagination_proof,
|
||||
],
|
||||
"issue_selection": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -803,6 +1174,8 @@ def assess_final_report_validator(
|
||||
mutations_observed: bool = False,
|
||||
local_edits: bool = False,
|
||||
issue_filing_lock: dict | None = None,
|
||||
session_pr_opened: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate final-report text against task-specific proof rules (#327).
|
||||
|
||||
@@ -857,6 +1230,8 @@ def assess_final_report_validator(
|
||||
"action_log": action_log,
|
||||
"mutations_observed": mutations_observed,
|
||||
"local_edits": local_edits,
|
||||
"session_pr_opened": session_pr_opened,
|
||||
"validation_session": validation_session,
|
||||
}
|
||||
|
||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||
@@ -875,4 +1250,4 @@ def assess_final_report_validator(
|
||||
"safe_next_action": _primary_safe_next_action(findings),
|
||||
"complete": grade == "A",
|
||||
"handoff_heading": HANDOFF_HEADING,
|
||||
}
|
||||
}
|
||||
|
||||
+2168
-121
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
"""Issue claim heartbeat leases and stale-claim reconciliation (#268).
|
||||
|
||||
Structured issue-thread comments prove live ownership beyond the
|
||||
``status:in-progress`` label alone. Queue inventory can classify claims as
|
||||
active, stale, reclaimable, PR-backed, or phantom.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
MARKER = "<!-- gitea-issue-claim-heartbeat:v1 -->"
|
||||
IN_PROGRESS_LABEL = "status:in-progress"
|
||||
|
||||
_KIND_CLAIM = "claim"
|
||||
_KIND_PROGRESS = "progress"
|
||||
_KIND_CLEANUP = "cleanup"
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^\s*-\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_ISSUE_REF_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def format_heartbeat_body(
|
||||
*,
|
||||
kind: str,
|
||||
issue_number: int,
|
||||
branch: str,
|
||||
phase: str,
|
||||
profile: str | None = None,
|
||||
pr: str = "none",
|
||||
next_action: str = "none",
|
||||
blocker: str = "none",
|
||||
) -> str:
|
||||
"""Return a structured, machine-parseable issue comment body."""
|
||||
profile_value = (profile or "unknown").strip() or "unknown"
|
||||
lines = [
|
||||
MARKER,
|
||||
"**Issue claim heartbeat**",
|
||||
f"- kind: {kind}",
|
||||
f"- issue: #{issue_number}",
|
||||
f"- branch: {branch}",
|
||||
f"- phase: {phase}",
|
||||
f"- profile: {profile_value}",
|
||||
f"- pr: {pr}",
|
||||
f"- blocker: {blocker}",
|
||||
f"- next_action: {next_action}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_heartbeat_comment(body: str) -> dict[str, Any] | None:
|
||||
"""Parse one structured heartbeat comment, or None when not a heartbeat."""
|
||||
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
|
||||
issue_raw = fields.get("issue", "")
|
||||
issue_digits = re.sub(r"[^\d]", "", issue_raw)
|
||||
issue_number = int(issue_digits) if issue_digits.isdigit() else None
|
||||
return {
|
||||
"kind": fields.get("kind"),
|
||||
"issue_number": issue_number,
|
||||
"branch": fields.get("branch"),
|
||||
"phase": fields.get("phase"),
|
||||
"profile": fields.get("profile"),
|
||||
"pr": fields.get("pr"),
|
||||
"blocker": fields.get("blocker"),
|
||||
"next_action": fields.get("next_action"),
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def extract_issue_heartbeats(
|
||||
comments: list[dict],
|
||||
*,
|
||||
issue_number: int | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return parsed heartbeats newest-last, optionally filtered to *issue_number*."""
|
||||
heartbeats: list[dict] = []
|
||||
for comment in comments or []:
|
||||
parsed = parse_heartbeat_comment(comment.get("body") or "")
|
||||
if not parsed:
|
||||
continue
|
||||
if issue_number is not None and parsed.get("issue_number") != issue_number:
|
||||
continue
|
||||
heartbeats.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 heartbeats
|
||||
|
||||
|
||||
def issue_has_in_progress_label(issue: dict) -> bool:
|
||||
labels = issue.get("labels") or []
|
||||
for label in labels:
|
||||
name = label if isinstance(label, str) else label.get("name")
|
||||
if name == IN_PROGRESS_LABEL:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
||||
pattern = f"issue-{issue_number}"
|
||||
closes = f"closes #{issue_number}"
|
||||
fixes = f"fixes #{issue_number}"
|
||||
for pr in open_prs or []:
|
||||
head = (pr.get("head") or {}).get("ref") or ""
|
||||
text = f"{pr.get('title', '')} {pr.get('body', '')}".lower()
|
||||
if pattern in head.lower():
|
||||
return pr
|
||||
if closes in text or fixes in text:
|
||||
return pr
|
||||
return None
|
||||
|
||||
|
||||
def _matching_branch_names(issue_number: int, branch_names: list[str]) -> list[str]:
|
||||
pattern = f"issue-{issue_number}"
|
||||
return [name for name in branch_names if pattern in (name or "").lower()]
|
||||
|
||||
|
||||
def classify_issue_claim(
|
||||
*,
|
||||
issue: dict,
|
||||
comments: list[dict],
|
||||
open_prs: list[dict] | None = None,
|
||||
branch_names: list[str] | None = None,
|
||||
now: datetime | None = None,
|
||||
heartbeat_lease_minutes: int = 30,
|
||||
reclaim_after_minutes: int = 60,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify one issue's claim state from labels, heartbeats, PRs, and branches."""
|
||||
issue_number = int(issue.get("number") or 0)
|
||||
open_prs = open_prs or []
|
||||
branch_names = branch_names or []
|
||||
current = now or datetime.now(timezone.utc)
|
||||
|
||||
has_label = issue_has_in_progress_label(issue)
|
||||
heartbeats = extract_issue_heartbeats(comments, issue_number=issue_number)
|
||||
latest = heartbeats[-1] if heartbeats else None
|
||||
latest_at = _parse_timestamp(
|
||||
(latest or {}).get("updated_at") or (latest or {}).get("created_at")
|
||||
)
|
||||
age_minutes = None
|
||||
if latest_at:
|
||||
age_minutes = int((current - latest_at).total_seconds() // 60)
|
||||
|
||||
linked_pr = _linked_open_pr(issue_number, open_prs)
|
||||
matching_branches = _matching_branch_names(issue_number, branch_names)
|
||||
|
||||
if not has_label:
|
||||
status = "not_claimed"
|
||||
reasons = ["issue lacks status:in-progress label"]
|
||||
elif linked_pr:
|
||||
status = "awaiting_review"
|
||||
reasons = [f"open PR #{linked_pr.get('number')} covers this issue"]
|
||||
elif not heartbeats:
|
||||
status = "phantom"
|
||||
reasons = [
|
||||
"status:in-progress label present without structured claim heartbeat"
|
||||
]
|
||||
elif latest_at is None:
|
||||
status = "phantom"
|
||||
reasons = ["heartbeat comments present but timestamps could not be parsed"]
|
||||
elif age_minutes is not None and age_minutes <= heartbeat_lease_minutes:
|
||||
status = "active"
|
||||
reasons = [f"heartbeat age {age_minutes}m within {heartbeat_lease_minutes}m lease"]
|
||||
elif matching_branches and age_minutes is not None and age_minutes <= reclaim_after_minutes:
|
||||
status = "active"
|
||||
reasons = [
|
||||
f"matching branch(es) {', '.join(matching_branches)} with heartbeat "
|
||||
f"age {age_minutes}m"
|
||||
]
|
||||
elif age_minutes is not None and age_minutes > reclaim_after_minutes and not matching_branches:
|
||||
status = "reclaimable"
|
||||
reasons = [
|
||||
f"no heartbeat within {reclaim_after_minutes}m and no matching branch"
|
||||
]
|
||||
elif age_minutes is not None and age_minutes > heartbeat_lease_minutes:
|
||||
status = "stale"
|
||||
reasons = [
|
||||
f"heartbeat age {age_minutes}m exceeds {heartbeat_lease_minutes}m lease"
|
||||
]
|
||||
else:
|
||||
status = "active"
|
||||
reasons = ["claim has structured heartbeat proof"]
|
||||
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"title": issue.get("title"),
|
||||
"status": status,
|
||||
"has_in_progress_label": has_label,
|
||||
"heartbeat_count": len(heartbeats),
|
||||
"latest_heartbeat": latest,
|
||||
"heartbeat_age_minutes": age_minutes,
|
||||
"linked_open_pr": linked_pr.get("number") if linked_pr else None,
|
||||
"matching_branches": matching_branches,
|
||||
"reasons": reasons,
|
||||
"reclaimable": status == "reclaimable",
|
||||
"stale": status in {"stale", "phantom", "reclaimable"},
|
||||
}
|
||||
|
||||
|
||||
def build_claim_inventory(
|
||||
*,
|
||||
issues: list[dict],
|
||||
comments_by_issue: dict[int, list[dict]],
|
||||
open_prs: list[dict],
|
||||
branch_names: list[str],
|
||||
now: datetime | None = None,
|
||||
heartbeat_lease_minutes: int = 30,
|
||||
reclaim_after_minutes: int = 60,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a queue inventory report for in-progress issue claims."""
|
||||
entries: list[dict] = []
|
||||
for issue in issues or []:
|
||||
if not issue_has_in_progress_label(issue):
|
||||
continue
|
||||
number = int(issue["number"])
|
||||
entry = classify_issue_claim(
|
||||
issue=issue,
|
||||
comments=comments_by_issue.get(number, []),
|
||||
open_prs=open_prs,
|
||||
branch_names=branch_names,
|
||||
now=now,
|
||||
heartbeat_lease_minutes=heartbeat_lease_minutes,
|
||||
reclaim_after_minutes=reclaim_after_minutes,
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for entry in entries:
|
||||
counts[entry["status"]] = counts.get(entry["status"], 0) + 1
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"counts": counts,
|
||||
"heartbeat_lease_minutes": heartbeat_lease_minutes,
|
||||
"reclaim_after_minutes": reclaim_after_minutes,
|
||||
"in_progress_total": len(entries),
|
||||
}
|
||||
|
||||
|
||||
def build_cleanup_plan(inventory: dict[str, Any]) -> list[dict]:
|
||||
"""Return stale/reclaimable/phantom claims that may be cleaned up."""
|
||||
plan: list[dict] = []
|
||||
for entry in inventory.get("entries") or []:
|
||||
if entry.get("status") in {"reclaimable", "phantom"}:
|
||||
plan.append(
|
||||
{
|
||||
"issue_number": entry["issue_number"],
|
||||
"status": entry["status"],
|
||||
"action": "remove_status_in_progress_and_comment",
|
||||
"reasons": list(entry.get("reasons") or []),
|
||||
}
|
||||
)
|
||||
elif entry.get("status") == "stale" and not entry.get("linked_open_pr"):
|
||||
plan.append(
|
||||
{
|
||||
"issue_number": entry["issue_number"],
|
||||
"status": entry["status"],
|
||||
"action": "report_only",
|
||||
"reasons": list(entry.get("reasons") or []),
|
||||
}
|
||||
)
|
||||
return plan
|
||||
@@ -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,269 @@
|
||||
"""Issue-lock provenance and external-state disclosure (#447).
|
||||
|
||||
Sanctioned locks are written only by ``gitea_lock_issue`` (or adoption recovery
|
||||
#442). Manual seeding of ``/tmp/gitea_issue_lock.json`` is unsafe and must be
|
||||
blocked at PR creation unless explicit operator override proof is recorded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
|
||||
|
||||
SOURCE_LOCK_ISSUE = "gitea_lock_issue"
|
||||
SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
|
||||
SOURCE_OPERATOR_OVERRIDE = "operator_override"
|
||||
|
||||
SANCTIONED_LOCK_SOURCES = frozenset({
|
||||
SOURCE_LOCK_ISSUE,
|
||||
SOURCE_LOCK_ADOPTION,
|
||||
SOURCE_OPERATOR_OVERRIDE,
|
||||
})
|
||||
|
||||
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
|
||||
|
||||
_ISSUE_LOCK_PATH_RE = re.compile(
|
||||
r"(?:/tmp/)?gitea_issue_lock\.json",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCK_SEED_RE = re.compile(
|
||||
r"(?:seed(?:ed|ing)?|restor(?:e|ed|ing)|wrote|written|write|programmatically|"
|
||||
r"hand[- ]forg|manual(?:ly)?).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_LOCK_REMOVE_RE = re.compile(
|
||||
r"(?:\brm\b|remove|deleted?|unlink).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_LOCK_READ_RE = re.compile(
|
||||
r"(?:read|loaded?|parsed?).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_EXTERNAL_NONE_RE = re.compile(
|
||||
r"external[- ]state mutations\s*:\s*none\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXTERNAL_FIELD_RE = re.compile(
|
||||
r"external[- ]state mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_ONLY_RE = re.compile(
|
||||
r"cleanup mutations\s*:\s*(?:none|lock removed|removed issue lock)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_CREATED_RE = re.compile(
|
||||
r"(?:\bgitea_create_pr\b|PR\s*#\s*\d+\s+created|created\s+PR\s*#|opened\s+PR\s*#|"
|
||||
r"PR\s+creation\s+(?:succeeded|complete))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEW_APPROVE_RE = re.compile(
|
||||
r"(?:submitted\s+(?:['\"]approve['\"]|approve\s+review)|"
|
||||
r"review decision\s*:\s*approve|approved\s+PR\s*#|gitea_review_pr.*approve)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OVERRIDE_PROOF_RE = re.compile(
|
||||
r"operator[- ]override\s+proof\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def build_sanctioned_lock_provenance(
|
||||
*,
|
||||
tool: str,
|
||||
source: str = SOURCE_LOCK_ISSUE,
|
||||
claimant: dict | None = None,
|
||||
adoption: dict | None = None,
|
||||
) -> dict:
|
||||
"""Return provenance metadata stored with a sanctioned lock write."""
|
||||
entry = {
|
||||
"source": source,
|
||||
"written_at": _utc_now_iso(),
|
||||
"written_by_tool": tool,
|
||||
"lock_file_path": ISSUE_LOCK_FILE,
|
||||
}
|
||||
if claimant:
|
||||
entry["claimant"] = claimant
|
||||
if adoption:
|
||||
entry["adoption"] = adoption
|
||||
return entry
|
||||
|
||||
|
||||
def operator_override_requested() -> bool:
|
||||
return os.environ.get(_OPERATOR_OVERRIDE_ENV, "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
|
||||
|
||||
def build_operator_override_provenance(*, reason: str, claimant: dict | None = None) -> dict:
|
||||
text = (reason or "").strip()
|
||||
if not text:
|
||||
raise ValueError(
|
||||
"operator override requires a non-empty override reason (fail closed)"
|
||||
)
|
||||
entry = build_sanctioned_lock_provenance(
|
||||
tool="operator_override",
|
||||
source=SOURCE_OPERATOR_OVERRIDE,
|
||||
claimant=claimant,
|
||||
)
|
||||
entry["override_reason"] = text
|
||||
return entry
|
||||
|
||||
|
||||
def assess_lock_file_for_create_pr(lock_data: dict | None) -> dict:
|
||||
"""Fail closed when lock file lacks sanctioned provenance (#447)."""
|
||||
data = lock_data if isinstance(lock_data, dict) else {}
|
||||
reasons: list[str] = []
|
||||
provenance = data.get("lock_provenance")
|
||||
if not isinstance(provenance, dict):
|
||||
reasons.append(
|
||||
"issue lock file lacks sanctioned lock_provenance; manual seeding is "
|
||||
"not a normal recovery path — call gitea_lock_issue or use #442 adoption"
|
||||
)
|
||||
return _provenance_result(False, reasons, provenance)
|
||||
|
||||
source = str(provenance.get("source") or "").strip()
|
||||
if source not in SANCTIONED_LOCK_SOURCES:
|
||||
reasons.append(
|
||||
f"issue lock provenance source '{source or '(missing)'}' is not sanctioned"
|
||||
)
|
||||
|
||||
if source == SOURCE_OPERATOR_OVERRIDE and not str(
|
||||
provenance.get("override_reason") or ""
|
||||
).strip():
|
||||
reasons.append(
|
||||
"operator_override lock provenance requires override_reason proof"
|
||||
)
|
||||
|
||||
if not data.get("work_lease"):
|
||||
reasons.append("issue lock file missing work_lease metadata")
|
||||
|
||||
if not str(provenance.get("written_by_tool") or "").strip():
|
||||
reasons.append("issue lock provenance missing written_by_tool")
|
||||
|
||||
proven = not reasons
|
||||
return _provenance_result(proven, reasons, provenance)
|
||||
|
||||
|
||||
def _provenance_result(proven: bool, reasons: list[str], provenance: dict | None) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"lock_provenance": provenance,
|
||||
}
|
||||
|
||||
|
||||
def format_lock_provenance_error(assessment: dict) -> str:
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown lock provenance violation"])
|
||||
return f"Issue lock provenance guard (#447): {reasons} (fail closed)"
|
||||
|
||||
|
||||
def _lock_activity_detected(text: str) -> dict[str, bool]:
|
||||
body = text or ""
|
||||
return {
|
||||
"seed_or_restore": bool(_LOCK_SEED_RE.search(body)),
|
||||
"remove": bool(_LOCK_REMOVE_RE.search(body)),
|
||||
"read": bool(_LOCK_READ_RE.search(body)),
|
||||
}
|
||||
|
||||
|
||||
def _external_state_discloses_lock(text: str) -> bool:
|
||||
match = _EXTERNAL_FIELD_RE.search(text or "")
|
||||
if not match:
|
||||
return False
|
||||
value = (match.group(1) or "").strip().lower()
|
||||
if value in {"", "none", "n/a"}:
|
||||
return False
|
||||
return "lock" in value or "gitea_issue_lock" in value or "issue-lock" in value
|
||||
|
||||
|
||||
def assess_issue_lock_external_state_report(report_text: str) -> dict:
|
||||
"""Require explicit external-state disclosure for issue-lock mutations (#447)."""
|
||||
text = report_text or ""
|
||||
activity = _lock_activity_detected(text)
|
||||
if not any(activity.values()):
|
||||
return {"proven": True, "block": False, "reasons": [], "activity": activity}
|
||||
|
||||
reasons: list[str] = []
|
||||
disclosed = _external_state_discloses_lock(text)
|
||||
|
||||
if activity["seed_or_restore"] and _EXTERNAL_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"report mentions seeding/restoring gitea_issue_lock.json but claims "
|
||||
"External-state mutations: none"
|
||||
)
|
||||
elif activity["seed_or_restore"] and not disclosed:
|
||||
reasons.append(
|
||||
"report mentions issue-lock file activity but External-state mutations "
|
||||
"does not disclose read/write of gitea_issue_lock.json"
|
||||
)
|
||||
|
||||
if activity["remove"]:
|
||||
if _EXTERNAL_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"report mentions removing gitea_issue_lock.json but claims "
|
||||
"External-state mutations: none"
|
||||
)
|
||||
elif not disclosed and _CLEANUP_ONLY_RE.search(text):
|
||||
reasons.append(
|
||||
"report removes issue lock but classifies it as cleanup only; "
|
||||
"record under External-state mutations"
|
||||
)
|
||||
elif not disclosed:
|
||||
reasons.append(
|
||||
"report mentions deleting issue lock without External-state "
|
||||
"mutation disclosure"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"activity": activity,
|
||||
}
|
||||
|
||||
|
||||
def assess_manual_lock_pr_without_override(report_text: str) -> dict:
|
||||
"""Block reports that created a PR via manual lock seed without override proof."""
|
||||
text = report_text or ""
|
||||
seeded = bool(_LOCK_SEED_RE.search(text))
|
||||
created = bool(_PR_CREATED_RE.search(text))
|
||||
if not (seeded and created):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
if _OVERRIDE_PROOF_RE.search(text):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report created/opened a PR after manual issue-lock seeding without "
|
||||
"operator override proof"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def assess_author_reviewer_same_run_report(report_text: str) -> dict:
|
||||
"""Reviewer handoff must not create and approve the same PR in one run (#447)."""
|
||||
text = report_text or ""
|
||||
if not (_PR_CREATED_RE.search(text) and _REVIEW_APPROVE_RE.search(text)):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report mixes author-side PR creation and reviewer approval in one "
|
||||
"final handoff; split author and reviewer sessions"
|
||||
],
|
||||
}
|
||||
@@ -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,180 @@
|
||||
"""Early duplicate-work detection for author work-issue sessions (#400)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import issue_claim_heartbeat as claim_hb
|
||||
|
||||
PHASE_LOCK = "lock_issue"
|
||||
PHASE_COMMIT = "commit"
|
||||
PHASE_PUSH = "push"
|
||||
PHASE_CREATE_PR = "create_pr"
|
||||
|
||||
OUTCOME_DUPLICATE_PR_PREVENTED = "duplicate_pr_prevented"
|
||||
OUTCOME_DUPLICATE_BRANCH_PREVENTED = "duplicate_branch_prevented"
|
||||
OUTCOME_DUPLICATE_COMMIT_PREVENTED = "duplicate_commit_prevented"
|
||||
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED = "duplicate_work_not_prevented"
|
||||
|
||||
_ACTIVE_CLAIM_STATUSES = frozenset({"active", "awaiting_review"})
|
||||
|
||||
|
||||
def _issue_pattern(issue_number: int) -> str:
|
||||
return f"issue-{int(issue_number)}"
|
||||
|
||||
|
||||
def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
||||
return claim_hb._linked_open_pr(issue_number, open_prs)
|
||||
|
||||
|
||||
def _matching_branches(
|
||||
issue_number: int,
|
||||
branch_names: list[str],
|
||||
*,
|
||||
locked_branch: str | None = None,
|
||||
) -> list[str]:
|
||||
pattern = _issue_pattern(issue_number)
|
||||
matches = [
|
||||
name for name in (branch_names or [])
|
||||
if pattern in (name or "").lower()
|
||||
]
|
||||
if locked_branch:
|
||||
locked = locked_branch.strip()
|
||||
matches = [name for name in matches if name != locked]
|
||||
return matches
|
||||
|
||||
|
||||
def assess_work_issue_duplicate_gate(
|
||||
issue_number: int,
|
||||
*,
|
||||
open_prs: list[dict] | None = None,
|
||||
branch_names: list[str] | None = None,
|
||||
claim_entry: dict | None = None,
|
||||
locked_branch: str | None = None,
|
||||
phase: str = PHASE_LOCK,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when duplicate work is already in flight for an issue."""
|
||||
reasons: list[str] = []
|
||||
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
||||
prs = list(open_prs or [])
|
||||
branches = list(branch_names or [])
|
||||
pattern = _issue_pattern(issue_number)
|
||||
|
||||
linked = _linked_open_pr(issue_number, prs)
|
||||
if linked:
|
||||
reasons.append(
|
||||
f"open PR #{linked.get('number')} already covers issue "
|
||||
f"#{issue_number} (fail closed)"
|
||||
)
|
||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||
|
||||
conflicting_branches = _matching_branches(
|
||||
issue_number, branches, locked_branch=locked_branch
|
||||
)
|
||||
if conflicting_branches:
|
||||
names = ", ".join(conflicting_branches[:5])
|
||||
reasons.append(
|
||||
f"remote branch(es) already match issue pattern '{pattern}': "
|
||||
f"{names} (fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
|
||||
entry = claim_entry or {}
|
||||
if entry.get("linked_open_pr") and not linked:
|
||||
reasons.append(
|
||||
f"claim inventory reports open PR #{entry['linked_open_pr']} "
|
||||
f"for issue #{issue_number} (fail closed)"
|
||||
)
|
||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||
|
||||
status = (entry.get("status") or "").strip().lower()
|
||||
if status in _ACTIVE_CLAIM_STATUSES and not linked:
|
||||
heartbeat = entry.get("latest_heartbeat") or {}
|
||||
claim_branch = (heartbeat.get("branch") or "").strip()
|
||||
if locked_branch and claim_branch and claim_branch != locked_branch:
|
||||
reasons.append(
|
||||
f"active claim lease on branch '{claim_branch}' blocks "
|
||||
f"work on '{locked_branch}' for issue #{issue_number} "
|
||||
"(fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
elif not locked_branch and status == "active":
|
||||
reasons.append(
|
||||
f"issue #{issue_number} has an active claim lease "
|
||||
"(fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
|
||||
if phase in {PHASE_COMMIT, PHASE_PUSH} and reasons:
|
||||
if outcome == OUTCOME_DUPLICATE_PR_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
|
||||
elif outcome == OUTCOME_DUPLICATE_BRANCH_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"performed": not block,
|
||||
"issue_number": issue_number,
|
||||
"phase": phase,
|
||||
"outcome": outcome,
|
||||
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
|
||||
"conflicting_branches": conflicting_branches,
|
||||
"claim_status": status or None,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"stop before mutating; preserve local work and produce a "
|
||||
"reconciliation handoff if a concurrent PR appeared after push"
|
||||
if block and phase == PHASE_CREATE_PR
|
||||
else "stop before mutating; do not commit or push duplicate work"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_work_issue_duplicate_report(report_text: str) -> dict[str, Any]:
|
||||
"""Require explicit duplicate-work outcome wording in work-issue reports."""
|
||||
text = (report_text or "").lower()
|
||||
markers = {
|
||||
OUTCOME_DUPLICATE_PR_PREVENTED: (
|
||||
"duplicate pr prevented",
|
||||
"duplicate_pr_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_BRANCH_PREVENTED: (
|
||||
"duplicate branch prevented",
|
||||
"duplicate_branch_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_COMMIT_PREVENTED: (
|
||||
"duplicate commit prevented",
|
||||
"duplicate_commit_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: (
|
||||
"duplicate work not prevented",
|
||||
"duplicate_work_not_prevented",
|
||||
"no duplicate work",
|
||||
),
|
||||
}
|
||||
matched = [
|
||||
key for key, phrases in markers.items()
|
||||
if any(phrase in text for phrase in phrases)
|
||||
]
|
||||
if len(matched) != 1:
|
||||
return {
|
||||
"complete": False,
|
||||
"downgraded": True,
|
||||
"reasons": [
|
||||
"work-issue report must state exactly one duplicate-work "
|
||||
"outcome (duplicate PR/branch/commit prevented, or "
|
||||
"duplicate work not prevented)"
|
||||
],
|
||||
}
|
||||
return {
|
||||
"complete": True,
|
||||
"downgraded": False,
|
||||
"outcome": matched[0],
|
||||
"reasons": [],
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
+10
-14
@@ -13,9 +13,9 @@ 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)
|
||||
|
||||
|
||||
@@ -37,22 +37,18 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
|
||||
|
||||
|
||||
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]:
|
||||
|
||||
@@ -31,6 +31,8 @@ REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"]
|
||||
def infer_role(name, execution_profile):
|
||||
"""Return the unambiguous role for a legacy profile name, or None."""
|
||||
haystack = f"{name} {execution_profile or ''}".lower()
|
||||
if "reconciler" in haystack:
|
||||
return "reconciler"
|
||||
has_author = "author" in haystack
|
||||
has_reviewer = "reviewer" in haystack
|
||||
if has_author == has_reviewer:
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Native MCP preference gate and shell health circuit breaker (#270).
|
||||
|
||||
Gitea mutations must use native MCP tools first. Shell scripts, direct API
|
||||
calls, browser helpers, and improvised encoders are blocked when MCP is
|
||||
available unless explicit recovery-mode proof is supplied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES
|
||||
|
||||
# Tasks that must prefer native MCP over shell/API/helper fallbacks.
|
||||
GITEA_MUTATION_TASKS = frozenset({
|
||||
"comment_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"set_issue_labels",
|
||||
"create_issue",
|
||||
"close_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"close_pr",
|
||||
"comment_pr",
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"delete_branch",
|
||||
"address_pr_change_requests",
|
||||
})
|
||||
|
||||
ALLOWED_PATH_KINDS = frozenset({
|
||||
"mcp_native",
|
||||
"recovery_fallback",
|
||||
})
|
||||
|
||||
BLOCKED_PATH_KINDS = frozenset({
|
||||
"shell_script",
|
||||
"direct_api",
|
||||
"webfetch",
|
||||
"playwright",
|
||||
"helper_script",
|
||||
"unsafe_helper",
|
||||
"mcp_server_touch",
|
||||
})
|
||||
|
||||
SHELL_SPAWN_FAILURE_THRESHOLD = 2
|
||||
|
||||
TERMINAL_REPORT_HEADING = (
|
||||
"MCP transport unavailable or shell circuit breaker tripped. "
|
||||
"No unsafe Gitea fallback performed."
|
||||
)
|
||||
|
||||
_RECOVERY_MODE_RE = re.compile(
|
||||
r"\b(?:recovery mode|explicit recovery|mcp unavailable|mcp not available|"
|
||||
r"mcp tools unavailable|no mcp path)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCAL_SCRIPT_RE = re.compile(
|
||||
r"(?:^|[\s\"'`/])(?:python3?|bash|sh)?\s*(?:"
|
||||
+ "|".join(re.escape(name) for name in LOCAL_GITEA_SCRIPT_NAMES)
|
||||
+ r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WEBFETCH_RE = re.compile(r"\b(?:webfetch|mcp_web_fetch|fetch\s+url)\b", re.IGNORECASE)
|
||||
_PLAYWRIGHT_RE = re.compile(r"\b(?:playwright|browser_navigate|browser_click)\b", re.IGNORECASE)
|
||||
_HELPER_SCRIPT_RE = re.compile(
|
||||
r"(?:^|[\s\"'`/])(?:_encode_|_emit_|_inline_)[\w-]+\.py",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MANUAL_BASE64_RE = re.compile(
|
||||
r"\b(?:manual\s+base64|llm-generated\s+base64|base64-encode\s+in\s+chat)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DIRECT_API_RE = re.compile(
|
||||
r"\b(?:api_request|urllib\.request|requests\.(?:post|patch|put)|curl\s+-X\s+POST)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MCP_SERVER_TOUCH_RE = re.compile(
|
||||
r"\b(?:kill|pkill|restart|reload|touch|edit|modify|write)\b.{0,40}\b(?:mcp_server|mcp-server|gitea_mcp_server)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLI_AUTH_DIVERGENCE_RE = re.compile(
|
||||
r"GITEA_MCP_PROFILE\s*=\s*['\"]?([^\s'\";]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_session_shell: dict[str, Any] = {
|
||||
"consecutive_spawn_failures": 0,
|
||||
"shell_unavailable": False,
|
||||
"hard_stopped": False,
|
||||
"last_exit_code": None,
|
||||
}
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return (value or "").strip()
|
||||
|
||||
|
||||
def is_spawn_failure(
|
||||
*,
|
||||
exit_code: int | None = None,
|
||||
stdout: str | None = None,
|
||||
stderr: str | None = None,
|
||||
spawn_failure: bool | None = None,
|
||||
) -> bool:
|
||||
"""True for executor spawn failures (exit_code -1, empty output)."""
|
||||
if spawn_failure is True:
|
||||
return True
|
||||
if spawn_failure is False:
|
||||
return False
|
||||
if exit_code != -1:
|
||||
return False
|
||||
return not (_clean(stdout) or _clean(stderr))
|
||||
|
||||
|
||||
def record_shell_spawn_outcome(
|
||||
*,
|
||||
exit_code: int | None = None,
|
||||
stdout: str | None = None,
|
||||
stderr: str | None = None,
|
||||
spawn_failure: bool | None = None,
|
||||
probe_attempted: bool = False,
|
||||
probe_succeeded: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Track shell spawn outcomes and trip the session circuit breaker (#270 AC4)."""
|
||||
global _session_shell
|
||||
failed = is_spawn_failure(
|
||||
exit_code=exit_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
spawn_failure=spawn_failure,
|
||||
)
|
||||
|
||||
if failed:
|
||||
_session_shell["consecutive_spawn_failures"] = (
|
||||
int(_session_shell.get("consecutive_spawn_failures") or 0) + 1
|
||||
)
|
||||
_session_shell["last_exit_code"] = exit_code
|
||||
if probe_attempted and probe_succeeded is False:
|
||||
_session_shell["shell_unavailable"] = True
|
||||
else:
|
||||
_session_shell["consecutive_spawn_failures"] = 0
|
||||
_session_shell["shell_unavailable"] = False
|
||||
_session_shell["hard_stopped"] = False
|
||||
_session_shell["last_exit_code"] = exit_code
|
||||
|
||||
failures = int(_session_shell["consecutive_spawn_failures"])
|
||||
if failures >= SHELL_SPAWN_FAILURE_THRESHOLD:
|
||||
_session_shell["shell_unavailable"] = True
|
||||
_session_shell["hard_stopped"] = True
|
||||
|
||||
return shell_health_status()
|
||||
|
||||
|
||||
def shell_health_status() -> dict[str, Any]:
|
||||
"""Return the current shell health / circuit-breaker state."""
|
||||
failures = int(_session_shell.get("consecutive_spawn_failures") or 0)
|
||||
hard_stopped = bool(_session_shell.get("hard_stopped"))
|
||||
shell_unavailable = bool(_session_shell.get("shell_unavailable"))
|
||||
return {
|
||||
"consecutive_spawn_failures": failures,
|
||||
"shell_unavailable": shell_unavailable,
|
||||
"hard_stopped": hard_stopped,
|
||||
"threshold": SHELL_SPAWN_FAILURE_THRESHOLD,
|
||||
"shell_use_allowed": not hard_stopped,
|
||||
"last_exit_code": _session_shell.get("last_exit_code"),
|
||||
"safe_next_action": (
|
||||
"emit terminal recovery report; prefer native MCP for remaining Gitea mutations"
|
||||
if hard_stopped
|
||||
else (
|
||||
"probe shell once with echo/pwd; if probe fails mark shell unavailable"
|
||||
if failures == 1
|
||||
else "shell healthy"
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def clear_shell_health_for_tests() -> None:
|
||||
"""Reset session shell state (tests only)."""
|
||||
global _session_shell
|
||||
_session_shell = {
|
||||
"consecutive_spawn_failures": 0,
|
||||
"shell_unavailable": False,
|
||||
"hard_stopped": False,
|
||||
"last_exit_code": None,
|
||||
}
|
||||
|
||||
|
||||
def classify_command_path(command_or_detail: str | None) -> str:
|
||||
"""Classify a proposed command/detail into a path kind."""
|
||||
text = command_or_detail or ""
|
||||
if _MCP_SERVER_TOUCH_RE.search(text):
|
||||
return "mcp_server_touch"
|
||||
if _WEBFETCH_RE.search(text):
|
||||
return "webfetch"
|
||||
if _PLAYWRIGHT_RE.search(text):
|
||||
return "playwright"
|
||||
if _HELPER_SCRIPT_RE.search(text) or _MANUAL_BASE64_RE.search(text):
|
||||
return "unsafe_helper"
|
||||
if _LOCAL_SCRIPT_RE.search(text):
|
||||
return "shell_script"
|
||||
if _DIRECT_API_RE.search(text):
|
||||
return "direct_api"
|
||||
return "mcp_native"
|
||||
|
||||
|
||||
def detect_cli_auth_divergence(
|
||||
command_or_detail: str | None,
|
||||
*,
|
||||
active_profile: str | None,
|
||||
) -> list[str]:
|
||||
"""Flag shell commands that override GITEA_MCP_PROFILE away from the session."""
|
||||
reasons: list[str] = []
|
||||
text = command_or_detail or ""
|
||||
active = _clean(active_profile)
|
||||
match = _CLI_AUTH_DIVERGENCE_RE.search(text)
|
||||
if not match or not active:
|
||||
return reasons
|
||||
requested = _clean(match.group(1))
|
||||
if requested and requested != active:
|
||||
reasons.append(
|
||||
f"CLI auth divergence: command sets GITEA_MCP_PROFILE='{requested}' "
|
||||
f"but active session profile is '{active}' (fail closed)"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def format_mcp_unavailable_terminal_report(
|
||||
*,
|
||||
task: str | None = None,
|
||||
reasons: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Terminal report when MCP is broken and unsafe recovery is forbidden (#270 AC3)."""
|
||||
lines = [TERMINAL_REPORT_HEADING, ""]
|
||||
if task:
|
||||
lines.append(f"Blocked task: {task}")
|
||||
if reasons:
|
||||
lines.extend(["", "Reasons:"])
|
||||
lines.extend(f"- {reason}" for reason in reasons)
|
||||
lines.extend([
|
||||
"",
|
||||
"Required recovery (no improvised fallback):",
|
||||
"- Restart the MCP session",
|
||||
"- Kill hung background terminals holding the shell executor",
|
||||
"- Retry the native MCP tool once after reconnect",
|
||||
"- If MCP remains unavailable, stop and hand off to the operator",
|
||||
"- Do not run local Gitea scripts, WebFetch, Playwright, or manual base64 encoders",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def assess_gitea_operation_path(
|
||||
*,
|
||||
task: str,
|
||||
path_kind: str | None = None,
|
||||
command_or_detail: str | None = None,
|
||||
mcp_available: bool = True,
|
||||
mcp_tool_visible: bool = True,
|
||||
recovery_mode: bool = False,
|
||||
recovery_proof_complete: bool = False,
|
||||
role: str | None = None,
|
||||
active_profile: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when a Gitea mutation would bypass native MCP (#270)."""
|
||||
task_name = _clean(task)
|
||||
resolved_kind = _clean(path_kind) or classify_command_path(command_or_detail)
|
||||
reasons: list[str] = []
|
||||
|
||||
if task_name and task_name not in GITEA_MUTATION_TASKS:
|
||||
reasons.append(
|
||||
f"unknown or non-mutation Gitea task '{task_name}'; "
|
||||
"native MCP preference gate applies only to Gitea mutations"
|
||||
)
|
||||
|
||||
shell = shell_health_status()
|
||||
if shell["hard_stopped"] and resolved_kind != "mcp_native":
|
||||
reasons.append(
|
||||
"shell circuit breaker is hard-stopped after consecutive spawn failures; "
|
||||
"use native MCP or emit terminal recovery report"
|
||||
)
|
||||
|
||||
recovery_declared = recovery_mode or bool(
|
||||
_RECOVERY_MODE_RE.search(command_or_detail or "")
|
||||
)
|
||||
recovery_allowed = (
|
||||
recovery_declared
|
||||
and recovery_proof_complete
|
||||
and not mcp_available
|
||||
and resolved_kind in BLOCKED_PATH_KINDS - {"mcp_server_touch"}
|
||||
)
|
||||
|
||||
if resolved_kind in BLOCKED_PATH_KINDS and not recovery_allowed:
|
||||
reasons.append(f"path kind '{resolved_kind}' is not an approved Gitea mutation path")
|
||||
|
||||
if resolved_kind == "mcp_server_touch":
|
||||
if _clean(role) == "reviewer" or (
|
||||
active_profile and "reviewer" in active_profile.lower()
|
||||
):
|
||||
reasons.append(
|
||||
"reviewer workflows must not touch, restart, or kill MCP server files/processes"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
"MCP server files/processes must not be modified during normal Gitea workflows"
|
||||
)
|
||||
|
||||
reasons.extend(
|
||||
detect_cli_auth_divergence(command_or_detail, active_profile=active_profile)
|
||||
)
|
||||
|
||||
if (
|
||||
mcp_available
|
||||
and mcp_tool_visible
|
||||
and resolved_kind != "mcp_native"
|
||||
and not recovery_allowed
|
||||
):
|
||||
if not recovery_declared:
|
||||
reasons.append(
|
||||
"native MCP tools are available; shell/API/helper fallback is forbidden"
|
||||
)
|
||||
elif not recovery_proof_complete:
|
||||
reasons.append(
|
||||
"recovery-mode fallback requires complete recovery proof before proceeding"
|
||||
)
|
||||
|
||||
if not mcp_available and resolved_kind != "mcp_native" and not recovery_allowed:
|
||||
if not recovery_declared:
|
||||
reasons.append(
|
||||
"MCP transport unavailable; produce terminal recovery report instead of "
|
||||
"improvising unsafe fallback"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
terminal_report = None
|
||||
if block and (not mcp_available or shell["hard_stopped"]):
|
||||
terminal_report = format_mcp_unavailable_terminal_report(
|
||||
task=task_name or None,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
return {
|
||||
"task": task_name or None,
|
||||
"path_kind": resolved_kind,
|
||||
"mcp_available": mcp_available,
|
||||
"mcp_tool_visible": mcp_tool_visible,
|
||||
"recovery_mode": recovery_declared,
|
||||
"shell_health": shell,
|
||||
"block": block,
|
||||
"allowed": not block,
|
||||
"reasons": reasons,
|
||||
"terminal_report": terminal_report,
|
||||
"safe_next_action": (
|
||||
"use the native MCP tool for this task"
|
||||
if mcp_available and mcp_tool_visible and block
|
||||
else (
|
||||
terminal_report or "proceed with native MCP"
|
||||
if block
|
||||
else "proceed with native MCP"
|
||||
)
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"""PR-only queue cleanup mode gates and report verifier (#390).
|
||||
|
||||
Cleanup mode dispatches exactly one canonical review run per PR. It forbids
|
||||
author-side mutations (issue claiming, branch creation, implementation edits,
|
||||
issue filing) and enforces the terminal-mutation chain: stop after
|
||||
``REQUEST_CHANGES``; after ``APPROVED`` continue only to same-PR merge when
|
||||
merge is explicitly authorized for that PR and merge gates pass; stop after
|
||||
merge or a merge blocker. The next PR always requires a fresh run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
CLEANUP_WORKFLOW_PATH = "workflows/pr-queue-cleanup.md"
|
||||
|
||||
# Author-side resolver tasks that must never run inside cleanup mode.
|
||||
CLEANUP_FORBIDDEN_TASKS = frozenset({
|
||||
"claim_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"create_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
})
|
||||
|
||||
# Run-state outcomes for one cleanup dispatch.
|
||||
STOP_AFTER_REQUEST_CHANGES = "STOP_AFTER_REQUEST_CHANGES"
|
||||
STOP_AFTER_DECISION = "STOP_AFTER_DECISION"
|
||||
CONTINUE_TO_SAME_PR_MERGE = "CONTINUE_TO_SAME_PR_MERGE"
|
||||
STOP_APPROVED_NO_MERGE_AUTH = "STOP_APPROVED_NO_MERGE_AUTH"
|
||||
STOP_APPROVED_MERGE_GATES_FAILED = "STOP_APPROVED_MERGE_GATES_FAILED"
|
||||
STOP_AFTER_MERGE = "STOP_AFTER_MERGE"
|
||||
STOP_AFTER_MERGE_BLOCKER = "STOP_AFTER_MERGE_BLOCKER"
|
||||
STOP_GATE_NOT_PROVEN = "STOP_GATE_NOT_PROVEN"
|
||||
|
||||
_TERMINAL_DECISIONS = frozenset({"approved", "request_changes", "comment", "skip"})
|
||||
|
||||
|
||||
def resolve_cleanup_run_state(
|
||||
decision: str | None,
|
||||
*,
|
||||
merge_authorized_for_pr: bool = False,
|
||||
merge_gates_passed: bool | None = None,
|
||||
merge_completed: bool = False,
|
||||
merge_blocker: bool = False,
|
||||
) -> dict:
|
||||
"""Resolve what one cleanup run may do after its terminal review decision.
|
||||
|
||||
Fail closed: unknown decisions stop the run with no further mutation.
|
||||
"""
|
||||
normalized = (decision or "").strip().lower()
|
||||
|
||||
if normalized not in _TERMINAL_DECISIONS:
|
||||
return {
|
||||
"outcome": STOP_GATE_NOT_PROVEN,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
f"unknown terminal decision {decision!r}; cleanup run stops "
|
||||
"(fail closed)"
|
||||
],
|
||||
}
|
||||
|
||||
if normalized == "request_changes":
|
||||
return {
|
||||
"outcome": STOP_AFTER_REQUEST_CHANGES,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["REQUEST_CHANGES is terminal in cleanup mode"],
|
||||
}
|
||||
|
||||
if normalized in {"comment", "skip"}:
|
||||
return {
|
||||
"outcome": STOP_AFTER_DECISION,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [f"{normalized} decision ends the cleanup run"],
|
||||
}
|
||||
|
||||
# normalized == "approved"
|
||||
if merge_completed:
|
||||
return {
|
||||
"outcome": STOP_AFTER_MERGE,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["merge completed; cleanup run stops"],
|
||||
}
|
||||
if merge_blocker:
|
||||
return {
|
||||
"outcome": STOP_AFTER_MERGE_BLOCKER,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["merge blocker recorded; cleanup run stops"],
|
||||
}
|
||||
if not merge_authorized_for_pr:
|
||||
return {
|
||||
"outcome": STOP_APPROVED_NO_MERGE_AUTH,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
"APPROVED without explicit per-PR merge authorization; "
|
||||
"cleanup run stops"
|
||||
],
|
||||
}
|
||||
if merge_gates_passed is not True:
|
||||
return {
|
||||
"outcome": STOP_APPROVED_MERGE_GATES_FAILED,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
"merge gates not proven passed; cleanup run stops before merge"
|
||||
],
|
||||
}
|
||||
return {
|
||||
"outcome": CONTINUE_TO_SAME_PR_MERGE,
|
||||
"further_mutation_allowed": True,
|
||||
"reasons": [],
|
||||
"allowed_mutation": "merge same PR only",
|
||||
}
|
||||
|
||||
|
||||
def check_cleanup_task_allowed(task: str) -> tuple[bool, list[str]]:
|
||||
"""Fail closed on any author-side mutation task inside cleanup mode."""
|
||||
normalized = (task or "").strip().lower()
|
||||
if normalized in CLEANUP_FORBIDDEN_TASKS:
|
||||
return False, [
|
||||
f"task '{normalized}' is forbidden in PR-only cleanup mode: "
|
||||
"no issue claiming, branch creation, implementation edits, or "
|
||||
"issue filing"
|
||||
]
|
||||
return True, []
|
||||
|
||||
|
||||
_SELECTED_PR_RE = re.compile(
|
||||
r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_NEXT_SUGGESTED_RE = re.compile(
|
||||
r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_TERMINAL_MUTATION_RE = re.compile(
|
||||
r"^\s*[-*]?\s*(?:review (?:decision|verdict)|terminal (?:review )?"
|
||||
r"(?:decision|mutation))\s*:\s*"
|
||||
r"(approved|request_changes|request changes|merged|comment)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_PAGINATION_HINT_RE = re.compile(
|
||||
r"inventory_complete|final page|has_more\s*[=:]\s*false|total[_ ]count",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge result\s*:\s*(?!none\b|not attempted\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_MERGE_AUTHORIZED_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge authorized(?: for pr)?\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_ISSUE_MUTATION_CLAIM_RE = re.compile(
|
||||
r"^\s*[-*]?\s*issue mutations\s*:\s*(?!none\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BRANCH_MUTATION_CLAIM_RE = re.compile(
|
||||
r"^\s*[-*]?\s*branch mutations\s*:\s*(?!none\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def assess_pr_queue_cleanup_report(report_text: str) -> dict:
|
||||
"""Validate a PR-only cleanup run report (single dispatch, fail closed)."""
|
||||
reasons: list[str] = []
|
||||
text = report_text or ""
|
||||
|
||||
if CLEANUP_WORKFLOW_PATH not in text:
|
||||
reasons.append(
|
||||
f"report must cite the canonical cleanup workflow "
|
||||
f"({CLEANUP_WORKFLOW_PATH})"
|
||||
)
|
||||
|
||||
selected = _SELECTED_PR_RE.findall(text)
|
||||
if len(selected) == 0:
|
||||
reasons.append("report must name exactly one Selected PR")
|
||||
elif len(set(selected)) > 1:
|
||||
reasons.append(
|
||||
"cleanup run selected multiple PRs "
|
||||
f"({', '.join(sorted(set(selected)))}); one canonical review per "
|
||||
"PR per run"
|
||||
)
|
||||
|
||||
terminal = _TERMINAL_MUTATION_RE.findall(text)
|
||||
if len(terminal) > 1:
|
||||
reasons.append(
|
||||
"multiple terminal review mutations reported in one cleanup run"
|
||||
)
|
||||
|
||||
if not _PAGINATION_HINT_RE.search(text):
|
||||
reasons.append(
|
||||
"report missing PR inventory pagination proof "
|
||||
"(inventory_complete / final page / total_count)"
|
||||
)
|
||||
|
||||
if not _NEXT_SUGGESTED_RE.search(text):
|
||||
reasons.append(
|
||||
"report must include 'Next suggested PR' (without continuing to it)"
|
||||
)
|
||||
|
||||
if _MERGE_RESULT_RE.search(text) and not _MERGE_AUTHORIZED_RE.search(text):
|
||||
reasons.append(
|
||||
"merge reported without explicit per-PR merge authorization "
|
||||
"('Merge authorized: true')"
|
||||
)
|
||||
|
||||
if _ISSUE_MUTATION_CLAIM_RE.search(text):
|
||||
reasons.append("issue mutations are forbidden in PR-only cleanup mode")
|
||||
if _BRANCH_MUTATION_CLAIM_RE.search(text):
|
||||
reasons.append("branch mutations are forbidden in PR-only cleanup mode")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"proceed" if proven else
|
||||
"fix the cleanup report: one PR, one terminal mutation, pagination "
|
||||
"proof, next-suggested-PR field, and no issue/branch mutations"
|
||||
),
|
||||
}
|
||||
@@ -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,66 @@
|
||||
"""Capability preflight lifetime contract (#470).
|
||||
|
||||
Defines which read-only MCP tools preserve an existing capability proof and the
|
||||
canonical sequencing operators must follow before mutations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Read-only tools that must not invalidate capability proof (#470).
|
||||
# gitea_whoami is read-only but re-pins identity; capability is preserved when
|
||||
# identity was already verified clean (#469).
|
||||
READ_ONLY_PREFLIGHT_TOOLS = frozenset({
|
||||
"gitea_whoami",
|
||||
"gitea_get_authenticated_user",
|
||||
"gitea_get_current_user",
|
||||
"gitea_view_pr",
|
||||
"gitea_view_issue",
|
||||
"gitea_list_prs",
|
||||
"gitea_list_issues",
|
||||
"gitea_list_issue_comments",
|
||||
"gitea_get_runtime_context",
|
||||
"gitea_resolve_task_capability",
|
||||
"gitea_check_pr_eligibility",
|
||||
"gitea_get_pr_review_feedback",
|
||||
"gitea_assess_work_issue_duplicate",
|
||||
"gitea_route_task_session",
|
||||
"gitea_audit_config",
|
||||
"gitea_list_labels",
|
||||
"gitea_get_profile",
|
||||
"gitea_list_profiles",
|
||||
})
|
||||
|
||||
PREFLIGHT_CONTRACT_SUMMARY = (
|
||||
"Capability preflight is session-scoped per MCP process, profile, and task. "
|
||||
"After gitea_resolve_task_capability(task=...), interleaved read-only calls "
|
||||
"(whoami, view_*, list_*, get_runtime_context, eligibility checks) preserve "
|
||||
"the proof until a gated mutation consumes it. Each mutation consumes the proof "
|
||||
"once; re-resolve immediately before the next mutation. A new resolve for a "
|
||||
"different task replaces the prior task binding. Profile/session changes or "
|
||||
"workspace edits before resolve invalidate proof."
|
||||
)
|
||||
|
||||
|
||||
def format_missing_capability_error(task: str | None = None) -> str:
|
||||
base = (
|
||||
"Pre-flight order violation: Task capability "
|
||||
"(gitea_resolve_task_capability) has not been resolved (fail closed)"
|
||||
)
|
||||
if task:
|
||||
return (
|
||||
f"{base}. Re-run gitea_resolve_task_capability(task=\"{task}\") "
|
||||
"immediately before this mutation."
|
||||
)
|
||||
return (
|
||||
f"{base}. Re-run gitea_resolve_task_capability for the mutation task "
|
||||
"immediately before acting."
|
||||
)
|
||||
|
||||
|
||||
def format_task_mismatch_error(resolved: str, required: str) -> str:
|
||||
return (
|
||||
"Pre-flight task mismatch: "
|
||||
f"resolved '{resolved}' but mutation requires '{required}' (fail closed). "
|
||||
f"Re-run gitea_resolve_task_capability(task=\"{required}\") "
|
||||
"immediately before this mutation."
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Reconciler profile model for already-landed PR closure (#304).
|
||||
|
||||
Defines the narrowly scoped operation set for a dedicated reconciler profile
|
||||
such as ``prgs-reconciler``. Close MCP tooling and ancestry gates ship in the
|
||||
#310 stack; this module validates profile shape only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gitea_config
|
||||
|
||||
RECONCILER_REQUIRED_OPERATIONS = (
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
)
|
||||
|
||||
RECONCILER_RECOMMENDED_OPERATIONS = (
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
)
|
||||
|
||||
RECONCILER_FORBIDDEN_OPERATIONS = (
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
)
|
||||
|
||||
|
||||
def _normalized_allowed(allowed: list[str]) -> set[str]:
|
||||
normalized: set[str] = set()
|
||||
for entry in allowed or []:
|
||||
try:
|
||||
normalized.add(gitea_config.normalize_operation(entry))
|
||||
except gitea_config.ConfigError:
|
||||
continue
|
||||
return normalized
|
||||
|
||||
|
||||
def _forbidden_in_allowed(allowed: list[str], ops: tuple[str, ...]) -> list[str]:
|
||||
"""Return forbidden ops that are explicitly listed in *allowed*."""
|
||||
allowed_n = _normalized_allowed(allowed)
|
||||
present: list[str] = []
|
||||
for op in ops:
|
||||
try:
|
||||
if gitea_config.normalize_operation(op) in allowed_n:
|
||||
present.append(op)
|
||||
except gitea_config.ConfigError:
|
||||
continue
|
||||
return present
|
||||
|
||||
|
||||
def is_reconciler_profile(allowed: list[str], forbidden: list[str]) -> bool:
|
||||
"""Return True when *allowed*/*forbidden* describe a reconciler profile."""
|
||||
def can(op: str) -> bool:
|
||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||
|
||||
if not can("gitea.pr.close"):
|
||||
return False
|
||||
if _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def assess_reconciler_profile(allowed: list[str], forbidden: list[str]) -> dict:
|
||||
"""Validate reconciler profile operations (read-only, fail closed)."""
|
||||
allowed = list(allowed or [])
|
||||
forbidden = list(forbidden or [])
|
||||
reasons: list[str] = []
|
||||
|
||||
for op in RECONCILER_REQUIRED_OPERATIONS:
|
||||
ok, _ = gitea_config.check_operation(op, allowed, forbidden)
|
||||
if not ok:
|
||||
reasons.append(f"missing required operation {op}")
|
||||
|
||||
for op in _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS):
|
||||
reasons.append(f"forbidden operation must not be allowed: {op}")
|
||||
|
||||
missing_recommended = [
|
||||
op for op in RECONCILER_RECOMMENDED_OPERATIONS
|
||||
if not gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||
]
|
||||
|
||||
return {
|
||||
"is_reconciler_profile": is_reconciler_profile(allowed, forbidden),
|
||||
"valid": not reasons and is_reconciler_profile(allowed, forbidden),
|
||||
"reasons": reasons,
|
||||
"missing_recommended_operations": missing_recommended,
|
||||
"required_operations": list(RECONCILER_REQUIRED_OPERATIONS),
|
||||
"forbidden_operations": list(RECONCILER_FORBIDDEN_OPERATIONS),
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Already-landed PR reconciliation workflow helpers (#301).
|
||||
|
||||
Read-only assessment and capability planning for reconciling open PRs whose
|
||||
heads are already ancestors of the target branch. Does not invoke review or
|
||||
merge paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
import gitea_config
|
||||
from merged_cleanup_reconcile import extract_linked_issue, is_head_ancestor_of_ref
|
||||
|
||||
ELIGIBILITY_ALREADY_LANDED = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
ELIGIBILITY_NOT_LANDED = "NOT_ALREADY_LANDED"
|
||||
ELIGIBILITY_STALE_TARGET = "TARGET_BRANCH_UNVERIFIED"
|
||||
ELIGIBILITY_PR_NOT_OPEN = "PR_NOT_OPEN"
|
||||
|
||||
OUTCOME_FULL_RECONCILE = "FULL_RECONCILE_CLOSE_ALLOWED"
|
||||
OUTCOME_PARTIAL_COMMENT = "PARTIAL_RECONCILE_COMMENT_THEN_STOP"
|
||||
OUTCOME_RECOVERY_HANDOFF = "RECOVERY_HANDOFF_ONLY"
|
||||
OUTCOME_NOT_LANDED = "NOT_LANDED_NO_ACTION"
|
||||
OUTCOME_GATE_NOT_PROVEN = "GATE_NOT_PROVEN"
|
||||
|
||||
RECONCILE_WORKFLOW_MARKERS = (
|
||||
"workflows/reconcile-landed-pr.md",
|
||||
"reconcile-landed-pr.md",
|
||||
)
|
||||
|
||||
RECONCILE_TASK_MARKERS = (
|
||||
"reconcile-landed-pr",
|
||||
"reconcile already-landed",
|
||||
"reconcile_already_landed",
|
||||
)
|
||||
|
||||
|
||||
def fetch_target_branch(project_root: str, remote: str, branch: str) -> dict[str, Any]:
|
||||
"""Fetch *branch* from *remote* and return the resolved SHA."""
|
||||
ref = f"{remote}/{branch}"
|
||||
fetch = subprocess.run(
|
||||
["git", "-C", project_root, "fetch", remote, branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git fetch {remote} {branch} failed: "
|
||||
f"{(fetch.stderr or fetch.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
rev = subprocess.run(
|
||||
["git", "-C", project_root, "rev-parse", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if rev.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git rev-parse {ref} failed: "
|
||||
f"{(rev.stderr or rev.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": (rev.stdout or "").strip(),
|
||||
"reasons": [],
|
||||
"git_fetch_command": f"git fetch {remote} {branch}",
|
||||
}
|
||||
|
||||
|
||||
def profile_reconciliation_capabilities(
|
||||
allowed: list[str] | None,
|
||||
forbidden: list[str] | None,
|
||||
) -> dict[str, bool]:
|
||||
"""Map active profile operations to reconciliation capabilities."""
|
||||
allowed = allowed or []
|
||||
forbidden = forbidden or []
|
||||
|
||||
def can(op: str) -> bool:
|
||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||
|
||||
return {
|
||||
"read": can("gitea.read"),
|
||||
"comment_pr": can("gitea.pr.comment"),
|
||||
"comment_issue": can("gitea.issue.comment"),
|
||||
"close_pr": can("gitea.pr.close"),
|
||||
"close_issue": can("gitea.issue.close"),
|
||||
"review_pr": can("gitea.pr.review") or can("gitea.pr.approve"),
|
||||
"merge_pr": can("gitea.pr.merge"),
|
||||
}
|
||||
|
||||
|
||||
def assess_open_pr_reconciliation(
|
||||
*,
|
||||
pr: dict[str, Any],
|
||||
project_root: str,
|
||||
remote: str,
|
||||
target_branch: str,
|
||||
target_fetch: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether an open PR is eligible for already-landed reconciliation."""
|
||||
pr_number = int(pr.get("number") or 0)
|
||||
pr_state = (pr.get("state") or "").strip().lower()
|
||||
head = pr.get("head") or {}
|
||||
head_sha = head.get("sha") if isinstance(head, dict) else None
|
||||
head_ref = head.get("ref") if isinstance(head, dict) else None
|
||||
base = pr.get("base") or {}
|
||||
base_ref = base.get("ref") if isinstance(base, dict) else None
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
|
||||
fetch_result = target_fetch or fetch_target_branch(
|
||||
project_root, remote, target_branch
|
||||
)
|
||||
linked_issue = extract_linked_issue(title, body)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pr_number": pr_number,
|
||||
"pr_state": pr_state,
|
||||
"candidate_head_sha": head_sha,
|
||||
"head_ref": head_ref,
|
||||
"base_ref": base_ref or target_branch,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": fetch_result.get("target_branch_sha"),
|
||||
"linked_issue": linked_issue,
|
||||
"git_ref_mutations": [],
|
||||
"reasons": [],
|
||||
"review_merge_allowed": False,
|
||||
}
|
||||
if fetch_result.get("git_fetch_command"):
|
||||
result["git_ref_mutations"].append(fetch_result["git_fetch_command"])
|
||||
|
||||
if pr_state != "open":
|
||||
result["eligibility_class"] = ELIGIBILITY_PR_NOT_OPEN
|
||||
result["ancestor_proof"] = None
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].append(f"PR #{pr_number} state is {pr_state!r}, not open")
|
||||
return result
|
||||
|
||||
if not fetch_result.get("success"):
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["ancestor_proof"] = None
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].extend(fetch_result.get("reasons") or [])
|
||||
return result
|
||||
|
||||
target_ref = fetch_result.get("target_ref") or f"{remote}/{target_branch}"
|
||||
ancestor = is_head_ancestor_of_ref(project_root, head_sha, target_ref)
|
||||
result["ancestor_proof"] = ancestor
|
||||
|
||||
if ancestor is None:
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"ancestor check failed for head {head_sha!r} against {target_ref}"
|
||||
)
|
||||
return result
|
||||
|
||||
if ancestor:
|
||||
result["eligibility_class"] = ELIGIBILITY_ALREADY_LANDED
|
||||
result["reconciliation_allowed"] = True
|
||||
return result
|
||||
|
||||
result["eligibility_class"] = ELIGIBILITY_NOT_LANDED
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"PR head {head_sha} is not an ancestor of {target_ref}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def resolve_reconciliation_plan(
|
||||
*,
|
||||
assessment: dict[str, Any],
|
||||
capabilities: dict[str, bool] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Map eligibility + profile capabilities to a reconciliation outcome."""
|
||||
caps = capabilities or {}
|
||||
reasons: list[str] = []
|
||||
|
||||
if not assessment.get("reconciliation_allowed"):
|
||||
outcome = OUTCOME_NOT_LANDED
|
||||
if assessment.get("eligibility_class") in {
|
||||
ELIGIBILITY_STALE_TARGET,
|
||||
ELIGIBILITY_PR_NOT_OPEN,
|
||||
}:
|
||||
outcome = OUTCOME_GATE_NOT_PROVEN
|
||||
reasons.extend(assessment.get("reasons") or [])
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"close_pr_allowed": False,
|
||||
"comment_pr_allowed": False,
|
||||
"close_issue_allowed": False,
|
||||
"comment_issue_allowed": False,
|
||||
"review_merge_allowed": False,
|
||||
"missing_capabilities": _missing_reconciliation_capabilities(caps),
|
||||
"safe_next_action": (
|
||||
"Do not reconcile via review/merge; repair missing proof first."
|
||||
),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
close_pr = bool(caps.get("close_pr"))
|
||||
comment_pr = bool(caps.get("comment_pr"))
|
||||
close_issue = bool(caps.get("close_issue"))
|
||||
comment_issue = bool(caps.get("comment_issue"))
|
||||
|
||||
if caps.get("review_pr") or caps.get("merge_pr"):
|
||||
reasons.append(
|
||||
"reconciliation path must not use review/merge capabilities"
|
||||
)
|
||||
|
||||
if close_pr:
|
||||
outcome = OUTCOME_FULL_RECONCILE
|
||||
safe_next_action = (
|
||||
"Run reconciliation close via exact gitea.pr.close after proof; "
|
||||
"do not approve or merge."
|
||||
)
|
||||
elif comment_pr:
|
||||
outcome = OUTCOME_PARTIAL_COMMENT
|
||||
safe_next_action = (
|
||||
"Post one reconciliation comment with ancestor proof, then stop "
|
||||
"for an authorized close profile."
|
||||
)
|
||||
else:
|
||||
outcome = OUTCOME_RECOVERY_HANDOFF
|
||||
safe_next_action = (
|
||||
"Produce a recovery handoff naming missing gitea.pr.close and/or "
|
||||
"gitea.pr.comment; do not loop through review/merge."
|
||||
)
|
||||
reasons.append("gitea.pr.close is not available in the active profile")
|
||||
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"close_pr_allowed": close_pr,
|
||||
"comment_pr_allowed": comment_pr,
|
||||
"close_issue_allowed": close_issue,
|
||||
"comment_issue_allowed": comment_issue,
|
||||
"review_merge_allowed": False,
|
||||
"missing_capabilities": _missing_reconciliation_capabilities(caps),
|
||||
"safe_next_action": safe_next_action,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def _missing_reconciliation_capabilities(caps: dict[str, bool]) -> list[str]:
|
||||
missing = []
|
||||
if not caps.get("read"):
|
||||
missing.append("gitea.read")
|
||||
if not caps.get("close_pr"):
|
||||
missing.append("gitea.pr.close")
|
||||
if not caps.get("comment_pr"):
|
||||
missing.append("gitea.pr.comment")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_reconcile_workflow_source(report_text: str) -> dict[str, Any]:
|
||||
"""Reconciliation reports must cite the canonical workflow file."""
|
||||
lower = (report_text or "").lower()
|
||||
reasons = []
|
||||
if not any(marker in lower for marker in RECONCILE_WORKFLOW_MARKERS):
|
||||
reasons.append(
|
||||
"reconciliation report missing workflow source "
|
||||
"(workflows/reconcile-landed-pr.md)"
|
||||
)
|
||||
if not any(marker in lower for marker in RECONCILE_TASK_MARKERS):
|
||||
reasons.append(
|
||||
"reconciliation report missing task mode declaration "
|
||||
"(reconcile-landed-pr)"
|
||||
)
|
||||
return {
|
||||
"complete": not reasons,
|
||||
"downgraded": bool(reasons),
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Reviewer final-report schema verification before session output (#391).
|
||||
|
||||
Composes the composable ``assess_final_report_validator`` (#327) with
|
||||
review-specific schema gates required before a reviewer session completes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from final_report_validator import (
|
||||
assess_final_report_validator,
|
||||
validator_finding,
|
||||
_handoff_fields,
|
||||
)
|
||||
|
||||
_LEGACY_STALE_FIELDS = (
|
||||
"pinned reviewed head",
|
||||
"scratch worktree used",
|
||||
"workspace mutations",
|
||||
)
|
||||
|
||||
_REVIEWED_HEAD_RE = re.compile(
|
||||
r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VALIDATION_PASS_RE = re.compile(
|
||||
r"validation\s*:\s*(?:pass|passed|strong|ok|green)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGED_CLAIM_RE = re.compile(
|
||||
r"\b(?:merged|merge result\s*:\s*merged|pr\s+#\d+\s+merged)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(r"merge result\s*:", re.IGNORECASE)
|
||||
_ANCESTRY_PROOF_RE = re.compile(
|
||||
r"(?:ancestor proof|target branch sha|merge commit)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ISSUE_CLOSED_RE = re.compile(
|
||||
r"(?:linked issue(?:\s+live)?\s+status\s*:\s*closed|issue\s+#\d+\s+closed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_ISSUE_PROOF_RE = re.compile(
|
||||
r"gitea_view_issue|(?:issue|linked issue).{0,40}fetched live|live fetch proof",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FULL_SUITE_PASS_RE = re.compile(
|
||||
r"(?:full suite passed|full test suite passed|\d+\s+passed,\s*0\s+failed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TESTS_IGNORED_RE = re.compile(
|
||||
r"(?:ignored tests|tests?\s+ignored|skipped tests|not run|tests?\s+skipped)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BLOCKED_HANDOFF_RE = re.compile(
|
||||
r"(?:blocker\s*:|recovery handoff|infra_stop|capability stop|mutation blocked)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REPLAY_COMMAND_RE = re.compile(
|
||||
r"(?:gitea_submit_pr_review|gitea_merge_pr|gitea_review_pr|"
|
||||
r"submitted\s+['\"]approve['\"]|replay approve|replay merge)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PENDING_RE = re.compile(r"\bPENDING\b", re.IGNORECASE)
|
||||
_APPROVED_RE = re.compile(
|
||||
r"(?:review decision\s*:\s*approve|submitted\s+approve|official review submitted)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DRY_RUN_RE = re.compile(r"dry[- ]run", re.IGNORECASE)
|
||||
_FINDING_READY_RE = re.compile(
|
||||
r"(?:finding ready|ready to submit|not yet submitted)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NARRATIVE_APPROVE_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:##\s+)?(?:summary|verdict|recommendation)\b[^\n]*\bapprove\b",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_HANDOFF_DECISION_RE = re.compile(
|
||||
r"review decision\s*:\s*(\w+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _rule_legacy_stale_fields(report_text: str) -> list[dict[str, str]]:
|
||||
fields = _handoff_fields(report_text)
|
||||
findings: list[dict[str, str]] = []
|
||||
for stale in _LEGACY_STALE_FIELDS:
|
||||
if stale in fields and fields[stale].lower() not in {"", "none", "n/a"}:
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.legacy_stale_field",
|
||||
"block",
|
||||
stale.title(),
|
||||
f"legacy or stale handoff field '{stale}' must not appear in "
|
||||
"canonical reviewer final reports",
|
||||
"remove stale fields; use Candidate head SHA and precise "
|
||||
"mutation categories instead",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def _rule_reviewed_head_without_validation(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _REVIEWED_HEAD_RE.search(text):
|
||||
return []
|
||||
if _VALIDATION_PASS_RE.search(text):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.reviewed_head_without_validation",
|
||||
"block",
|
||||
"Pinned reviewed head",
|
||||
"reviewed/pinned head SHA reported without validation pass proof",
|
||||
"document validation command, cwd, and pass result before pinning head SHA",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_merged_without_proof(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _MERGED_CLAIM_RE.search(text):
|
||||
return []
|
||||
if _MERGE_RESULT_RE.search(text) and _ANCESTRY_PROOF_RE.search(text):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.merged_without_proof",
|
||||
"block",
|
||||
"Merge result",
|
||||
"merged outcome claimed without merge result and target ancestry proof",
|
||||
"include Merge result, target branch SHA, and ancestor proof before claiming merged",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_issue_closed_without_live_proof(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _ISSUE_CLOSED_RE.search(text):
|
||||
return []
|
||||
fields = _handoff_fields(text)
|
||||
status_value = fields.get("linked issue live status", "")
|
||||
readonly_value = fields.get("read-only diagnostics", "")
|
||||
proof_blob = f"{status_value} {readonly_value}".lower()
|
||||
if _LIVE_ISSUE_PROOF_RE.search(proof_blob):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.issue_closed_without_live_proof",
|
||||
"block",
|
||||
"Linked issue",
|
||||
"issue closed claimed without live linked-issue verification proof",
|
||||
"fetch linked issue live (gitea_view_issue) and record Linked issue live status",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_full_suite_pass_ignored_tests(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not (_FULL_SUITE_PASS_RE.search(text) and _TESTS_IGNORED_RE.search(text)):
|
||||
return []
|
||||
if re.search(r"explicitly\s+(?:ignored|skipped)", text, re.IGNORECASE):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.full_suite_pass_ignored_tests",
|
||||
"block",
|
||||
"Validation",
|
||||
"full suite pass claimed while tests were ignored or skipped without "
|
||||
"explicit disclosure",
|
||||
"state which tests were ignored/skipped or downgrade validation verdict",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_blocked_handoff_replay(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _BLOCKED_HANDOFF_RE.search(text):
|
||||
return []
|
||||
if not _REPLAY_COMMAND_RE.search(text):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.blocked_handoff_replay",
|
||||
"block",
|
||||
"Safe next action",
|
||||
"blocked recovery handoff includes direct approve or merge replay commands",
|
||||
"restart the full workflow after the blocker clears; do not replay mutations",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_narrative_handoff_drift(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
narrative_approve = bool(_NARRATIVE_APPROVE_RE.search(text))
|
||||
decision_match = _HANDOFF_DECISION_RE.search(text)
|
||||
if not narrative_approve or not decision_match:
|
||||
return []
|
||||
handoff_decision = decision_match.group(1).strip().lower()
|
||||
if handoff_decision in {"approve", "approved"}:
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.narrative_handoff_drift",
|
||||
"block",
|
||||
"Review decision",
|
||||
f"narrative approves PR but controller handoff says '{handoff_decision}'",
|
||||
"align narrative summary with controller handoff Review decision field",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_review_state_ambiguous(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
findings: list[dict[str, str]] = []
|
||||
if _PENDING_RE.search(text) and _APPROVED_RE.search(text):
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.pending_vs_approved",
|
||||
"block",
|
||||
"Review decision",
|
||||
"report conflates PENDING review state with APPROVED outcome",
|
||||
"distinguish official submitted review from pending or ready-to-submit state",
|
||||
)
|
||||
)
|
||||
if _DRY_RUN_RE.search(text) and _APPROVED_RE.search(text):
|
||||
if "dry-run only" not in text.lower():
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.dry_run_vs_submitted",
|
||||
"block",
|
||||
"Review mutations",
|
||||
"dry-run language mixed with official approve/submitted claims",
|
||||
"mark dry-run explicitly or document the live review mutation proof",
|
||||
)
|
||||
)
|
||||
if _FINDING_READY_RE.search(text) and _APPROVED_RE.search(text):
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.finding_ready_vs_submitted",
|
||||
"downgrade",
|
||||
"Review decision",
|
||||
"finding-ready wording combined with submitted-approve claims",
|
||||
"state whether review was submitted live or only prepared for submission",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
_SCHEMA_RULES = (
|
||||
_rule_legacy_stale_fields,
|
||||
_rule_reviewed_head_without_validation,
|
||||
_rule_merged_without_proof,
|
||||
_rule_issue_closed_without_live_proof,
|
||||
_rule_full_suite_pass_ignored_tests,
|
||||
_rule_blocked_handoff_replay,
|
||||
_rule_narrative_handoff_drift,
|
||||
_rule_review_state_ambiguous,
|
||||
)
|
||||
|
||||
|
||||
def _merge_validator_results(
|
||||
base: dict[str, Any],
|
||||
extra_findings: list[dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
findings = list(base.get("findings") or []) + extra_findings
|
||||
blocked = base.get("blocked") or any(f["severity"] == "block" for f in extra_findings)
|
||||
downgraded = base.get("downgraded") or any(
|
||||
f["severity"] == "downgrade" for f in extra_findings
|
||||
)
|
||||
grade = base.get("grade", "A")
|
||||
if blocked:
|
||||
grade = "blocked"
|
||||
elif downgraded and grade == "A":
|
||||
grade = "downgraded"
|
||||
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
|
||||
safe_next = base.get("safe_next_action") or "none"
|
||||
if extra_findings:
|
||||
safe_next = extra_findings[0].get("safe_next_action", safe_next)
|
||||
return {
|
||||
**base,
|
||||
"grade": grade,
|
||||
"blocked": blocked,
|
||||
"downgraded": downgraded,
|
||||
"findings": findings,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": safe_next,
|
||||
"complete": grade == "A",
|
||||
"schema_rules_applied": len(_SCHEMA_RULES),
|
||||
}
|
||||
|
||||
|
||||
def assess_review_final_report_schema(
|
||||
report_text: str,
|
||||
*,
|
||||
review_decision_lock: dict | None = None,
|
||||
linked_issue_lock: dict | None = None,
|
||||
validation_report: dict | None = None,
|
||||
action_log: list[dict] | None = None,
|
||||
mutations_observed: bool = False,
|
||||
local_edits: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate reviewer final report text before session completion (#391)."""
|
||||
base = assess_final_report_validator(
|
||||
report_text,
|
||||
"review_pr",
|
||||
review_decision_lock=review_decision_lock,
|
||||
linked_issue_lock=linked_issue_lock,
|
||||
validation_report=validation_report,
|
||||
action_log=action_log,
|
||||
mutations_observed=mutations_observed,
|
||||
local_edits=local_edits,
|
||||
validation_session=validation_session,
|
||||
)
|
||||
extra: list[dict[str, str]] = []
|
||||
for rule in _SCHEMA_RULES:
|
||||
extra.extend(rule(report_text))
|
||||
return _merge_validator_results(base, extra)
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Enforced PR review/merge workflow state machine (#290).
|
||||
|
||||
Review and merge must advance through explicit states. Any failed upstream
|
||||
gate forbids downstream approve/merge mutations until the full workflow is
|
||||
restarted after blockers clear.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
REVIEW_MERGE_STATES: tuple[str, ...] = (
|
||||
"PRECHECK",
|
||||
"INVENTORY",
|
||||
"SELECT_PR",
|
||||
"PIN_HEAD_SHA",
|
||||
"CREATE_BRANCHES_WORKTREE",
|
||||
"VALIDATE",
|
||||
"REVIEW_DECISION",
|
||||
"APPROVE_OR_REQUEST_CHANGES",
|
||||
"PRE_MERGE_RECHECK",
|
||||
"MERGE",
|
||||
"POST_MERGE_REPORT",
|
||||
)
|
||||
|
||||
TERMINAL_BLOCKED_HEADING = (
|
||||
"PR review/merge workflow blocked. Restart the full workflow after blockers clear."
|
||||
)
|
||||
|
||||
_FORBIDDEN_RECOVERY_REPLAY_RE = re.compile(
|
||||
r"\b(?:approve(?:\s+pr)?\s*#?\d+|merge(?:\s+pr)?\s*#?\d+|gitea_merge_pr|"
|
||||
r"gitea_submit_pr_review|submit\s+approve|run\s+merge)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RESTART_WORKFLOW_RE = re.compile(
|
||||
r"restart(?:\s+the)?\s+full\s+workflow|rerun\s+the\s+full\s+workflow",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_READY_TO_MERGE_RE = re.compile(
|
||||
r"\bready\s+to\s+merge\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWED_VALIDATED_RE = re.compile(
|
||||
r"\b(?:reviewed|validated|approval\s+submitted)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_PRE_MERGE_REQUIRED_GATES = (
|
||||
"whoami_verified",
|
||||
"profile_runtime_verified",
|
||||
"merge_capability_verified",
|
||||
"pr_refetched",
|
||||
"reviewed_head_sha_unchanged",
|
||||
"pr_mergeable",
|
||||
"checks_passed",
|
||||
"reviewer_not_author",
|
||||
"worktree_clean",
|
||||
)
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return (value or "").strip()
|
||||
|
||||
|
||||
def state_index(state: str) -> int:
|
||||
name = _clean(state).upper()
|
||||
try:
|
||||
return REVIEW_MERGE_STATES.index(name)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"unknown review/merge state '{state}' (fail closed)") from exc
|
||||
|
||||
|
||||
def downstream_states(from_state: str) -> list[str]:
|
||||
idx = state_index(from_state)
|
||||
return list(REVIEW_MERGE_STATES[idx + 1 :])
|
||||
|
||||
|
||||
def _completed_through(state_completion: dict[str, bool], state: str) -> bool:
|
||||
return bool(state_completion.get(state))
|
||||
|
||||
|
||||
def _first_incomplete_state(state_completion: dict[str, bool]) -> str | None:
|
||||
for state in REVIEW_MERGE_STATES:
|
||||
if not _completed_through(state_completion, state):
|
||||
return state
|
||||
return None
|
||||
|
||||
|
||||
def assess_workflow_blockers(
|
||||
*,
|
||||
infra_stop: bool = False,
|
||||
capability_blocked: bool = False,
|
||||
mcp_reconnect_failed: bool = False,
|
||||
stale_capability_state: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Return hard blockers that forbid all PR queue work (#290 AC3)."""
|
||||
reasons: list[str] = []
|
||||
if infra_stop:
|
||||
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
|
||||
if capability_blocked:
|
||||
reasons.append("gitea_resolve_task_capability returned blocked/stop_required")
|
||||
if mcp_reconnect_failed:
|
||||
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")
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"forbidden_states": list(REVIEW_MERGE_STATES) if reasons else [],
|
||||
"safe_next_action": (
|
||||
TERMINAL_BLOCKED_HEADING if reasons else "proceed with PRECHECK"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_state_advancement(
|
||||
state_completion: dict[str, bool] | None,
|
||||
*,
|
||||
target_state: str,
|
||||
infra_stop: bool = False,
|
||||
capability_blocked: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when *target_state* is requested before upstream gates pass."""
|
||||
completion = dict(state_completion or {})
|
||||
target = _clean(target_state).upper()
|
||||
blockers = assess_workflow_blockers(
|
||||
infra_stop=infra_stop,
|
||||
capability_blocked=capability_blocked,
|
||||
)
|
||||
reasons = list(blockers["reasons"])
|
||||
|
||||
try:
|
||||
target_idx = state_index(target)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"target_state": target,
|
||||
"allowed": False,
|
||||
"block": True,
|
||||
"reasons": [str(exc)],
|
||||
"next_allowed_state": None,
|
||||
"safe_next_action": TERMINAL_BLOCKED_HEADING,
|
||||
}
|
||||
|
||||
if blockers["block"]:
|
||||
return {
|
||||
"target_state": target,
|
||||
"allowed": False,
|
||||
"block": True,
|
||||
"reasons": reasons,
|
||||
"next_allowed_state": None,
|
||||
"safe_next_action": blockers["safe_next_action"],
|
||||
}
|
||||
|
||||
next_allowed = _first_incomplete_state(completion)
|
||||
if next_allowed is None:
|
||||
allowed = target_idx == len(REVIEW_MERGE_STATES) - 1
|
||||
if not allowed:
|
||||
reasons.append("workflow already completed through POST_MERGE_REPORT")
|
||||
else:
|
||||
allowed = state_index(next_allowed) >= target_idx
|
||||
if not allowed:
|
||||
reasons.append(
|
||||
f"state '{target}' is forbidden until '{next_allowed}' completes"
|
||||
)
|
||||
|
||||
return {
|
||||
"target_state": target,
|
||||
"allowed": allowed and not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"next_allowed_state": next_allowed,
|
||||
"completed_states": [s for s in REVIEW_MERGE_STATES if completion.get(s)],
|
||||
"safe_next_action": (
|
||||
f"complete state '{next_allowed}' before advancing"
|
||||
if next_allowed and not allowed
|
||||
else (
|
||||
TERMINAL_BLOCKED_HEADING
|
||||
if reasons
|
||||
else f"advance to {target}"
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def can_approve(state_completion: dict[str, bool] | None, **blocker_kwargs) -> dict[str, Any]:
|
||||
"""Approval requires all states through REVIEW_DECISION (#290 AC)."""
|
||||
required = REVIEW_MERGE_STATES[: REVIEW_MERGE_STATES.index("REVIEW_DECISION") + 1]
|
||||
completion = dict(state_completion or {})
|
||||
advance = assess_state_advancement(
|
||||
completion,
|
||||
target_state="APPROVE_OR_REQUEST_CHANGES",
|
||||
**blocker_kwargs,
|
||||
)
|
||||
missing = [s for s in required if not completion.get(s)]
|
||||
reasons = list(advance["reasons"])
|
||||
if missing:
|
||||
reasons.append(
|
||||
"approval blocked: incomplete states: " + ", ".join(missing)
|
||||
)
|
||||
block = bool(reasons) or advance["block"]
|
||||
return {
|
||||
"allowed": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"missing_states": missing,
|
||||
"safe_next_action": advance["safe_next_action"],
|
||||
}
|
||||
|
||||
|
||||
def can_merge(
|
||||
state_completion: dict[str, bool] | None,
|
||||
*,
|
||||
pre_merge_gates: dict[str, bool] | None = None,
|
||||
**blocker_kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge requires approve path plus fresh PRE_MERGE_RECHECK gates (#290 AC6)."""
|
||||
completion = dict(state_completion or {})
|
||||
approve = can_approve(completion, **blocker_kwargs)
|
||||
reasons = list(approve["reasons"])
|
||||
|
||||
if not completion.get("APPROVE_OR_REQUEST_CHANGES"):
|
||||
reasons.append("merge blocked: APPROVE_OR_REQUEST_CHANGES not completed")
|
||||
|
||||
gate_map = dict(pre_merge_gates or {})
|
||||
missing_gates = [g for g in _PRE_MERGE_REQUIRED_GATES if not gate_map.get(g)]
|
||||
if missing_gates:
|
||||
reasons.append(
|
||||
"merge blocked: pre-merge gates incomplete: " + ", ".join(missing_gates)
|
||||
)
|
||||
|
||||
advance = assess_state_advancement(
|
||||
completion,
|
||||
target_state="MERGE",
|
||||
**blocker_kwargs,
|
||||
)
|
||||
reasons.extend(advance["reasons"])
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"allowed": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"missing_pre_merge_gates": missing_gates,
|
||||
"safe_next_action": (
|
||||
"complete PRE_MERGE_RECHECK with fresh whoami/capability/PR re-fetch "
|
||||
"before merge"
|
||||
if block
|
||||
else "merge allowed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_blocked_recovery_handoff(report_text: str) -> dict[str, Any]:
|
||||
"""Blocked handoffs must not replay approve/merge commands (#290 AC4)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
if _FORBIDDEN_RECOVERY_REPLAY_RE.search(text):
|
||||
reasons.append(
|
||||
"blocked recovery handoff contains direct approve/merge replay command"
|
||||
)
|
||||
if reasons and not _RESTART_WORKFLOW_RE.search(text):
|
||||
reasons.append(
|
||||
"blocked recovery handoff must direct operator to restart full workflow"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"allowed": not reasons,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"remove approve/merge replay commands and say to restart full workflow "
|
||||
"after blockers clear"
|
||||
if reasons
|
||||
else "recovery handoff wording acceptable"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_final_report_state_claims(
|
||||
report_text: str,
|
||||
*,
|
||||
state_completion: dict[str, bool] | None = None,
|
||||
approve_completed: bool = False,
|
||||
merge_completed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Reports must not claim reviewed/ready-to-merge without gate proof (#290 AC10)."""
|
||||
text = report_text or ""
|
||||
completion = dict(state_completion or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _READY_TO_MERGE_RE.search(text) and not (
|
||||
merge_completed or completion.get("PRE_MERGE_RECHECK")
|
||||
):
|
||||
reasons.append(
|
||||
"report claims ready-to-merge without PRE_MERGE_RECHECK completion"
|
||||
)
|
||||
|
||||
if _REVIEWED_VALIDATED_RE.search(text):
|
||||
if not (approve_completed or completion.get("VALIDATE")):
|
||||
reasons.append(
|
||||
"report claims reviewed/validated without VALIDATE/APPROVE proof"
|
||||
)
|
||||
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"allowed": not reasons,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"remove stale reviewed/ready-to-merge claims unless gates passed"
|
||||
if reasons
|
||||
else "final report state claims consistent with workflow"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def workflow_status(
|
||||
state_completion: dict[str, bool] | None,
|
||||
**blocker_kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize current state-machine position for MCP/runtime reporting."""
|
||||
completion = dict(state_completion or {})
|
||||
blockers = assess_workflow_blockers(**blocker_kwargs)
|
||||
next_state = _first_incomplete_state(completion)
|
||||
return {
|
||||
"states": list(REVIEW_MERGE_STATES),
|
||||
"completed_states": [s for s in REVIEW_MERGE_STATES if completion.get(s)],
|
||||
"next_required_state": next_state,
|
||||
"workflow_complete": next_state is None,
|
||||
"approve_allowed": can_approve(completion, **blocker_kwargs)["allowed"],
|
||||
"merge_allowed": can_merge(completion, **blocker_kwargs)["allowed"],
|
||||
"blockers": blockers,
|
||||
}
|
||||
+1466
-107
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
"""Already-landed PR classification verifier for reviewer reports (#295)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
ALREADY_LANDED_ELIGIBILITY_CLASS = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
|
||||
_LANDED_CONTEXT_RE = re.compile(
|
||||
r"already[- ]landed|ancestor proof\s*:\s*passed|"
|
||||
r"eligibility class\s*:\s*already_landed",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ELIGIBILITY_CLASS_LINE_RE = re.compile(
|
||||
r"eligibility class\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_INELIGIBLE_SELECTION_RE = re.compile(
|
||||
r"\b(?:next|oldest)\s+eligible\s+pr\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEW_ELIGIBLE_RE = re.compile(
|
||||
r"\beligible for (?:review|merge)\b|\bready for (?:review|merge)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PINNED_REVIEWED_HEAD_RE = re.compile(
|
||||
r"\bpinned reviewed head\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CANDIDATE_HEAD_RE = re.compile(
|
||||
r"\bcandidate head sha\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWED_HEAD_NONE_RE = re.compile(
|
||||
r"\breviewed head sha\s*:\s*none\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VALIDATION_PROOF_RE = re.compile(
|
||||
r"(?:validation passed|pytest.*passed|diff review passed|"
|
||||
r"validated on pinned head)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_QUEUE_BLOCKED_RE = re.compile(
|
||||
r"queue blocked by already[- ]landed|"
|
||||
r"reconciliation(?:-only)?\s+(?:next step|required)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _landed_context(report_text: str, eligibility_class: str | None) -> bool:
|
||||
if (eligibility_class or "").strip().upper() == ALREADY_LANDED_ELIGIBILITY_CLASS:
|
||||
return True
|
||||
return bool(_LANDED_CONTEXT_RE.search(report_text or ""))
|
||||
|
||||
|
||||
def assess_already_landed_classification_report(
|
||||
report_text: str,
|
||||
*,
|
||||
eligibility_class: str | None = None,
|
||||
selected_pr_already_landed: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
landed = _landed_context(text, eligibility_class)
|
||||
if selected_pr_already_landed is True:
|
||||
landed = True
|
||||
if not landed:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"landed_context": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
if _INELIGIBLE_SELECTION_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed PR must not be described as oldest/next eligible PR; "
|
||||
"use 'Oldest open PR requiring action' and "
|
||||
f"'Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}'"
|
||||
)
|
||||
|
||||
if _REVIEW_ELIGIBLE_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed PR must not be described as eligible for review or merge"
|
||||
)
|
||||
|
||||
class_match = _ELIGIBILITY_CLASS_LINE_RE.search(text)
|
||||
if class_match:
|
||||
declared = class_match.group(1).strip().upper()
|
||||
if declared != ALREADY_LANDED_ELIGIBILITY_CLASS:
|
||||
reasons.append(
|
||||
"already-landed PR eligibility class must be "
|
||||
f"{ALREADY_LANDED_ELIGIBILITY_CLASS}"
|
||||
)
|
||||
elif selected_pr_already_landed is True:
|
||||
reasons.append(
|
||||
f"already-landed selected PR must declare Eligibility class: "
|
||||
f"{ALREADY_LANDED_ELIGIBILITY_CLASS}"
|
||||
)
|
||||
|
||||
if _PINNED_REVIEWED_HEAD_RE.search(text):
|
||||
if not _VALIDATION_PROOF_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed pre-review report must use Candidate head SHA, "
|
||||
"not Pinned reviewed head, unless validation and diff review passed"
|
||||
)
|
||||
|
||||
if selected_pr_already_landed is True and not _CANDIDATE_HEAD_RE.search(text):
|
||||
if _PINNED_REVIEWED_HEAD_RE.search(text) or "head sha" in text.lower():
|
||||
reasons.append(
|
||||
"already-landed ancestry check must report Candidate head SHA"
|
||||
)
|
||||
|
||||
if selected_pr_already_landed is True and not _REVIEWED_HEAD_NONE_RE.search(text):
|
||||
if _PINNED_REVIEWED_HEAD_RE.search(text) and not _VALIDATION_PROOF_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed report must state 'Reviewed head SHA: none' "
|
||||
"when validation did not run"
|
||||
)
|
||||
|
||||
if selected_pr_already_landed is True and not _QUEUE_BLOCKED_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed queue blocker must state reconciliation requirement "
|
||||
"or queue blocked wording"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"landed_context": True,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"classify as ALREADY_LANDED_RECONCILE_REQUIRED, use Candidate head SHA, "
|
||||
"and stop normal review"
|
||||
if not proven
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Already-landed controller handoff consistency verifier (#299)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from review_proofs import git_ref_mutating_commands
|
||||
|
||||
ALREADY_LANDED_STATE = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
|
||||
_HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
|
||||
|
||||
_STALE_HANDOFF_FIELDS = (
|
||||
"pinned reviewed head",
|
||||
"scratch worktree used",
|
||||
)
|
||||
|
||||
_LEGACY_MUTATIONS_NONE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*mutations\s*:\s*none\s*$",
|
||||
re.I | re.M,
|
||||
)
|
||||
_LEGACY_WORKSPACE_NONE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*workspace\s+mutations\s*:\s*none\s*$",
|
||||
re.I | re.M,
|
||||
)
|
||||
|
||||
_FORBIDDEN_REVIEW_STATES_RE = re.compile(
|
||||
r"review decision\s*:\s*approved?\b|"
|
||||
r"merge result\s*:\s*merged\b|"
|
||||
r"\bready[_ ]to[_ ]merge\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_NARRATIVE_ELIGIBILITY_RE = re.compile(
|
||||
r"eligibility class\s*:\s*([^\n]+)",
|
||||
re.I,
|
||||
)
|
||||
_NARRATIVE_REVIEWED_SHA_RE = re.compile(
|
||||
r"reviewed head sha\s*:\s*([^\n]+)",
|
||||
re.I,
|
||||
)
|
||||
_NARRATIVE_WORKTREE_RE = re.compile(
|
||||
r"review worktree used\s*:\s*([^\n]+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _handoff_field_map(report_text: str) -> dict[str, str]:
|
||||
text = report_text or ""
|
||||
match = _HANDOFF_SECTION_RE.search(text)
|
||||
if not match:
|
||||
return {}
|
||||
fields: dict[str, str] = {}
|
||||
for line in text[match.end() :].splitlines():
|
||||
stripped = line.strip().lstrip("-*").strip()
|
||||
if ":" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split(":", 1)
|
||||
fields[key.strip().lower()] = value.strip()
|
||||
return fields
|
||||
|
||||
|
||||
def _narrative_before_handoff(report_text: str) -> str:
|
||||
text = report_text or ""
|
||||
match = _HANDOFF_SECTION_RE.search(text)
|
||||
if not match:
|
||||
return text
|
||||
return text[: match.start()]
|
||||
|
||||
|
||||
def _is_truthy(value: str) -> bool:
|
||||
lowered = (value or "").strip().lower()
|
||||
return lowered not in {"", "none", "n/a", "not applicable", "false", "no", "—", "-"}
|
||||
|
||||
|
||||
def _gate_active(text: str, fields: dict[str, str], session: dict) -> bool:
|
||||
if session.get("gate_fired") or session.get("already_landed_gate_fired"):
|
||||
return True
|
||||
eligibility = (fields.get("eligibility class") or "").upper()
|
||||
if ALREADY_LANDED_STATE in eligibility:
|
||||
return True
|
||||
return ALREADY_LANDED_STATE.lower() in text.lower()
|
||||
|
||||
|
||||
def assess_already_landed_handoff_report(
|
||||
report_text: str,
|
||||
*,
|
||||
handoff_session: dict | None = None,
|
||||
command_log: list | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reject stale handoff fields and narrative/handoff drift after the gate (#299)."""
|
||||
text = report_text or ""
|
||||
session = dict(handoff_session or {})
|
||||
fields = _handoff_field_map(text)
|
||||
reasons: list[str] = []
|
||||
|
||||
if not _gate_active(text, fields, session):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"gate_active": False,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
for stale_field in _STALE_HANDOFF_FIELDS:
|
||||
if stale_field in fields:
|
||||
reasons.append(
|
||||
f"already-landed handoff must not include stale field "
|
||||
f"'{stale_field.title()}' (#299)"
|
||||
)
|
||||
|
||||
if _LEGACY_WORKSPACE_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed handoff must not include legacy "
|
||||
"'Workspace mutations: None' (#299)"
|
||||
)
|
||||
|
||||
ref_commands = git_ref_mutating_commands(command_log or session.get("command_log"))
|
||||
mutations_observed = bool(
|
||||
ref_commands
|
||||
or session.get("mutations_observed")
|
||||
or session.get("mcp_mutations")
|
||||
or session.get("review_mutations")
|
||||
)
|
||||
if mutations_observed and _LEGACY_MUTATIONS_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed handoff must not claim 'Mutations: None' when "
|
||||
"git ref, MCP, review, merge, or cleanup mutations occurred (#299)"
|
||||
)
|
||||
|
||||
git_ref_value = fields.get("git ref mutations", "").strip().lower()
|
||||
if ref_commands and (not git_ref_value or git_ref_value == "none"):
|
||||
reasons.append(
|
||||
"git fetch/ref updates must be reported under Git ref mutations (#299)"
|
||||
)
|
||||
|
||||
reviewed_sha = fields.get("reviewed head sha", "")
|
||||
if reviewed_sha and _is_truthy(reviewed_sha):
|
||||
reasons.append(
|
||||
"already-landed handoff must use 'Reviewed head SHA: none' (#299)"
|
||||
)
|
||||
|
||||
worktree_used = fields.get("review worktree used", "")
|
||||
if worktree_used and _is_truthy(worktree_used):
|
||||
reasons.append(
|
||||
"already-landed handoff must set Review worktree used: false (#299)"
|
||||
)
|
||||
|
||||
candidate_sha = fields.get("candidate head sha", "")
|
||||
if not candidate_sha or candidate_sha.lower() in {"none", "n/a", "unknown"}:
|
||||
reasons.append(
|
||||
"already-landed handoff must include Candidate head SHA (#299)"
|
||||
)
|
||||
|
||||
if _FORBIDDEN_REVIEW_STATES_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed handoff must not claim approved/merged/ready-to-merge (#299)"
|
||||
)
|
||||
|
||||
narrative = _narrative_before_handoff(text)
|
||||
handoff_eligibility = (fields.get("eligibility class") or "").strip()
|
||||
narrative_eligibility = (
|
||||
_NARRATIVE_ELIGIBILITY_RE.search(narrative).group(1).strip()
|
||||
if _NARRATIVE_ELIGIBILITY_RE.search(narrative)
|
||||
else ""
|
||||
)
|
||||
if handoff_eligibility and narrative_eligibility:
|
||||
if handoff_eligibility.upper() != narrative_eligibility.upper():
|
||||
reasons.append(
|
||||
"narrative Eligibility class disagrees with controller handoff (#299)"
|
||||
)
|
||||
|
||||
narrative_reviewed = (
|
||||
_NARRATIVE_REVIEWED_SHA_RE.search(narrative).group(1).strip()
|
||||
if _NARRATIVE_REVIEWED_SHA_RE.search(narrative)
|
||||
else ""
|
||||
)
|
||||
if narrative_reviewed and reviewed_sha:
|
||||
if narrative_reviewed.lower() != reviewed_sha.lower():
|
||||
reasons.append(
|
||||
"narrative Reviewed head SHA disagrees with controller handoff (#299)"
|
||||
)
|
||||
|
||||
narrative_worktree = (
|
||||
_NARRATIVE_WORKTREE_RE.search(narrative).group(1).strip()
|
||||
if _NARRATIVE_WORKTREE_RE.search(narrative)
|
||||
else ""
|
||||
)
|
||||
if narrative_worktree and worktree_used:
|
||||
if narrative_worktree.lower() != worktree_used.lower():
|
||||
reasons.append(
|
||||
"narrative Review worktree used disagrees with controller handoff (#299)"
|
||||
)
|
||||
|
||||
git_ref_narrative = "git ref mutations" in narrative.lower()
|
||||
if git_ref_narrative and git_ref_value:
|
||||
if "none" in git_ref_value and ref_commands:
|
||||
reasons.append(
|
||||
"narrative and handoff disagree on git ref mutation reporting (#299)"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
"gate_active": True,
|
||||
"safe_next_action": (
|
||||
"emit canonical ALREADY_LANDED_RECONCILE_REQUIRED handoff without "
|
||||
"stale review/merge fields; align narrative and controller handoff"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Infra-stop repair handoff verifier for blocked reviewer workflows (#289)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from capability_stop_terminal import (
|
||||
TERMINAL_REPORT_HEADING,
|
||||
assess_capability_stop_report,
|
||||
)
|
||||
|
||||
_REPAIR_HANDOFF_RE = re.compile(
|
||||
r"repair handoff|control-checkout repair mode",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONTROL_REPAIR_RE = re.compile(r"control-checkout repair mode", re.IGNORECASE)
|
||||
_PR_QUEUE_ADVANCE_RE = re.compile(
|
||||
r"(?:selected pr|pr #\d+ (?:to review|selected)|eligible pr|"
|
||||
r"next (?:eligible )?pr(?: to review)?|oldest eligible pr|pinned (?:review )?head)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEW_MUTATION_RE = re.compile(
|
||||
r"(?:gitea_review_pr|gitea_merge_pr|request_changes|submitted\s+approve|"
|
||||
r"merge result|review decision\s*:\s*approve)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BACKGROUND_TOOL_RE = re.compile(r"\b(?:schedule|manage_task)\b", re.IGNORECASE)
|
||||
_PR_REVIEW_REPORT_RE = re.compile(
|
||||
r"(?:review summary|merge recommendation|validation result\s*:\s*pass|"
|
||||
r"queue status report with selected pr)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_DIAGNOSTIC_FIELDS = {
|
||||
"mcp process root": re.compile(r"mcp process root\s*:", re.IGNORECASE),
|
||||
"inspected git root": re.compile(r"inspected git root\s*:", re.IGNORECASE),
|
||||
"conflict marker path": re.compile(
|
||||
r"(?:conflict marker path|exact conflict marker path)\s*:",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"merge/rebase control path": re.compile(
|
||||
r"(?:merge/rebase control path|exact merge/rebase control path)\s*:",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"safe next repair action": re.compile(
|
||||
r"safe next (?:repair )?action\s*:",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_infra_stop_handoff_report(
|
||||
report_text: str,
|
||||
*,
|
||||
stop_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate repair handoff purity when infra_stop blocks reviewer capability (#289)."""
|
||||
text = report_text or ""
|
||||
session = dict(stop_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
infra_stop = bool(
|
||||
session.get("infra_stop")
|
||||
or session.get("infra_stop_blocked")
|
||||
or session.get("route_result") == "infra_stop"
|
||||
)
|
||||
if not infra_stop:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"infra_stop": False,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
lower = text.lower()
|
||||
repair_handoff = bool(
|
||||
_REPAIR_HANDOFF_RE.search(text)
|
||||
or TERMINAL_REPORT_HEADING.lower() in lower
|
||||
)
|
||||
if not repair_handoff:
|
||||
reasons.append(
|
||||
"infra_stop requires a repair handoff, not a PR review report"
|
||||
)
|
||||
|
||||
if _PR_REVIEW_REPORT_RE.search(text) and not _REPAIR_HANDOFF_RE.search(text):
|
||||
reasons.append(
|
||||
"final output must be a repair handoff instead of stale PR review state"
|
||||
)
|
||||
|
||||
if _PR_QUEUE_ADVANCE_RE.search(text):
|
||||
reasons.append(
|
||||
"infra_stop blocks PR queue advancement; do not select or advance next PR"
|
||||
)
|
||||
|
||||
if _REVIEW_MUTATION_RE.search(text):
|
||||
reasons.append(
|
||||
"review, approval, request-changes, merge, or comment mutations "
|
||||
"forbidden while infra_stop is active"
|
||||
)
|
||||
|
||||
if session.get("pinned_head_sha") or re.search(
|
||||
r"pinned (?:review )?head sha\s*:", text, re.IGNORECASE
|
||||
):
|
||||
if session.get("capability_cleared") is not True:
|
||||
reasons.append(
|
||||
"cannot pin PR head SHA while review capability never cleared"
|
||||
)
|
||||
|
||||
if session.get("main_checkout_diagnostics") and not _CONTROL_REPAIR_RE.search(text):
|
||||
reasons.append(
|
||||
"main-checkout diagnostics must be labeled CONTROL-CHECKOUT REPAIR MODE"
|
||||
)
|
||||
|
||||
if _BACKGROUND_TOOL_RE.search(text):
|
||||
reasons.append(
|
||||
"background schedule/manage_task tools must not be used during "
|
||||
"blocked infra_stop recovery"
|
||||
)
|
||||
|
||||
assessment = session.get("infra_stop_assessment") or {}
|
||||
if assessment.get("infra_stop") or infra_stop:
|
||||
missing = [
|
||||
label
|
||||
for label, pattern in _DIAGNOSTIC_FIELDS.items()
|
||||
if not pattern.search(text)
|
||||
]
|
||||
if missing and session.get("require_infra_diagnostics", True):
|
||||
reasons.append(
|
||||
"infra_stop repair handoff missing diagnostic fields: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
conflict_file = assessment.get("conflict_file")
|
||||
if conflict_file and conflict_file.lower() not in lower:
|
||||
reasons.append(
|
||||
f"infra_stop assessment conflict file {conflict_file!r} "
|
||||
"not reflected in repair handoff"
|
||||
)
|
||||
|
||||
capability = assess_capability_stop_report(
|
||||
text,
|
||||
trust_gate_status=session.get("trust_gate_status"),
|
||||
capability_denied=True,
|
||||
)
|
||||
if not capability.get("pure"):
|
||||
reasons.extend(capability.get("reasons") or [])
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
"infra_stop": True,
|
||||
"repair_handoff": repair_handoff,
|
||||
"safe_next_action": (
|
||||
"stop PR queue work; emit CONTROL-CHECKOUT REPAIR MODE handoff "
|
||||
"with infra diagnostics and safe next repair action"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Reviewer inventory completeness and worktree state verifier (#293)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_PAGINATION_FINALITY_EVIDENCE = re.compile(
|
||||
r"pagination_complete\s*:\s*true|inventory_complete\s*:\s*true|"
|
||||
r"is_final_page\s*:\s*true|pages_fetched|has_more\s*:\s*false|"
|
||||
r"no next page|final[- ]page|pr_inventory_trust_gate\.status|"
|
||||
r"total_count\s*:|pagination.*(?:final|complete)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_INCOMPLETE_PAGINATION_PROOF = re.compile(
|
||||
r"first page only|partial page|page 1 only|truncated|"
|
||||
r"returned \d+ open prs?(?:\s*$|\s*[,;])",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_DEFAULT_PAGE_SIZE_ASSUMPTION = re.compile(
|
||||
r"(?:less|fewer) than (?:the )?(?:default )?(?:gitea )?page[- ]?(?:size|limit)|"
|
||||
r"default (?:gitea )?page[- ]?size|under (?:the )?50[- ]?(?:item )?limit|"
|
||||
r"(?:complete|exhaustive).*(?:default )?page[- ]?size|"
|
||||
r"page[- ]?size assumption|assumed complete",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_ELIGIBILITY_CLAIM_RE = re.compile(
|
||||
r"\b(?:oldest eligible pr|next eligible pr|next pr to review|"
|
||||
r"inventory (?:is )?complete|inventory exhaustive|exhaustive inventory)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_EXACT_PAGE_LIMIT_RE = re.compile(
|
||||
r"(?:returned|listed|fetched)\s+(\d+)\s+open prs?|"
|
||||
r"open pr count\s*:\s*(\d+)|"
|
||||
r"page[- ]?size\s*(?:=|:)\s*(\d+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_LEGACY_SCRATCH_FALSE_RE = re.compile(
|
||||
r"scratch worktree used\s*:\s*false",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_BRANCHES_WORKTREE_RE = re.compile(r"\bbranches/", re.I)
|
||||
|
||||
_CONTRADICTORY_HEAD_RE = re.compile(
|
||||
r"detached head\s*/\s*branch\s+master|"
|
||||
r"branch\s+master\s*/\s*detached head|"
|
||||
r"detached head.*branch\s+(?:master|main|dev)\b.*detached|"
|
||||
r"checkout\s*:\s*detached head\s*/\s*branch",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_MAIN_CHECKOUT_BRANCH_RE = re.compile(
|
||||
r"main checkout branch\s*:\s*(\S+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_REVIEW_HEAD_STATE_RE = re.compile(
|
||||
r"review worktree head state\s*:\s*(\S+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_HANDOFF_SECTION_RE = re.compile(
|
||||
r"^##\s*Controller Handoff\s*$",
|
||||
re.I | re.M,
|
||||
)
|
||||
|
||||
|
||||
def _handoff_field_map(report_text: str) -> dict[str, str]:
|
||||
"""Parse ``- Field: value`` lines from the Controller Handoff section."""
|
||||
text = report_text or ""
|
||||
match = _HANDOFF_SECTION_RE.search(text)
|
||||
if not match:
|
||||
return {}
|
||||
section = text[match.end() :]
|
||||
fields: dict[str, str] = {}
|
||||
for line in section.splitlines():
|
||||
stripped = line.strip().lstrip("-*").strip()
|
||||
if ":" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split(":", 1)
|
||||
fields[key.strip().lower()] = value.strip()
|
||||
return fields
|
||||
|
||||
|
||||
def _truthy_field(value: str) -> bool:
|
||||
lowered = (value or "").strip().lower()
|
||||
if not lowered:
|
||||
return False
|
||||
if lowered in {"false", "no", "none", "not applicable", "n/a", "—", "-"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _field_proves_pagination(value: str) -> bool:
|
||||
proof = (value or "").strip()
|
||||
if not proof or proof.lower() in {"none", "n/a", "not applicable", "unknown"}:
|
||||
return False
|
||||
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(proof):
|
||||
return False
|
||||
if _INCOMPLETE_PAGINATION_PROOF.search(proof):
|
||||
return False
|
||||
return bool(_PAGINATION_FINALITY_EVIDENCE.search(proof))
|
||||
|
||||
|
||||
def _pagination_proven(text: str, session: dict, *, pagination_field: str = "") -> bool:
|
||||
if session.get("pagination_complete") or session.get("inventory_complete"):
|
||||
return True
|
||||
session_proof = session.get("inventory_pagination_proof") or ""
|
||||
if _field_proves_pagination(str(session_proof)):
|
||||
return True
|
||||
if _field_proves_pagination(pagination_field):
|
||||
return True
|
||||
if _PAGINATION_FINALITY_EVIDENCE.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _exact_page_limit_unproven(
|
||||
text: str, session: dict, *, pagination_proven: bool = False
|
||||
) -> bool:
|
||||
"""True when a full page was returned without final-page proof."""
|
||||
if pagination_proven:
|
||||
return False
|
||||
requested = session.get("requested_page_size")
|
||||
returned = session.get("returned_page_size")
|
||||
if isinstance(requested, int) and isinstance(returned, int):
|
||||
if returned >= requested > 0:
|
||||
return True
|
||||
for match in _EXACT_PAGE_LIMIT_RE.finditer(text):
|
||||
count = next((g for g in match.groups() if g), None)
|
||||
if not count:
|
||||
continue
|
||||
try:
|
||||
n = int(count)
|
||||
except ValueError:
|
||||
continue
|
||||
if n in {10, 20, 50}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assess_inventory_worktree_report(
|
||||
report_text: str,
|
||||
*,
|
||||
inventory_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate PR inventory pagination proof and worktree field consistency (#293)."""
|
||||
text = report_text or ""
|
||||
session = dict(inventory_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
fields = _handoff_field_map(text)
|
||||
pagination_proof_field = fields.get("inventory pagination proof", "")
|
||||
|
||||
pagination_proven = _pagination_proven(
|
||||
text, session, pagination_field=pagination_proof_field
|
||||
)
|
||||
|
||||
if _ELIGIBILITY_CLAIM_RE.search(text):
|
||||
if not pagination_proven:
|
||||
reasons.append(
|
||||
"oldest/next eligible PR or inventory-complete claim requires "
|
||||
"final-page/no-next-page/pagination_complete proof"
|
||||
)
|
||||
|
||||
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(text):
|
||||
if not pagination_proven:
|
||||
reasons.append(
|
||||
"inventory pagination assumed from default page size without "
|
||||
"final-page/no-next-page/total-count/traversal proof"
|
||||
)
|
||||
|
||||
if pagination_proof_field:
|
||||
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(pagination_proof_field):
|
||||
reasons.append(
|
||||
"Inventory pagination proof field relies on page-size assumption"
|
||||
)
|
||||
elif _INCOMPLETE_PAGINATION_PROOF.search(pagination_proof_field):
|
||||
reasons.append(
|
||||
"Inventory pagination proof field does not prove final page"
|
||||
)
|
||||
elif not _field_proves_pagination(pagination_proof_field):
|
||||
if _ELIGIBILITY_CLAIM_RE.search(text) or _EXACT_PAGE_LIMIT_RE.search(text):
|
||||
reasons.append(
|
||||
"Inventory pagination proof field missing final-page metadata"
|
||||
)
|
||||
|
||||
if _exact_page_limit_unproven(text, session, pagination_proven=pagination_proven):
|
||||
reasons.append(
|
||||
"exactly-full first page returned without final-page or "
|
||||
"no-next-page proof"
|
||||
)
|
||||
|
||||
review_worktree_used = fields.get("review worktree used", "")
|
||||
review_worktree_path = fields.get("review worktree path", "")
|
||||
scratch_used = fields.get("scratch worktree used", "")
|
||||
inside_branches = fields.get("review worktree inside branches:", "") or fields.get(
|
||||
"review worktree inside branches", ""
|
||||
)
|
||||
|
||||
branches_path_in_text = bool(
|
||||
_BRANCHES_WORKTREE_RE.search(review_worktree_path)
|
||||
or _BRANCHES_WORKTREE_RE.search(text)
|
||||
)
|
||||
|
||||
if branches_path_in_text:
|
||||
if review_worktree_used and not _truthy_field(review_worktree_used):
|
||||
reasons.append(
|
||||
"reports using a branches/ review worktree must set "
|
||||
"Review worktree used: true"
|
||||
)
|
||||
if scratch_used and re.search(r"\bfalse\b", scratch_used, re.I):
|
||||
reasons.append(
|
||||
"Scratch worktree used: false rejected when a branches/ "
|
||||
"review worktree was created or used"
|
||||
)
|
||||
if _LEGACY_SCRATCH_FALSE_RE.search(text) and not _truthy_field(
|
||||
review_worktree_used
|
||||
):
|
||||
reasons.append(
|
||||
"legacy Scratch worktree used: false contradicts branches/ "
|
||||
"review worktree usage"
|
||||
)
|
||||
|
||||
if review_worktree_path and _truthy_field(review_worktree_used):
|
||||
if "branches/" not in review_worktree_path.replace("\\", "/").lower():
|
||||
reasons.append(
|
||||
"Review worktree path must be under branches/ when "
|
||||
"Review worktree used is true"
|
||||
)
|
||||
if inside_branches and re.search(r"\bfalse\b", inside_branches, re.I):
|
||||
reasons.append(
|
||||
"Review worktree inside branches must be true when path is "
|
||||
"under branches/"
|
||||
)
|
||||
|
||||
main_branch = (
|
||||
fields.get("main checkout branch", "")
|
||||
or _MAIN_CHECKOUT_BRANCH_RE.search(text).group(1)
|
||||
if _MAIN_CHECKOUT_BRANCH_RE.search(text)
|
||||
else ""
|
||||
)
|
||||
head_state = (
|
||||
fields.get("review worktree head state", "")
|
||||
or (
|
||||
_REVIEW_HEAD_STATE_RE.search(text).group(1)
|
||||
if _REVIEW_HEAD_STATE_RE.search(text)
|
||||
else ""
|
||||
)
|
||||
)
|
||||
if main_branch and head_state:
|
||||
main_norm = main_branch.strip().lower()
|
||||
head_norm = head_state.strip().lower()
|
||||
if head_norm in {"branch", "on branch"} and main_norm in {
|
||||
"master",
|
||||
"main",
|
||||
"dev",
|
||||
}:
|
||||
if "detached" in text.lower() and "review worktree" in text.lower():
|
||||
reasons.append(
|
||||
"review worktree HEAD state must not reuse main checkout "
|
||||
"branch wording"
|
||||
)
|
||||
|
||||
if _CONTRADICTORY_HEAD_RE.search(text):
|
||||
reasons.append(
|
||||
"contradictory checkout wording such as 'Detached HEAD / Branch master'"
|
||||
)
|
||||
|
||||
if review_worktree_used and _truthy_field(review_worktree_used):
|
||||
if not review_worktree_path or review_worktree_path.lower() in {
|
||||
"none",
|
||||
"n/a",
|
||||
"not applicable",
|
||||
}:
|
||||
reasons.append(
|
||||
"Review worktree used: true requires Review worktree path"
|
||||
)
|
||||
if not head_state or head_state.lower() in {
|
||||
"none",
|
||||
"n/a",
|
||||
"not applicable",
|
||||
"unknown",
|
||||
}:
|
||||
reasons.append(
|
||||
"Review worktree used: true requires Review worktree HEAD state"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
"pagination_proven": pagination_proven,
|
||||
"branches_worktree_used": branches_path_in_text,
|
||||
"safe_next_action": (
|
||||
"prove inventory pagination with final-page metadata; align "
|
||||
"Review worktree used/path/HEAD state with branches/ usage"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Merge-simulation worktree mutation verifier for reviewer reports (#317)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_WORKTREE_INDEX_FIELD_RE = re.compile(
|
||||
r"^\s*worktree/index mutations\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_READONLY_DIAG_RE = re.compile(
|
||||
r"read[- ]only diagnostics\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_WORKTREE_PATH_RE = re.compile(
|
||||
r"(?:worktree path|diagnostic worktree|simulation worktree)\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PRE_CLEAN_RE = re.compile(
|
||||
r"(?:pre[- ]simulation|before simulation|clean before).*(?:clean|status)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"(?:merge result|simulation result|conflict status|merge conflict)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ABORT_RE = re.compile(
|
||||
r"(?:merge --abort|abort/reset command|abort command|git merge --abort)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_POST_CLEAN_RE = re.compile(
|
||||
r"(?:post[- ]abort|after abort|clean after).*(?:clean|status)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _command_text(entry: Any) -> str:
|
||||
if isinstance(entry, dict):
|
||||
return str(entry.get("command") or "").strip()
|
||||
return str(entry or "").strip()
|
||||
|
||||
|
||||
def _git_subcommand_line(command: str) -> str | None:
|
||||
tokens = command.split()
|
||||
if not tokens or tokens[0] != "git":
|
||||
return None
|
||||
return " ".join(tokens[1:])
|
||||
|
||||
|
||||
def merge_simulation_commands(command_log: list | None) -> list[str]:
|
||||
"""Return logged commands that perform local merge simulation (#317)."""
|
||||
out: list[str] = []
|
||||
for entry in command_log or []:
|
||||
command = _command_text(entry)
|
||||
rest = _git_subcommand_line(command)
|
||||
if rest is None:
|
||||
continue
|
||||
lower = command.lower()
|
||||
if rest.startswith("merge"):
|
||||
if "--no-commit" in lower or (
|
||||
"--no-ff" in lower and "merge" in lower and "--commit" not in lower
|
||||
):
|
||||
out.append(command)
|
||||
elif "--abort" in lower:
|
||||
out.append(command)
|
||||
return out
|
||||
|
||||
|
||||
def assess_merge_simulation_report(
|
||||
report_text: str,
|
||||
*,
|
||||
command_log: list | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reject merge simulation classified as read-only; require worktree proof (#317)."""
|
||||
text = report_text or ""
|
||||
lower = text.lower()
|
||||
reasons: list[str] = []
|
||||
simulation_commands = merge_simulation_commands(command_log)
|
||||
has_abort = any("--abort" in c.lower() for c in simulation_commands)
|
||||
has_simulation = any(
|
||||
"--no-commit" in c.lower() or (
|
||||
"--no-ff" in c.lower() and "--abort" not in c.lower()
|
||||
)
|
||||
for c in simulation_commands
|
||||
)
|
||||
|
||||
if not simulation_commands:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"simulation_commands": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
field = _WORKTREE_INDEX_FIELD_RE.search(text)
|
||||
value = (field.group(1).strip().lower() if field else "") or ""
|
||||
if not value or value in {"none", "n/a", "no"}:
|
||||
reasons.append(
|
||||
"merge simulation commands ran but report lacks "
|
||||
"'Worktree/index mutations' entry"
|
||||
)
|
||||
elif "merge" not in value and "simulation" not in value:
|
||||
if not any(cmd.lower() in lower for cmd in simulation_commands):
|
||||
reasons.append(
|
||||
"Worktree/index mutations must document merge simulation commands"
|
||||
)
|
||||
|
||||
readonly = _READONLY_DIAG_RE.search(text)
|
||||
if readonly:
|
||||
readonly_value = readonly.group(1)
|
||||
for command in simulation_commands:
|
||||
if command.lower() in readonly_value.lower():
|
||||
reasons.append(
|
||||
"merge simulation may not be listed under read-only diagnostics"
|
||||
)
|
||||
if has_simulation and "merge simulation" in readonly_value.lower():
|
||||
reasons.append(
|
||||
"merge simulation may not be classified as read-only diagnostics"
|
||||
)
|
||||
|
||||
if has_simulation:
|
||||
if not _WORKTREE_PATH_RE.search(text):
|
||||
reasons.append("merge simulation report missing worktree path")
|
||||
if not _PRE_CLEAN_RE.search(text):
|
||||
reasons.append("merge simulation report missing pre-simulation clean status")
|
||||
if not _MERGE_RESULT_RE.search(text):
|
||||
reasons.append("merge simulation report missing merge result/conflict status")
|
||||
if has_abort or has_simulation:
|
||||
if has_abort and not _ABORT_RE.search(text):
|
||||
reasons.append("merge simulation report missing abort/reset command proof")
|
||||
if has_abort and not _POST_CLEAN_RE.search(text):
|
||||
reasons.append("merge simulation report missing post-abort clean status")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"simulation_commands": simulation_commands,
|
||||
"safe_next_action": (
|
||||
"classify merge simulation under Worktree/index mutations with full "
|
||||
"pre/merge/abort/post-clean proof; never list as read-only diagnostics"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Precise mutation category verifier for reviewer controller handoffs (#319)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_CONTROLLER_HANDOFF_RE = re.compile(r"controller handoff", re.IGNORECASE)
|
||||
_WORKSPACE_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*workspace\s+mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_VAGUE_MUTATIONS_NONE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*mutations\s*:\s*none\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_REQUIRED_CATEGORIES = (
|
||||
"file edits by reviewer",
|
||||
"worktree/index mutations",
|
||||
"git ref mutations",
|
||||
)
|
||||
_WORKTREE_FIELD_ALIASES = (
|
||||
"worktree/index mutations",
|
||||
"worktree mutations",
|
||||
)
|
||||
|
||||
|
||||
def _has_category(text: str, category: str) -> bool:
|
||||
pattern = re.compile(
|
||||
rf"^\s*[-*]?\s*{re.escape(category)}\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
return bool(pattern.search(text))
|
||||
|
||||
|
||||
def _has_worktree_category(text: str) -> bool:
|
||||
return any(_has_category(text, alias) for alias in _WORKTREE_FIELD_ALIASES)
|
||||
|
||||
|
||||
def _normalize_for_consistency_check(text: str) -> str:
|
||||
"""Map #319 precise labels to #313 field names for consistency delegation."""
|
||||
normalized = text
|
||||
if _has_category(text, "worktree/index mutations") and not _has_category(
|
||||
text, "worktree mutations"
|
||||
):
|
||||
normalized = re.sub(
|
||||
r"(worktree/index mutations\s*:)",
|
||||
"Worktree mutations:",
|
||||
normalized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def assess_mutation_categories_report(
|
||||
report_text: str,
|
||||
*,
|
||||
handoff_session: dict | None = None,
|
||||
observed_commands: list | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Require precise mutation categories in reviewer controller handoffs (#319)."""
|
||||
text = report_text or ""
|
||||
session = dict(handoff_session or {})
|
||||
reasons: list[str] = []
|
||||
lower = text.lower()
|
||||
|
||||
is_controller = bool(
|
||||
session.get("controller_handoff")
|
||||
or _CONTROLLER_HANDOFF_RE.search(text)
|
||||
)
|
||||
if not is_controller and not session.get("require_categories"):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"controller_handoff": False,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
if _WORKSPACE_MUTATIONS_RE.search(text):
|
||||
reasons.append(
|
||||
"controller handoffs must not use legacy 'Workspace mutations'; "
|
||||
"use precise mutation category fields"
|
||||
)
|
||||
|
||||
if _VAGUE_MUTATIONS_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"controller handoffs must not use vague 'Mutations: none'; "
|
||||
"report each precise category separately"
|
||||
)
|
||||
|
||||
missing = [
|
||||
category
|
||||
for category in _REQUIRED_CATEGORIES
|
||||
if category != "worktree/index mutations" and not _has_category(text, category)
|
||||
]
|
||||
if not _has_worktree_category(text):
|
||||
missing.append("worktree/index mutations")
|
||||
|
||||
if missing:
|
||||
reasons.append(
|
||||
"controller handoff missing precise mutation categories: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
commands = observed_commands or session.get("observed_commands")
|
||||
if commands:
|
||||
from review_proofs import assess_workspace_mutation_consistency
|
||||
|
||||
consistency = assess_workspace_mutation_consistency(
|
||||
_normalize_for_consistency_check(text),
|
||||
commands,
|
||||
)
|
||||
if not consistency.get("complete"):
|
||||
reasons.extend(consistency.get("reasons") or [])
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"controller_handoff": True,
|
||||
"safe_next_action": (
|
||||
"replace Workspace mutations with file edits, worktree/index, git ref, "
|
||||
"and other precise mutation category fields"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
"""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",
|
||||
})
|
||||
|
||||
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:
|
||||
"""Newest non-terminal, unexpired lease for *pr_number*."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
for lease in reversed(_lease_entries(comments, pr_number=pr_number)):
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_PHASES:
|
||||
continue
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
if phase in _ACTIVE_PHASES or phase:
|
||||
lease = dict(lease)
|
||||
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,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when another session holds an active lease."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
existing = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def record_session_lease(lease: dict[str, Any]) -> dict[str, Any]:
|
||||
global _SESSION_LEASE
|
||||
_SESSION_LEASE = dict(lease)
|
||||
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 before {mutation}"
|
||||
)
|
||||
elif session.get("pr_number") != pr_number:
|
||||
reasons.append(
|
||||
f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}"
|
||||
)
|
||||
elif (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,
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Proof-backed reviewer handoff claim verifier (#395)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_PAGINATION_FINALITY = re.compile(
|
||||
r"has_more\s*:\s*false|is_final_page\s*:\s*true|"
|
||||
r"inventory_complete\s*:\s*true|pages_fetched|total_count\s*:",
|
||||
re.I,
|
||||
)
|
||||
_PAGE_SIZE_ONLY = re.compile(
|
||||
r"(?:less|fewer) than (?:the )?(?:default )?page[- ]?size|"
|
||||
r"under (?:the )?50[- ]?(?:item )?limit|"
|
||||
r"returned \d+ (?:open )?prs?.*less than 50",
|
||||
re.I,
|
||||
)
|
||||
_INVENTORY_COMPLETE_CLAIM = re.compile(
|
||||
r"\b(?:inventory (?:is )?complete|inventory exhaustive|"
|
||||
r"complete (?:pr )?inventory)\b",
|
||||
re.I,
|
||||
)
|
||||
_SKIP_CLAIM = re.compile(
|
||||
r"\b(?:earlier prs? skipped|skipped (?:earlier )?pr|"
|
||||
r"skip(?:ped)? pr #?\d+|non-mergeable|prior request.changes)\b",
|
||||
re.I,
|
||||
)
|
||||
_CONFLICT_PROOF = re.compile(
|
||||
r"merge simulation|git merge --no-commit|conflicting files|"
|
||||
r"conflict proof|merge_exit|non-mergeable",
|
||||
re.I,
|
||||
)
|
||||
_BASELINE_CLAIM = re.compile(
|
||||
r"\b(?:baseline (?:validation|worktree|comparison)|"
|
||||
r"same as master|pre-existing (?:on )?master|"
|
||||
r"failure signatures match)\b",
|
||||
re.I,
|
||||
)
|
||||
_BASELINE_PATH = re.compile(r"baseline worktree path\s*:\s*(\S+)", re.I)
|
||||
_BASELINE_SHA = re.compile(r"baseline (?:target )?sha\s*:\s*([0-9a-f]{7,40})", re.I)
|
||||
_BASELINE_DIRTY_BEFORE = re.compile(
|
||||
r"baseline.*dirty before|dirty before.*baseline", re.I
|
||||
)
|
||||
_BASELINE_DIRTY_AFTER = re.compile(
|
||||
r"baseline.*dirty after|dirty after.*baseline", re.I
|
||||
)
|
||||
_BASELINE_COMMAND = re.compile(
|
||||
r"baseline.*(?:validation )?command|pytest.*baseline", re.I
|
||||
)
|
||||
_BASELINE_RESULT = re.compile(
|
||||
r"baseline.*(?:validation )?result|baseline_exit|baseline failures", re.I
|
||||
)
|
||||
_MASTER_INTEGRATION = re.compile(
|
||||
r"\b(?:merged master into|merge(?:d)? (?:remote[- ]tracking )?branch.*master|"
|
||||
r"master integration|integrated master|rebase.*master)\b",
|
||||
re.I,
|
||||
)
|
||||
_CLEANUP_CLAIM = re.compile(
|
||||
r"\b(?:worktree(?:s)? (?:were )?cleaned|cleanup (?:result|mutations)|"
|
||||
r"removed (?:session[- ]owned )?worktree|git worktree remove)\b",
|
||||
re.I,
|
||||
)
|
||||
_WORKTREE_LIST = re.compile(r"git worktree list|worktree list proof", re.I)
|
||||
_PROOF_SOURCE = re.compile(
|
||||
r"proof source\s*:\s*(command|mcp metadata|prior blocker|not checked)",
|
||||
re.I,
|
||||
)
|
||||
_HANDOFF_SECTION = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
|
||||
|
||||
|
||||
def _handoff_fields(report_text: str) -> dict[str, str]:
|
||||
text = report_text or ""
|
||||
match = _HANDOFF_SECTION.search(text)
|
||||
if not match:
|
||||
return {}
|
||||
fields: dict[str, str] = {}
|
||||
for line in text[match.end() :].splitlines():
|
||||
stripped = line.strip().lstrip("-*").strip()
|
||||
if ":" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split(":", 1)
|
||||
fields[key.strip().lower()] = value.strip()
|
||||
return fields
|
||||
|
||||
|
||||
def _command_text(entry: Any) -> str:
|
||||
if isinstance(entry, dict):
|
||||
return str(entry.get("command") or entry.get("tool") or "").strip()
|
||||
return str(entry or "").strip()
|
||||
|
||||
|
||||
def _action_log_has(action_log: list | None, pattern: re.Pattern[str]) -> bool:
|
||||
for entry in action_log or []:
|
||||
blob = " ".join(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
_command_text(entry),
|
||||
str(entry.get("result") or "") if isinstance(entry, dict) else "",
|
||||
str(entry.get("reason") or "") if isinstance(entry, dict) else "",
|
||||
],
|
||||
)
|
||||
)
|
||||
if pattern.search(blob):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assess_proof_backed_handoff_report(
|
||||
report_text: str,
|
||||
*,
|
||||
action_log: list | None = None,
|
||||
inventory_session: dict | None = None,
|
||||
baseline_session: dict | None = None,
|
||||
skip_session: list[dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Require explicit command/tool evidence for proof-sensitive review claims (#395)."""
|
||||
text = report_text or ""
|
||||
fields = _handoff_fields(text)
|
||||
reasons: list[str] = []
|
||||
inventory = dict(inventory_session or {})
|
||||
baseline = dict(baseline_session or {})
|
||||
skips = list(skip_session or [])
|
||||
|
||||
pagination_field = fields.get("inventory pagination proof", "")
|
||||
if _INVENTORY_COMPLETE_CLAIM.search(text) or _PAGE_SIZE_ONLY.search(text):
|
||||
has_meta = bool(
|
||||
inventory.get("inventory_complete")
|
||||
or inventory.get("pagination_complete")
|
||||
or _PAGINATION_FINALITY.search(pagination_field)
|
||||
or _PAGINATION_FINALITY.search(text)
|
||||
or _action_log_has(action_log, re.compile(r"gitea_list_prs", re.I))
|
||||
)
|
||||
if _PAGE_SIZE_ONLY.search(text) and not has_meta:
|
||||
reasons.append(
|
||||
"inventory completeness claimed from page-size assumption only; "
|
||||
"require has_more=false, is_final_page=true, or inventory_complete=true"
|
||||
)
|
||||
elif _INVENTORY_COMPLETE_CLAIM.search(text) and not has_meta:
|
||||
reasons.append(
|
||||
"inventory complete claim lacks explicit pagination metadata proof"
|
||||
)
|
||||
|
||||
if _SKIP_CLAIM.search(text) or fields.get("earlier prs skipped", "").lower() not in {
|
||||
"",
|
||||
"none",
|
||||
"n/a",
|
||||
}:
|
||||
skip_proof = bool(
|
||||
skips
|
||||
or _CONFLICT_PROOF.search(text)
|
||||
or _action_log_has(
|
||||
action_log, re.compile(r"git merge --no-commit|gitea_get_pr_review_feedback", re.I)
|
||||
)
|
||||
)
|
||||
if not skip_proof:
|
||||
reasons.append(
|
||||
"earlier PR skip claim lacks command/tool conflict or blocker proof"
|
||||
)
|
||||
|
||||
if _BASELINE_CLAIM.search(text) or fields.get("baseline worktree used", "").lower() == "true":
|
||||
baseline_ok = bool(
|
||||
baseline.get("complete")
|
||||
or (
|
||||
_BASELINE_PATH.search(text)
|
||||
and _BASELINE_SHA.search(text)
|
||||
and (_BASELINE_DIRTY_BEFORE.search(text) or baseline.get("clean_before"))
|
||||
and (_BASELINE_COMMAND.search(text) or baseline.get("command"))
|
||||
and (_BASELINE_RESULT.search(text) or baseline.get("result"))
|
||||
and (_BASELINE_DIRTY_AFTER.search(text) or baseline.get("clean_after"))
|
||||
)
|
||||
)
|
||||
if not baseline_ok:
|
||||
reasons.append(
|
||||
"baseline validation claim missing worktree path, target SHA, "
|
||||
"dirty-before/after status, command, or result proof"
|
||||
)
|
||||
|
||||
if _MASTER_INTEGRATION.search(text):
|
||||
if not _action_log_has(
|
||||
action_log,
|
||||
re.compile(r"git merge.*master|git rebase.*master", re.I),
|
||||
) and not re.search(r"merge_exit\s*=\s*\d+|merge simulation", text, re.I):
|
||||
reasons.append(
|
||||
"master integration claim lacks exact merge/rebase command and result"
|
||||
)
|
||||
|
||||
if _CLEANUP_CLAIM.search(text) or fields.get("cleanup mutations", "").lower() not in {
|
||||
"",
|
||||
"none",
|
||||
"n/a",
|
||||
}:
|
||||
if not (_WORKTREE_LIST.search(text) or _action_log_has(action_log, _WORKTREE_LIST)):
|
||||
reasons.append(
|
||||
"cleanup claim lacks final git worktree list or equivalent proof"
|
||||
)
|
||||
|
||||
if re.search(r"\blive proof\b", text, re.I) and not _PROOF_SOURCE.search(text):
|
||||
if not action_log:
|
||||
reasons.append(
|
||||
"live proof wording requires proof source classification "
|
||||
"(command, MCP metadata, prior blocker, or not checked)"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"cite explicit command/tool evidence or structured MCP pagination metadata "
|
||||
"for each proof-sensitive claim"
|
||||
if not proven
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Reconciliation PR inventory pagination proof verifier (#308)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_COMPLETE_SCAN_CLAIM_RE = re.compile(
|
||||
r"\b(?:all already[- ]landed(?:\s+prs?)?(?:\s+were)?\s+found|"
|
||||
r"complete queue scan|all open prs? (?:were )?(?:found|checked|inventoried|listed)|"
|
||||
r"inventory (?:is )?complete|exhaustive (?:pr )?inventory|"
|
||||
r"no additional already[- ]landed|scanned (?:the )?(?:full )?open pr queue)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_FINALITY_RE = re.compile(
|
||||
r"is_final_page\s*:\s*true|has_more\s*:\s*false|no next page|final[- ]page|"
|
||||
r"pagination_complete\s*:\s*true|inventory pagination proof\s*:\s*(?!assumed|none\b).+",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_REQUESTED_PAGE_SIZE_RE = re.compile(
|
||||
r"requested page size\s*:\s*(\d+)|page[- ]?size\s*(?:=|:)\s*(\d+)",
|
||||
re.I,
|
||||
)
|
||||
_PAGES_FETCHED_RE = re.compile(
|
||||
r"pages? fetched\s*:\s*(\d+)|page count\s*:\s*(\d+)",
|
||||
re.I,
|
||||
)
|
||||
_RETURNED_PER_PAGE_RE = re.compile(
|
||||
r"returned pr count(?: per page)?\s*:|open pr count per page|"
|
||||
r"returned \d+ open prs? per page|per[- ]page counts?\s*:",
|
||||
re.I,
|
||||
)
|
||||
_EXACT_LIMIT_RETURN_RE = re.compile(
|
||||
r"returned\s+(\d+)\s+open prs?|listed\s+(\d+)\s+open prs?",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_DEFAULT_PAGE_SIZE_ASSUMPTION = re.compile(
|
||||
r"(?:less|fewer) than (?:the )?(?:default )?(?:gitea )?page[- ]?(?:size|limit)|"
|
||||
r"default (?:gitea )?page[- ]?size|assumed complete",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _int_from_groups(match: re.Match[str] | None) -> int | None:
|
||||
if not match:
|
||||
return None
|
||||
for group in match.groups():
|
||||
if group:
|
||||
return int(group)
|
||||
return None
|
||||
|
||||
|
||||
def _pagination_proven(text: str, session: dict) -> bool:
|
||||
if session.get("pagination_complete") or session.get("inventory_complete"):
|
||||
return True
|
||||
if _FINALITY_RE.search(text):
|
||||
return True
|
||||
pages = session.get("pages_fetched")
|
||||
if isinstance(pages, int) and pages >= 1 and session.get("is_final_page"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _metadata_present(text: str, session: dict) -> list[str]:
|
||||
missing: list[str] = []
|
||||
has_page_size = bool(
|
||||
_REQUESTED_PAGE_SIZE_RE.search(text)
|
||||
or session.get("requested_page_size")
|
||||
)
|
||||
has_pages_fetched = bool(
|
||||
_PAGES_FETCHED_RE.search(text) or session.get("pages_fetched")
|
||||
)
|
||||
has_returned_counts = bool(
|
||||
_RETURNED_PER_PAGE_RE.search(text) or session.get("returned_per_page")
|
||||
)
|
||||
if not has_page_size:
|
||||
missing.append("requested page size")
|
||||
if not has_pages_fetched:
|
||||
missing.append("page count fetched")
|
||||
if not has_returned_counts:
|
||||
missing.append("returned PR count per page")
|
||||
if not _pagination_proven(text, session):
|
||||
missing.append("final-page/no-next-page proof")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_reconcile_inventory_report(
|
||||
report_text: str,
|
||||
*,
|
||||
inventory_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate PR inventory pagination proof in reconciliation reports (#308)."""
|
||||
text = report_text or ""
|
||||
session = dict(inventory_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
claims_scan = bool(_COMPLETE_SCAN_CLAIM_RE.search(text))
|
||||
lists_open_prs = bool(
|
||||
re.search(r"open prs? listed|listed open prs?|pr inventory", text, re.I)
|
||||
)
|
||||
|
||||
if not claims_scan and not lists_open_prs and not session.get("inventory_required"):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"inventory_claimed": False,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(text) and not _pagination_proven(text, session):
|
||||
reasons.append(
|
||||
"reconciliation inventory assumed complete from page-size guess "
|
||||
"without final-page proof (#308)"
|
||||
)
|
||||
|
||||
if claims_scan and not _pagination_proven(text, session):
|
||||
reasons.append(
|
||||
"complete queue scan or all already-landed claim requires "
|
||||
"pagination finality proof (#308)"
|
||||
)
|
||||
|
||||
missing = _metadata_present(text, session)
|
||||
if (claims_scan or lists_open_prs) and missing:
|
||||
reasons.append(
|
||||
"reconciliation inventory missing pagination metadata: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
requested = session.get("requested_page_size")
|
||||
returned = session.get("returned_page_size")
|
||||
if isinstance(requested, int) and isinstance(returned, int):
|
||||
if returned >= requested > 0 and not _pagination_proven(text, session):
|
||||
reasons.append(
|
||||
"exactly-full first reconciliation page without final-page proof (#308)"
|
||||
)
|
||||
else:
|
||||
for match in _EXACT_LIMIT_RETURN_RE.finditer(text):
|
||||
count = _int_from_groups(match)
|
||||
if count in {10, 20, 50} and not _pagination_proven(text, session):
|
||||
reasons.append(
|
||||
"exactly-full first reconciliation page without final-page proof (#308)"
|
||||
)
|
||||
break
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
"inventory_claimed": claims_scan or lists_open_prs,
|
||||
"pagination_proven": _pagination_proven(text, session),
|
||||
"safe_next_action": (
|
||||
"include requested page size, pages fetched, per-page counts, and "
|
||||
"final-page/no-next-page proof before claiming complete scan"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Reconciliation linked-issue live proof verifier (#300)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
|
||||
|
||||
_LINKED_ISSUE_FIELD_RE = re.compile(
|
||||
r"linked issue\s*:\s*(.+)$",
|
||||
re.I | re.M,
|
||||
)
|
||||
_LINKED_ISSUE_STATUS_RE = re.compile(
|
||||
r"linked issue(?:\s+live)?\s+status\s*:\s*(.+)$",
|
||||
re.I | re.M,
|
||||
)
|
||||
|
||||
_STATUS_CLAIM_RE = re.compile(
|
||||
r"\b(?:issue\s+#?\d+\s*\()?(open|closed|resolved)\b|"
|
||||
r"issue\s+(?:is\s+)?(open|closed|resolved)\b|"
|
||||
r"linked issue.*\b(open|closed|resolved)\b",
|
||||
re.I,
|
||||
)
|
||||
_NOT_VERIFIED_RE = re.compile(r"not verified in this session", re.I)
|
||||
|
||||
_LIVE_FETCH_PROOF_RE = re.compile(
|
||||
r"gitea_view_issue|issue fetched live|live issue fetch|"
|
||||
r"fetched linked issue.*(?:current|this) session|"
|
||||
r"linked issue (?:fetch|proof).*(?:current|this) session|"
|
||||
r"live linked issue proof",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_ISSUE_ACTION_RECOMMEND_RE = re.compile(
|
||||
r"(?:recommend(?:ed)?|should|must)\s+(?:close|reopen|comment on)\s+"
|
||||
r"(?:the\s+)?(?:linked\s+)?issue|"
|
||||
r"(?:close|reopen|comment on)\s+linked issue\s+#?\d+",
|
||||
re.I,
|
||||
)
|
||||
_CAPABILITY_PROOF_RE = re.compile(
|
||||
r"capability proof|exact capability|gitea_close_issue|"
|
||||
r"gitea_mark_issue|gitea_create_issue_comment|gitea_edit_issue",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_NO_LINKED_ISSUE_VALUES = frozenset({
|
||||
"",
|
||||
"none",
|
||||
"n/a",
|
||||
"not applicable",
|
||||
"no linked issue",
|
||||
"unknown",
|
||||
})
|
||||
|
||||
|
||||
def _handoff_field_map(report_text: str) -> dict[str, str]:
|
||||
text = report_text or ""
|
||||
match = _HANDOFF_SECTION_RE.search(text)
|
||||
if not match:
|
||||
return {}
|
||||
fields: dict[str, str] = {}
|
||||
for line in text[match.end() :].splitlines():
|
||||
stripped = line.strip().lstrip("-*").strip()
|
||||
if ":" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split(":", 1)
|
||||
fields[key.strip().lower()] = value.strip()
|
||||
return fields
|
||||
|
||||
|
||||
def _extract_linked_issue_number(text: str, fields: dict[str, str]) -> int | None:
|
||||
linked = fields.get("linked issue", "")
|
||||
if linked.lower() in _NO_LINKED_ISSUE_VALUES:
|
||||
return None
|
||||
match = re.search(r"#?(\d+)", linked)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
field_match = _LINKED_ISSUE_FIELD_RE.search(text)
|
||||
if field_match:
|
||||
num = re.search(r"#?(\d+)", field_match.group(1))
|
||||
if num:
|
||||
return int(num.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _status_value(text: str, fields: dict[str, str]) -> str:
|
||||
for key in ("linked issue live status", "linked issue status"):
|
||||
if fields.get(key):
|
||||
return fields[key]
|
||||
match = _LINKED_ISSUE_STATUS_RE.search(text)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
|
||||
def _live_proof_present(text: str, session: dict, issue_number: int | None) -> bool:
|
||||
if session.get("live_fetched") or session.get("verified_live"):
|
||||
return True
|
||||
if session.get("issue_fetch_proof"):
|
||||
return True
|
||||
if _LIVE_FETCH_PROOF_RE.search(text):
|
||||
return True
|
||||
if issue_number is not None:
|
||||
pattern = re.compile(
|
||||
rf"gitea_view_issue.*#?{issue_number}|"
|
||||
rf"issue\s+#?{issue_number}.*(?:fetched|viewed).*(?:current|this) session",
|
||||
re.I,
|
||||
)
|
||||
if pattern.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assess_reconcile_linked_issue_report(
|
||||
report_text: str,
|
||||
*,
|
||||
reconcile_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate linked issue status claims in reconciliation handoffs (#300)."""
|
||||
text = report_text or ""
|
||||
session = dict(reconcile_session or {})
|
||||
fields = _handoff_field_map(text)
|
||||
reasons: list[str] = []
|
||||
|
||||
issue_number = session.get("linked_issue_number")
|
||||
if issue_number is None:
|
||||
issue_number = _extract_linked_issue_number(text, fields)
|
||||
|
||||
if issue_number is None:
|
||||
status = _status_value(text, fields)
|
||||
if status and status.lower() not in _NO_LINKED_ISSUE_VALUES:
|
||||
if _STATUS_CLAIM_RE.search(status):
|
||||
reasons.append(
|
||||
"linked issue status reported without identifying the linked issue (#300)"
|
||||
)
|
||||
if not reasons:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"linked_issue_number": None,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
status = _status_value(text, fields)
|
||||
status_lower = status.lower()
|
||||
|
||||
if session.get("issue_missing"):
|
||||
if status_lower and "not verified" not in status_lower:
|
||||
reasons.append(
|
||||
"missing linked issue must be reported as "
|
||||
"'not verified in this session' (#300)"
|
||||
)
|
||||
elif status:
|
||||
if _NOT_VERIFIED_RE.search(status):
|
||||
pass
|
||||
elif _STATUS_CLAIM_RE.search(status):
|
||||
if not _live_proof_present(text, session, issue_number):
|
||||
reasons.append(
|
||||
"linked issue open/closed/resolved status requires live "
|
||||
"gitea_view_issue proof in the current session (#300)"
|
||||
)
|
||||
elif status_lower not in _NO_LINKED_ISSUE_VALUES:
|
||||
if not _live_proof_present(text, session, issue_number):
|
||||
reasons.append(
|
||||
"linked issue status claim requires live fetch proof or "
|
||||
"'not verified in this session' (#300)"
|
||||
)
|
||||
|
||||
if _ISSUE_ACTION_RECOMMEND_RE.search(text):
|
||||
if not _CAPABILITY_PROOF_RE.search(text) and not session.get(
|
||||
"issue_action_capability_proven"
|
||||
):
|
||||
reasons.append(
|
||||
"recommended linked issue close/reopen/comment requires "
|
||||
"exact capability proof (#300)"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
"linked_issue_number": issue_number,
|
||||
"live_proof_present": _live_proof_present(text, session, issue_number),
|
||||
"safe_next_action": (
|
||||
"fetch linked issue with gitea_view_issue in this session or "
|
||||
"report 'Linked issue status: not verified in this session'"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -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"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Transient validation failure history verifier (#396).
|
||||
|
||||
Reviewer sessions may observe validation failures that later pass on rerun.
|
||||
Final reports must document every failure observed during the session, not
|
||||
only the last passing result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_SECTION_RE = re.compile(
|
||||
r"validation failure history",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FAILURE_ENTRY_RE = re.compile(
|
||||
r"(?:failure\s*(?:#|entry)?\s*\d+|validation failure)\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_COMMAND_RE = re.compile(
|
||||
r"(?:command|validation command)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FAILING_TEST_RE = re.compile(
|
||||
r"(?:failing test|failure|error)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CAUSE_RE = re.compile(
|
||||
r"(?:suspected cause|cause)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REPRODUCED_RE = re.compile(
|
||||
r"(?:reproduced|reproduces)\s*:\s*(yes|no|unknown)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BASELINE_RE = re.compile(
|
||||
r"(?:on baseline master|baseline master|exists on baseline)\s*:\s*(yes|no|unknown|not checked)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_CAUSED_RE = re.compile(
|
||||
r"(?:pr[- ]caused|pr caused)\s*:\s*(yes|no|unknown|not proven)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TRANSIENT_STATUS_RE = re.compile(
|
||||
r"(?:passed after transient failure investigation|transient failure investigation)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PLAIN_PASS_RE = re.compile(
|
||||
r"validation\s*:\s*(?:pass|passed|strong|ok|green)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ENV_CLEANUP_RE = re.compile(
|
||||
r"(?:environmental cleanup|state cleaned|what changed between runs)\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_UNKNOWN_CAUSE_RE = re.compile(
|
||||
r"(?:suspected cause|cause)\s*:\s*(?:unknown|unexplained|not determined)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _failure_documented_in_text(text: str, failure: dict[str, Any]) -> bool:
|
||||
"""Return True when *failure* appears documented in free-form report text."""
|
||||
command = (failure.get("command") or "").strip()
|
||||
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
|
||||
if command and command not in text:
|
||||
return False
|
||||
if failing and failing not in text:
|
||||
return False
|
||||
return bool(command or failing)
|
||||
|
||||
|
||||
def _section_has_structured_fields(text: str) -> bool:
|
||||
if not _SECTION_RE.search(text):
|
||||
return False
|
||||
section_start = _SECTION_RE.search(text).start()
|
||||
section = text[section_start:]
|
||||
has_command = bool(_COMMAND_RE.search(section))
|
||||
has_failure = bool(_FAILING_TEST_RE.search(section))
|
||||
return has_command and has_failure
|
||||
|
||||
|
||||
def assess_validation_failure_history_report(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Require final reports to account for transient validation failures (#396)."""
|
||||
text = report_text or ""
|
||||
session = dict(validation_session or {})
|
||||
reasons: list[str] = []
|
||||
violations: list[str] = []
|
||||
|
||||
observed = list(session.get("observed_failures") or [])
|
||||
final_status = (session.get("final_validation_status") or "").strip().lower()
|
||||
cause_unknown = any(
|
||||
(f.get("suspected_cause") or "").strip().lower() in {
|
||||
"unknown", "unexplained", "not determined", ""
|
||||
}
|
||||
and not (f.get("pr_caused") or "").strip().lower() in {"yes", "no"}
|
||||
for f in observed
|
||||
) or bool(session.get("cause_unknown"))
|
||||
|
||||
if not observed:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"violations": [],
|
||||
"observed_failure_count": 0,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
documented = _section_has_structured_fields(text)
|
||||
if not documented:
|
||||
for failure in observed:
|
||||
if _failure_documented_in_text(text, failure):
|
||||
documented = True
|
||||
break
|
||||
|
||||
if not documented:
|
||||
violations.append(
|
||||
"session observed validation failure(s) but final report omits "
|
||||
"Validation failure history"
|
||||
)
|
||||
reasons.append(
|
||||
"every validation failure observed during the session must appear "
|
||||
"in a Validation failure history section"
|
||||
)
|
||||
|
||||
if documented and not _SECTION_RE.search(text):
|
||||
reasons.append(
|
||||
"failure details must appear under an explicit "
|
||||
"'Validation failure history' heading"
|
||||
)
|
||||
|
||||
for idx, failure in enumerate(observed, start=1):
|
||||
prefix = f"failure #{idx}"
|
||||
if not _failure_documented_in_text(text, failure) and documented:
|
||||
reasons.append(
|
||||
f"{prefix}: command and failing test/error not documented in report"
|
||||
)
|
||||
command = (failure.get("command") or "").strip()
|
||||
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
|
||||
if not command:
|
||||
reasons.append(f"{prefix}: missing command in session failure record")
|
||||
if not failing:
|
||||
reasons.append(
|
||||
f"{prefix}: missing failing test or error in session failure record"
|
||||
)
|
||||
|
||||
plain_pass = bool(_PLAIN_PASS_RE.search(text))
|
||||
transient_wording = bool(_TRANSIENT_STATUS_RE.search(text))
|
||||
final_passed = final_status in {
|
||||
"passed",
|
||||
"pass",
|
||||
"passed_after_transient_failure_investigation",
|
||||
}
|
||||
|
||||
if (final_passed or plain_pass) and observed:
|
||||
if cause_unknown and not transient_wording:
|
||||
violations.append(
|
||||
"unknown transient failure cause cannot be erased as plain 'passed'"
|
||||
)
|
||||
reasons.append(
|
||||
"when cause is unknown, use status "
|
||||
"'passed after transient failure investigation'"
|
||||
)
|
||||
elif not transient_wording and final_status != "passed_after_transient_failure_investigation":
|
||||
if not _ENV_CLEANUP_RE.search(text):
|
||||
reasons.append(
|
||||
"later passing rerun must document what changed between runs "
|
||||
"or environmental cleanup performed"
|
||||
)
|
||||
|
||||
if session.get("environmental_contamination") and not _ENV_CLEANUP_RE.search(text):
|
||||
reasons.append(
|
||||
"environmental /tmp state contamination must document cleanup or "
|
||||
"what changed before the passing rerun"
|
||||
)
|
||||
|
||||
proven = not reasons and not violations
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": bool(violations) or not proven,
|
||||
"reasons": reasons,
|
||||
"violations": violations,
|
||||
"observed_failure_count": len(observed),
|
||||
"documented": documented,
|
||||
"safe_next_action": (
|
||||
"add Validation failure history with command, failing test, cause, "
|
||||
"reproduction, baseline comparison, and PR-caused evidence; use "
|
||||
"'passed after transient failure investigation' when appropriate"
|
||||
if not proven
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"""PR-head vs diagnostic validation integrity verifier (#316)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_DIAGNOSTIC_LABEL_RE = re.compile(
|
||||
r"diagnostic local experiment|not pr-head validation|diagnostic-only validation",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OFFICIAL_VALIDATION_RE = re.compile(
|
||||
r"(?:official validation|pr-head validation|validation integrity)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OFFICIAL_COMMAND_RE = re.compile(
|
||||
r"(?:official validation command|pr-head validation command|validation command)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OFFICIAL_RESULT_RE = re.compile(
|
||||
r"(?:official validation result|pr-head validation result|validation result|validation integrity status)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DIRTY_AFTER_RE = re.compile(
|
||||
r"(?:worktree dirty after (?:official )?validation|dirty (?:state )?after (?:official )?validation|"
|
||||
r"validation worktree dirty after)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DIAGNOSTIC_EDIT_RE = re.compile(
|
||||
r"(?:diagnostic edit(?:ed)? path|file edits by reviewer|diagnostic local edit)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DIAGNOSTIC_COMMAND_RE = re.compile(
|
||||
r"(?:diagnostic (?:validation )?command|diagnostic test run|diagnostic result)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SUGGESTED_FIX_RE = re.compile(
|
||||
r"(?:diagnostic results? (?:were )?used only for suggested fix|suggested fix only|"
|
||||
r"not used as official validation)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_INTEGRITY_STATUS_RE = re.compile(
|
||||
r"validation integrity status\s*:\s*(passed|failed|not run|contaminated)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_POST_EDIT_AS_OFFICIAL_RE = re.compile(
|
||||
r"(?:official validation(?:\s+result)?\s*:\s*pass|validation passed after (?:local )?edit|"
|
||||
r"full suite passed after diagnostic edit)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _performed_edits(action_log: list[dict] | None) -> list[str]:
|
||||
paths: list[str] = []
|
||||
for entry in action_log or []:
|
||||
if entry.get("gated_rejected") or entry.get("performed") is False:
|
||||
continue
|
||||
path = entry.get("path")
|
||||
if path and entry.get("kind", "file_edit") in {"file_edit", "edit", "write"}:
|
||||
paths.append(str(path))
|
||||
return paths
|
||||
|
||||
|
||||
def assess_validation_integrity_report(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
action_log: list[dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Separate official PR-head validation from diagnostic edited-worktree tests (#316)."""
|
||||
text = report_text or ""
|
||||
session = dict(validation_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
diagnostic_edits = list(session.get("diagnostic_edits") or [])
|
||||
if not diagnostic_edits:
|
||||
diagnostic_edits = _performed_edits(action_log)
|
||||
diagnostic_ran = bool(
|
||||
session.get("diagnostic_validation_ran")
|
||||
or session.get("diagnostic_runs")
|
||||
)
|
||||
official_ran = bool(session.get("official_validation_ran", True))
|
||||
official_result = (session.get("official_result") or "").strip().lower()
|
||||
diagnostic_result = (session.get("diagnostic_result") or "").strip().lower()
|
||||
dirty_after = session.get("worktree_dirty_after_official")
|
||||
contaminated = bool(session.get("contaminated"))
|
||||
|
||||
if official_ran:
|
||||
if not _OFFICIAL_VALIDATION_RE.search(text) and not _OFFICIAL_COMMAND_RE.search(text):
|
||||
reasons.append("report missing official PR-head validation section")
|
||||
if not _OFFICIAL_COMMAND_RE.search(text) and "validation:" not in text.lower():
|
||||
reasons.append("report missing official validation command")
|
||||
if not _OFFICIAL_RESULT_RE.search(text):
|
||||
reasons.append("report missing official validation result or integrity status")
|
||||
if dirty_after is not None and not _DIRTY_AFTER_RE.search(text):
|
||||
reasons.append(
|
||||
"report missing worktree dirty state after official validation"
|
||||
)
|
||||
elif dirty_after is None and official_ran and not _DIRTY_AFTER_RE.search(text):
|
||||
reasons.append(
|
||||
"report missing worktree dirty state after official validation"
|
||||
)
|
||||
|
||||
if diagnostic_edits or diagnostic_ran:
|
||||
if not _DIAGNOSTIC_LABEL_RE.search(text):
|
||||
reasons.append(
|
||||
"post-edit diagnostic runs must be labeled "
|
||||
"'Diagnostic local experiment — not PR-head validation'"
|
||||
)
|
||||
if diagnostic_edits and not _DIAGNOSTIC_EDIT_RE.search(text):
|
||||
reasons.append("report missing diagnostic edit path")
|
||||
if diagnostic_ran and not _DIAGNOSTIC_COMMAND_RE.search(text):
|
||||
reasons.append("report missing diagnostic command/result")
|
||||
if not _SUGGESTED_FIX_RE.search(text):
|
||||
reasons.append(
|
||||
"report must state diagnostic results were used only for suggested fix"
|
||||
)
|
||||
if _POST_EDIT_AS_OFFICIAL_RE.search(text):
|
||||
reasons.append(
|
||||
"post-edit diagnostic results cannot be reported as official PR-head validation"
|
||||
)
|
||||
if diagnostic_result == "pass" and official_result == "fail":
|
||||
if "request_changes" not in text.lower() and "failed" not in text.lower():
|
||||
reasons.append(
|
||||
"request-changes must cite unmodified PR-head failure, not diagnostic pass"
|
||||
)
|
||||
|
||||
if contaminated:
|
||||
status = _INTEGRITY_STATUS_RE.search(text)
|
||||
if not status or "contaminated" not in status.group(1).lower():
|
||||
reasons.append(
|
||||
"contaminated validation must report integrity status "
|
||||
"'contaminated — recovery required'"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"diagnostic_edits": diagnostic_edits,
|
||||
"safe_next_action": (
|
||||
"separate official PR-head validation from diagnostic experiments; "
|
||||
"report dirty-after-validation state"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"""PR validation worktree read-only edit verifier (#315)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_VALIDATION_WORKTREE_RE = re.compile(
|
||||
r"branches/(?:review-pr\d+[\w/-]*|review-[\w-]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DIAGNOSTIC_WORKTREE_RE = re.compile(
|
||||
r"branches/(?:diagnostic[\w/-]*|scratch[\w/-]*)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FILE_EDITS_NONE_RE = re.compile(
|
||||
r"file edits by reviewer\s*:\s*none\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FILE_EDITS_FIELD_RE = re.compile(
|
||||
r"file edits by reviewer\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VALIDATION_WORKTREE_PATH_RE = re.compile(
|
||||
r"(?:validation worktree path|review worktree path)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DIAGNOSTIC_WORKTREE_PATH_RE = re.compile(
|
||||
r"(?:diagnostic (?:scratch )?worktree path|diagnostic worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DIAGNOSTIC_LABEL_RE = re.compile(
|
||||
r"diagnostic local experiment|not pr-head validation|diagnostic-only",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OFFICIAL_VALIDATION_RE = re.compile(
|
||||
r"(?:official (?:pr-head )?validation|pr-head validation result)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_POST_EDIT_OFFICIAL_RE = re.compile(
|
||||
r"(?:official validation(?:\s+result)?\s*:\s*pass|validation passed after (?:local )?edit)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_PERFORMED_EDIT_KINDS = frozenset({"file_edit", "edit", "write", "edited", "created", "wrote"})
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").strip()
|
||||
|
||||
|
||||
def _is_validation_worktree(path: str) -> bool:
|
||||
return bool(_VALIDATION_WORKTREE_RE.search(_normalize_path(path)))
|
||||
|
||||
|
||||
def _is_diagnostic_worktree(path: str) -> bool:
|
||||
normalized = _normalize_path(path)
|
||||
return bool(
|
||||
_DIAGNOSTIC_WORKTREE_RE.search(normalized)
|
||||
or "diagnostic" in normalized.lower()
|
||||
)
|
||||
|
||||
|
||||
def _performed_edits(action_log: list[dict] | None) -> list[dict[str, str]]:
|
||||
edits: list[dict[str, str]] = []
|
||||
for entry in action_log or []:
|
||||
if entry.get("gated_rejected") or entry.get("performed") is False:
|
||||
continue
|
||||
kind = (entry.get("kind") or entry.get("action") or "file_edit").strip().lower()
|
||||
if kind not in _PERFORMED_EDIT_KINDS:
|
||||
continue
|
||||
path = (entry.get("path") or "").strip()
|
||||
if not path:
|
||||
continue
|
||||
worktree = _normalize_path(str(entry.get("worktree_path") or entry.get("cwd") or ""))
|
||||
edits.append({"path": path, "worktree_path": worktree})
|
||||
return edits
|
||||
|
||||
|
||||
def _extract_validation_path(text: str, session: dict) -> str:
|
||||
path = _normalize_path(str(session.get("validation_worktree_path") or ""))
|
||||
if path:
|
||||
return path
|
||||
match = _VALIDATION_WORKTREE_PATH_RE.search(text or "")
|
||||
return _normalize_path(match.group(1)) if match else ""
|
||||
|
||||
|
||||
def _extract_diagnostic_path(text: str, session: dict) -> str:
|
||||
path = _normalize_path(str(session.get("diagnostic_worktree_path") or ""))
|
||||
if path:
|
||||
return path
|
||||
match = _DIAGNOSTIC_WORKTREE_PATH_RE.search(text or "")
|
||||
return _normalize_path(match.group(1)) if match else ""
|
||||
|
||||
|
||||
def assess_validation_worktree_edit_report(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
action_log: list[dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Block edits in PR validation worktrees; require diagnostic separation (#315)."""
|
||||
text = report_text or ""
|
||||
session = dict(validation_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
validation_path = _extract_validation_path(text, session)
|
||||
diagnostic_path = _extract_diagnostic_path(text, session)
|
||||
validation_edits = list(session.get("validation_worktree_edits") or [])
|
||||
diagnostic_edits = list(session.get("diagnostic_edits") or [])
|
||||
edits = _performed_edits(action_log)
|
||||
|
||||
if not validation_edits:
|
||||
for entry in edits:
|
||||
wt = entry.get("worktree_path") or validation_path
|
||||
if wt and _is_validation_worktree(wt) and not _is_diagnostic_worktree(wt):
|
||||
validation_edits.append(entry["path"])
|
||||
|
||||
if not diagnostic_edits:
|
||||
for entry in edits:
|
||||
wt = entry.get("worktree_path") or ""
|
||||
if wt and _is_diagnostic_worktree(wt):
|
||||
diagnostic_edits.append(entry["path"])
|
||||
elif entry["path"] and diagnostic_path and not validation_edits:
|
||||
if _is_diagnostic_worktree(diagnostic_path):
|
||||
diagnostic_edits.append(entry["path"])
|
||||
|
||||
claimed_none = bool(_FILE_EDITS_NONE_RE.search(text))
|
||||
any_edits = bool(validation_edits or diagnostic_edits or edits)
|
||||
|
||||
if validation_edits:
|
||||
reasons.append(
|
||||
"reviewer must not edit files in the PR validation worktree; "
|
||||
f"observed edits: {', '.join(validation_edits)}"
|
||||
)
|
||||
|
||||
if diagnostic_edits or session.get("diagnostic_experiment"):
|
||||
if not diagnostic_path and not _DIAGNOSTIC_WORKTREE_PATH_RE.search(text):
|
||||
reasons.append(
|
||||
"diagnostic experiments require a separate diagnostic scratch worktree path"
|
||||
)
|
||||
if not _DIAGNOSTIC_LABEL_RE.search(text):
|
||||
reasons.append(
|
||||
"diagnostic experiments must be labeled "
|
||||
"'Diagnostic local experiment — not PR-head validation'"
|
||||
)
|
||||
if not _FILE_EDITS_FIELD_RE.search(text) or claimed_none:
|
||||
reasons.append(
|
||||
"diagnostic edits must be reported under 'File edits by reviewer'"
|
||||
)
|
||||
|
||||
if any_edits and claimed_none:
|
||||
reasons.append(
|
||||
"report claims 'File edits by reviewer: none' but reviewer file edits occurred"
|
||||
)
|
||||
|
||||
if session.get("official_validation_after_edit") or _POST_EDIT_OFFICIAL_RE.search(text):
|
||||
if validation_edits or (validation_path and diagnostic_edits):
|
||||
reasons.append(
|
||||
"post-edit test results cannot be reported as official PR-head validation"
|
||||
)
|
||||
|
||||
if validation_edits and _OFFICIAL_VALIDATION_RE.search(text):
|
||||
if not _DIAGNOSTIC_LABEL_RE.search(text):
|
||||
reasons.append(
|
||||
"validation worktree was edited; official PR-head validation is no longer valid"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"validation_worktree_path": validation_path or None,
|
||||
"diagnostic_worktree_path": diagnostic_path or None,
|
||||
"validation_worktree_edits": validation_edits,
|
||||
"diagnostic_edits": diagnostic_edits,
|
||||
"safe_next_action": (
|
||||
"keep PR validation worktrees read-only; use a separate diagnostic "
|
||||
"scratch worktree and report all reviewer file edits"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Reviewer worktree ownership and safe-reuse proof verifier (#312)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_SESSION_OWNED_RE = re.compile(
|
||||
r"branches/(?:review-pr\d+[\w/-]*|review-[\w-]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_PATH_RE = re.compile(
|
||||
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCHES_PATH_RE = re.compile(r"/branches/|\bbranches/", re.IGNORECASE)
|
||||
_MAIN_CHECKOUT_RE = re.compile(
|
||||
r"main checkout|not (?:the )?main checkout|outside branches/",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SAFE_REUSE_RE = re.compile(r"safe[- ]reuse proof", re.IGNORECASE)
|
||||
_REUSE_POLICY_RE = re.compile(
|
||||
r"(?:project policy|policy) (?:allows|allowing) (?:reuse|reset)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEAN_TRACKED_RE = re.compile(
|
||||
r"(?:clean tracked state|tracked state\s*:\s*clean|no uncommitted tracked)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEAN_UNTRACKED_RE = re.compile(
|
||||
r"(?:clean untracked state|untracked state\s*:\s*clean|no untracked files)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NOT_OTHER_SESSION_RE = re.compile(
|
||||
r"not owned by (?:another|other) (?:active )?(?:task|session)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCH_BEFORE_RESET_RE = re.compile(
|
||||
r"(?:branch/head before reset|head before reset|branch before reset)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RESET_TARGET_RE = re.compile(
|
||||
r"(?:reset target sha|reset target)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DESTRUCTIVE_CMD_RE = re.compile(
|
||||
r"\bgit\b(?:\s+(?:-C\s+\S+\s+)?)?"
|
||||
r"(?:reset\s+--hard|clean(?:\s+-[A-Za-z]+)*|checkout\s+(?!HEAD\s+--)|switch\s+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKSPACE_NONE_RE = re.compile(r"workspace mutations\s*:\s*none", re.IGNORECASE)
|
||||
_WORKTREE_MUTATION_RE = re.compile(
|
||||
r"(?:worktree(?:/index)? mutations|destructive reset)\s*:\s*(?!none\b).+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RESET_IN_REPORT_RE = re.compile(r"reset\s+--hard", re.IGNORECASE)
|
||||
|
||||
|
||||
def _command_text(entry) -> str:
|
||||
if isinstance(entry, dict):
|
||||
return str(entry.get("command") or "").strip()
|
||||
return str(entry or "").strip()
|
||||
|
||||
|
||||
def _destructive_commands(command_log: list | None) -> list[str]:
|
||||
return [
|
||||
cmd
|
||||
for cmd in (_command_text(entry) for entry in (command_log or []))
|
||||
if cmd and _DESTRUCTIVE_CMD_RE.search(cmd)
|
||||
]
|
||||
|
||||
|
||||
def _extract_worktree_path(text: str, session: dict) -> str:
|
||||
path = (session.get("worktree_path") or "").strip()
|
||||
if path:
|
||||
return path
|
||||
match = _WORKTREE_PATH_RE.search(text or "")
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
|
||||
def _is_session_owned_path(path: str) -> bool:
|
||||
normalized = (path or "").replace("\\", "/")
|
||||
return bool(_SESSION_OWNED_RE.search(normalized))
|
||||
|
||||
|
||||
def _safe_reuse_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not _WORKTREE_PATH_RE.search(text):
|
||||
missing.append("exact worktree path")
|
||||
if not _BRANCHES_PATH_RE.search(text):
|
||||
missing.append("worktree inside branches/")
|
||||
if not _MAIN_CHECKOUT_RE.search(text):
|
||||
missing.append("worktree is not the main checkout")
|
||||
if not _NOT_OTHER_SESSION_RE.search(text):
|
||||
missing.append("worktree not owned by another active session")
|
||||
if not _CLEAN_TRACKED_RE.search(text):
|
||||
missing.append("clean tracked state")
|
||||
if not _CLEAN_UNTRACKED_RE.search(text):
|
||||
missing.append("clean untracked state")
|
||||
if not _BRANCH_BEFORE_RESET_RE.search(text):
|
||||
missing.append("branch/head before reset")
|
||||
if not _RESET_TARGET_RE.search(text):
|
||||
missing.append("reset target SHA")
|
||||
if not _REUSE_POLICY_RE.search(text):
|
||||
missing.append("explicit project policy allowing reuse/reset")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_worktree_ownership_report(
|
||||
report_text: str,
|
||||
*,
|
||||
ownership_session: dict | None = None,
|
||||
command_log: list | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Prove reviewer worktree ownership before reset or validation (#312)."""
|
||||
text = report_text or ""
|
||||
session = dict(ownership_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
worktree_path = _extract_worktree_path(text, session)
|
||||
destructive = _destructive_commands(command_log or session.get("command_log"))
|
||||
if not destructive and session.get("destructive_reset"):
|
||||
destructive = ["git reset --hard (session)"]
|
||||
|
||||
session_owned = bool(
|
||||
session.get("session_owned")
|
||||
or (worktree_path and _is_session_owned_path(worktree_path))
|
||||
)
|
||||
safe_reuse = bool(session.get("safe_reuse") or _SAFE_REUSE_RE.search(text))
|
||||
main_checkout = bool(session.get("main_checkout"))
|
||||
dirty_tracked = session.get("dirty_tracked")
|
||||
dirty_untracked = session.get("dirty_untracked")
|
||||
other_session_owned = bool(session.get("other_session_owned"))
|
||||
|
||||
if worktree_path or destructive or session.get("validation_ran"):
|
||||
if not worktree_path:
|
||||
reasons.append("report missing exact review worktree path")
|
||||
elif main_checkout or "branches/" not in worktree_path.replace("\\", "/"):
|
||||
reasons.append("review worktree must be inside branches/, not the main checkout")
|
||||
elif not _BRANCHES_PATH_RE.search(worktree_path.replace("\\", "/")):
|
||||
reasons.append("review worktree path must be under branches/")
|
||||
|
||||
if other_session_owned and not safe_reuse:
|
||||
reasons.append(
|
||||
"reused worktree appears owned by another active task/session; "
|
||||
"safe-reuse proof required"
|
||||
)
|
||||
|
||||
if dirty_tracked:
|
||||
reasons.append(
|
||||
"worktree has uncommitted tracked changes before reset or validation"
|
||||
)
|
||||
if dirty_untracked and safe_reuse:
|
||||
reasons.append("safe-reuse proof requires clean untracked state")
|
||||
|
||||
if safe_reuse and not session_owned:
|
||||
reasons.extend(
|
||||
f"safe-reuse proof missing {field}"
|
||||
for field in _safe_reuse_fields_present(text)
|
||||
)
|
||||
|
||||
if destructive:
|
||||
if not session_owned and not safe_reuse:
|
||||
reasons.append(
|
||||
"destructive worktree commands forbidden without session-owned "
|
||||
"worktree or safe-reuse proof"
|
||||
)
|
||||
if _WORKSPACE_NONE_RE.search(text) and (
|
||||
_RESET_IN_REPORT_RE.search(text) or any("reset" in c.lower() for c in destructive)
|
||||
):
|
||||
reasons.append(
|
||||
"final report must not claim 'Workspace mutations: none' when "
|
||||
"git reset --hard occurred"
|
||||
)
|
||||
if not _WORKTREE_MUTATION_RE.search(text) and not _RESET_IN_REPORT_RE.search(text):
|
||||
reasons.append(
|
||||
"destructive reset must be reported under Worktree/index mutations "
|
||||
"or destructive reset operations"
|
||||
)
|
||||
|
||||
if (
|
||||
worktree_path
|
||||
and not session_owned
|
||||
and not safe_reuse
|
||||
and (destructive or session.get("validation_ran"))
|
||||
and not _is_session_owned_path(worktree_path)
|
||||
):
|
||||
reasons.append(
|
||||
"reused branch-named worktree requires safe-reuse proof before reset or validation"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"worktree_path": worktree_path or None,
|
||||
"session_owned": session_owned,
|
||||
"safe_reuse": safe_reuse,
|
||||
"destructive_commands": destructive,
|
||||
"safe_next_action": (
|
||||
"use a fresh session-owned review worktree under branches/review-pr<N>-* "
|
||||
"or document full safe-reuse proof before destructive reset"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -57,6 +57,8 @@ REVIEWER_TASKS = frozenset({
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"request_changes_pr",
|
||||
"approve_pr",
|
||||
})
|
||||
@@ -72,6 +74,15 @@ AUTHOR_TASKS = frozenset({
|
||||
"comment_pr",
|
||||
"address_pr_change_requests",
|
||||
"delete_branch",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
"reconcile_landed_pr",
|
||||
})
|
||||
|
||||
RECONCILER_TASKS = frozenset({
|
||||
"reconcile_already_landed_pr",
|
||||
"reconcile_already_landed",
|
||||
"reconcile-landed-pr",
|
||||
})
|
||||
|
||||
TASK_REQUIRED_ROLE = {
|
||||
@@ -88,14 +99,30 @@ TASK_REQUIRED_ROLE = {
|
||||
"review_pr": "reviewer",
|
||||
"merge_pr": "reviewer",
|
||||
"blind_pr_queue_review": "reviewer",
|
||||
"pr_queue_cleanup": "reviewer",
|
||||
"pr-queue-cleanup": "reviewer",
|
||||
"request_changes_pr": "reviewer",
|
||||
"approve_pr": "reviewer",
|
||||
"work_issue": "author",
|
||||
"work-issue": "author",
|
||||
"reconcile_landed_pr": "author",
|
||||
"reconcile_already_landed_pr": "reconciler",
|
||||
"reconcile_already_landed": "reconciler",
|
||||
"reconcile-landed-pr": "reconciler",
|
||||
# #309: reconciler tasks close already-landed PRs/issues only.
|
||||
"reconcile_close_landed_pr": "reconciler",
|
||||
"reconcile_close_landed_issue": "reconciler",
|
||||
}
|
||||
|
||||
WRONG_ROLE_REVIEWER_MSG = (
|
||||
"Wrong role/session for reviewer task. Launch reviewer MCP namespace."
|
||||
)
|
||||
|
||||
WRONG_ROLE_RECONCILER_MSG = (
|
||||
"Wrong role/session for reconciler task. Launch a reconciler-capable "
|
||||
"MCP namespace/profile with exact close capability."
|
||||
)
|
||||
|
||||
_session_last_route: dict | None = None
|
||||
|
||||
|
||||
@@ -191,6 +218,26 @@ def route_task_session(
|
||||
_record_route(result)
|
||||
return result
|
||||
|
||||
if required_role == "reconciler":
|
||||
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_RECONCILER_MSG,
|
||||
"Reconciler tasks cannot run in author or reviewer "
|
||||
"sessions without exact close capability.",
|
||||
],
|
||||
"message": WRONG_ROLE_RECONCILER_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 = (
|
||||
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
exec python3 -m webui "$@"
|
||||
+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"
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ workflow file.
|
||||
| Reconcile already-landed open PRs | [`workflows/reconcile-landed-pr.md`](workflows/reconcile-landed-pr.md) | [`schemas/reconcile-landed-final-report.md`](schemas/reconcile-landed-final-report.md) |
|
||||
| Create or update Gitea issues | [`workflows/create-issue.md`](workflows/create-issue.md) | [`schemas/create-issue-final-report.md`](schemas/create-issue-final-report.md) |
|
||||
| Work on an assigned issue / author code | [`workflows/work-issue.md`](workflows/work-issue.md) | [`schemas/work-issue-final-report.md`](schemas/work-issue-final-report.md) |
|
||||
| PR-only queue cleanup (one canonical review per PR) | [`workflows/pr-queue-cleanup.md`](workflows/pr-queue-cleanup.md) | [`schemas/pr-queue-cleanup-final-report.md`](schemas/pr-queue-cleanup-final-report.md) |
|
||||
|
||||
## Universal rules
|
||||
|
||||
@@ -50,6 +51,10 @@ changes, merge, implement fixes, create branches, commit, push, or create PRs.
|
||||
A run that starts in `work-issue` mode may not review, approve, request changes,
|
||||
merge, close PRs, or act as reviewer.
|
||||
|
||||
A run that starts in `pr-queue-cleanup` mode may not claim issues, create
|
||||
branches, edit implementation files, file new issues, or review a second PR
|
||||
after any terminal review mutation.
|
||||
|
||||
If the task requires a different mode, stop and produce a handoff for the
|
||||
correct workflow.
|
||||
|
||||
@@ -156,6 +161,7 @@ Ready-to-copy task prompts live in [`templates/`](templates/):
|
||||
|
||||
- [`start-issue.md`](templates/start-issue.md) — author work (loads `work-issue.md`)
|
||||
- [`review-pr.md`](templates/review-pr.md) — review (loads `review-merge-pr.md`)
|
||||
- [`pr-queue-cleanup.md`](templates/pr-queue-cleanup.md) — one PR per cleanup run
|
||||
- [`merge-pr.md`](templates/merge-pr.md) — merge (loads `review-merge-pr.md`)
|
||||
- [`recover-bad-state.md`](templates/recover-bad-state.md)
|
||||
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# PR-queue-cleanup final report schema
|
||||
|
||||
One report per cleanup run (one run = one PR). Fields must all be present;
|
||||
use `none` where nothing occurred. Validated by
|
||||
`pr_queue_cleanup.assess_pr_queue_cleanup_report` (fail closed).
|
||||
|
||||
* Task: pr-queue-cleanup
|
||||
* Workflow source: workflows/pr-queue-cleanup.md (+ version/commit/hash)
|
||||
* Repo:
|
||||
* Role/profile:
|
||||
* Identity:
|
||||
* PR inventory pagination proof: (inventory_complete / final page / total_count)
|
||||
* Queue ordering proof:
|
||||
* Earlier PRs skipped: (with live per-PR proof)
|
||||
* Selected PR: (exactly one)
|
||||
* Pinned head SHA:
|
||||
* Review decision: (single terminal decision)
|
||||
* Merge authorized for PR: true/false (explicit per-PR operator authorization)
|
||||
* Merge gates result:
|
||||
* Merge result: (none / not attempted / merged SHA / blocker)
|
||||
* Run stop point: (which §4 chain rule ended the run)
|
||||
* Next suggested PR: (named, not continued to)
|
||||
* File edits by reviewer:
|
||||
* Worktree/index mutations:
|
||||
* Git ref mutations:
|
||||
* MCP/Gitea mutations:
|
||||
* Issue mutations: none (required — forbidden in cleanup mode)
|
||||
* Branch mutations: none (required — forbidden in cleanup mode)
|
||||
* Read-only diagnostics:
|
||||
* Blockers:
|
||||
* Safe next action: (fresh run for the next PR)
|
||||
@@ -91,4 +91,23 @@ Verifier: `review_proofs.assess_queue_status_report()`.
|
||||
|
||||
Narrative final report and controller handoff must agree on eligibility class,
|
||||
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
|
||||
terminal review mutation, merge result, and linked issue status.
|
||||
terminal review mutation, merge result, and linked issue status.
|
||||
|
||||
### Proof-backed claims (#395)
|
||||
|
||||
Proof-sensitive claims must cite explicit command/tool evidence in the report
|
||||
or structured MCP metadata — not narrative alone:
|
||||
|
||||
- **Inventory complete:** `has_more=false`, `is_final_page=true`,
|
||||
`inventory_complete=true`, and/or `total_count` from `gitea_list_prs`.
|
||||
- **Skipped earlier PRs:** merge-simulation command output, conflict file list,
|
||||
or live `gitea_get_pr_review_feedback` / mergeability fields.
|
||||
- **Baseline validation:** baseline worktree path, baseline target SHA,
|
||||
dirty-before/after, exact command, exact result.
|
||||
- **Master integration:** exact `git merge` / `git rebase` command and exit status.
|
||||
- **Cleanup:** final `git worktree list` (or remove commands) proving session
|
||||
worktrees were removed.
|
||||
|
||||
When a claim relies on prior-session blocker state or MCP metadata only, label
|
||||
the proof source explicitly (`command`, `MCP metadata`, `prior blocker`,
|
||||
`not checked`). Do not use `live proof` without that classification.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Template: PR-only queue cleanup (one PR per run)
|
||||
|
||||
Copy, fill the `<...>` fields, and paste as the task prompt.
|
||||
|
||||
```text
|
||||
Task: PR-only queue cleanup for <repo>.
|
||||
|
||||
Load workflows/pr-queue-cleanup.md and workflows/review-merge-pr.md before any
|
||||
mutation. Route pr_queue_cleanup through gitea_route_task_session (reviewer
|
||||
profile required).
|
||||
|
||||
Rules:
|
||||
- One run = exactly one selected PR = one terminal review decision.
|
||||
- Build full open-PR inventory with pagination proof before selection.
|
||||
- Forbidden: issue claiming, branch creation, implementation edits, issue filing,
|
||||
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.
|
||||
|
||||
Operator PR list (optional): <pr numbers or "oldest eligible from inventory">
|
||||
Merge authorized for selected PR in this run: <true|false>
|
||||
|
||||
End with the pr-queue-cleanup final report schema.
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
task_mode: pr-queue-cleanup
|
||||
canonical: true
|
||||
final_report_schema: ../schemas/pr-queue-cleanup-final-report.md
|
||||
---
|
||||
|
||||
# PR-only queue cleanup workflow (canonical)
|
||||
|
||||
**Task mode:** `pr-queue-cleanup`
|
||||
|
||||
Reviewer-role mode for cleanup periods when the queue holds many open PRs.
|
||||
Each run dispatches **exactly one** canonical review for **exactly one** PR,
|
||||
then stops after any terminal review mutation. The next PR always requires a
|
||||
new run with fresh identity, capability, and inventory proof.
|
||||
|
||||
This mode composes with the canonical review workflow: for the selected PR,
|
||||
load and follow [`workflows/review-merge-pr.md`](review-merge-pr.md) in full.
|
||||
This file adds the cleanup-mode boundaries around that per-PR run; it does
|
||||
not replace the review workflow.
|
||||
|
||||
## 0. Load the canonical workflow first
|
||||
|
||||
Load this file and `workflows/review-merge-pr.md` before any mutation and
|
||||
report source, version/commit/hash, and any conflict with the operator
|
||||
prompt. If either cannot be loaded, stop and produce a recovery handoff.
|
||||
|
||||
## 1. Mode isolation — forbidden actions
|
||||
|
||||
PR-only cleanup mode forbids, with no exceptions:
|
||||
|
||||
* issue claiming (`claim_issue`, `mark_issue`, `lock_issue`)
|
||||
* branch creation or push (`create_branch`, `push_branch`)
|
||||
* implementation edits of any kind
|
||||
* new issue filing (`create_issue`)
|
||||
* PR creation (`create_pr`) and commit tools (`commit_files`)
|
||||
* reviewing a second PR after any terminal review mutation
|
||||
|
||||
`pr_queue_cleanup.check_cleanup_task_allowed` fails closed on these tasks.
|
||||
If author-side work is needed, stop and hand off to `work-issue` mode in a
|
||||
separate session.
|
||||
|
||||
## 2. Identity, capability, and routing
|
||||
|
||||
Route `pr_queue_cleanup` through `gitea_route_task_session` — reviewer role
|
||||
required; author sessions receive `wrong_role_stop`. Prove `gitea.pr.review`
|
||||
capability before selection. Merge additionally requires `gitea.pr.merge`
|
||||
plus the explicit per-PR authorization in §4.
|
||||
|
||||
## 3. Inventory and selection
|
||||
|
||||
Build the complete open-PR inventory with pagination proof
|
||||
(`inventory_complete`, final page, `total_count`) before any selection
|
||||
claim. Select exactly one PR according to project queue ordering rules
|
||||
(oldest-first unless the operator queue says otherwise), skipping earlier
|
||||
PRs only with live per-PR proof. No multi-PR validation and no batch report
|
||||
may substitute for per-PR proof.
|
||||
|
||||
## 4. Terminal mutation chain
|
||||
|
||||
`pr_queue_cleanup.resolve_cleanup_run_state` is the authority:
|
||||
|
||||
* After `REQUEST_CHANGES` → the run stops.
|
||||
* After `COMMENT` or a proof-backed skip → the run stops.
|
||||
* After `APPROVED` → the run may continue **only** to same-PR merge, and only
|
||||
when the operator explicitly authorized merge for that specific PR in this
|
||||
run (`Merge authorized: true`) and every merge gate passes.
|
||||
* After merge, or on any merge blocker → the run stops.
|
||||
* Any terminal mutation targeting a PR other than the selected PR is a hard
|
||||
stop and must be reported as a violation.
|
||||
|
||||
## 5. Fresh run per PR
|
||||
|
||||
The next PR requires a new run with fresh identity, capability, inventory,
|
||||
and ordering proof. Do not carry pinned SHAs, eligibility classes, or
|
||||
validation results across runs.
|
||||
|
||||
## 6. Final report
|
||||
|
||||
Use the schema in
|
||||
[`schemas/pr-queue-cleanup-final-report.md`](../schemas/pr-queue-cleanup-final-report.md).
|
||||
The report must include the **Next suggested PR** (from the proven ordering)
|
||||
without continuing to it, exactly one **Selected PR**, the single terminal
|
||||
decision, pagination proof, and `Issue mutations: none` / `Branch mutations:
|
||||
none`. `pr_queue_cleanup.assess_pr_queue_cleanup_report` validates these
|
||||
fields and fails closed on batch reviews, missing pagination proof, missing
|
||||
next-suggested-PR, unauthorized merges, or any issue/branch mutation.
|
||||
@@ -248,6 +248,28 @@ Post a reconciliation comment only if:
|
||||
If comment capability is missing, record the intended comment in the final
|
||||
handoff only.
|
||||
|
||||
## 12A. Partial reconciliation policy (#302)
|
||||
|
||||
The durable policy when `close_pr` capability is missing is
|
||||
**comment-then-stop** (`resolve_partial_reconciliation_plan` in
|
||||
`review_proofs.py` enforces it):
|
||||
|
||||
* `close_pr` proven → full reconciliation: close the PR and report the
|
||||
close result (`FULL_RECONCILE_CLOSE_ALLOWED`).
|
||||
* `close_pr` missing, `comment_pr` proven → post exactly one
|
||||
reconciliation comment carrying PR head SHA, target branch SHA,
|
||||
ancestor proof, linked issue status, and the required missing
|
||||
capability, then stop for a human or authorized close
|
||||
(`PARTIAL_RECONCILE_COMMENT_THEN_STOP`).
|
||||
* `comment_pr` also missing → no Gitea mutation; produce a recovery
|
||||
handoff recording the intended comment and required capabilities
|
||||
(`RECOVERY_HANDOFF_ONLY`).
|
||||
* Ancestry not proven → no mutation regardless of capability
|
||||
(`GATE_NOT_PROVEN`).
|
||||
|
||||
Final reports must explain why the comment was or was not posted and
|
||||
name the missing capability (`assess_partial_reconciliation_report`).
|
||||
|
||||
## 13. PR close rules
|
||||
|
||||
Close a PR only if:
|
||||
|
||||
@@ -538,6 +538,58 @@ Official validation integrity status must be one of:
|
||||
|
||||
Do not invent a softer status.
|
||||
|
||||
## 21A. Validation failure history rule
|
||||
|
||||
Every validation failure observed during the review session must appear in the
|
||||
final report, even if a later rerun passes.
|
||||
|
||||
Before claiming `Validation: passed`, list every failed validation command from
|
||||
the session under `Validation failure history`.
|
||||
|
||||
For each failure, report:
|
||||
|
||||
* command
|
||||
* failing test or error
|
||||
* suspected cause
|
||||
* whether it reproduced
|
||||
* whether it exists on baseline master
|
||||
* evidence for whether it is or is not PR-caused
|
||||
|
||||
If a later rerun passes, also report:
|
||||
|
||||
* what changed between runs
|
||||
* whether environmental state was cleaned (for example `/tmp` lock files)
|
||||
* why the pass is trustworthy
|
||||
|
||||
If the cause is unknown, do not erase the earlier failure with plain
|
||||
`Validation: passed`. Use a status such as
|
||||
`passed after transient failure investigation`.
|
||||
|
||||
`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.
|
||||
@@ -574,6 +626,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.
|
||||
@@ -702,6 +776,44 @@ The final report must identify:
|
||||
* whether same-PR merge continuation was allowed
|
||||
* whether the run stopped as required
|
||||
|
||||
## 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.
|
||||
|
||||
## 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:
|
||||
@@ -712,6 +824,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
|
||||
@@ -728,6 +841,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
|
||||
@@ -1096,6 +1210,8 @@ Controller Handoff:
|
||||
* Baseline worktree path:
|
||||
* Files reviewed:
|
||||
* Validation:
|
||||
* Validation failure history:
|
||||
* Validation cwd/HEAD proof:
|
||||
* Official validation integrity status:
|
||||
* Terminal review mutation:
|
||||
* Review decision:
|
||||
|
||||
@@ -83,6 +83,20 @@ Examples:
|
||||
|
||||
If capability cannot be proven, stop and produce a recovery handoff only.
|
||||
|
||||
### 2A. Pre-task role routing (#139)
|
||||
|
||||
Before issue selection or any mutation, resolve the composite author task:
|
||||
|
||||
* `gitea_route_task_session(task_type="work-issue", remote=…)` — must return
|
||||
`route_result: allowed_current_session` and `downstream_allowed: true`
|
||||
* `gitea_resolve_task_capability(task="work_issue", remote=…)` — must show
|
||||
`required_role_kind: author` and `allowed_in_current_session: true`
|
||||
|
||||
Hyphen (`work-issue`) and underscore (`work_issue`) aliases are equivalent.
|
||||
If routing returns `ambiguous_task_stop`, `wrong_role_stop`, or
|
||||
`route_to_author_session`, stop and produce a recovery handoff only — do not
|
||||
fall back to reviewer tools or guess a different profile.
|
||||
|
||||
## 3. Stop immediately on blocked infrastructure
|
||||
|
||||
If any of the following appears, stop immediately:
|
||||
@@ -130,6 +144,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.
|
||||
@@ -283,6 +305,36 @@ Report:
|
||||
|
||||
Do not create another branch/PR for the same issue unless the project explicitly allows taking over or updating existing work and exact capability is proven.
|
||||
|
||||
### 10A. Duplicate-work gate phases (#400)
|
||||
|
||||
Before any file edits, prove duplicate-work clearance with
|
||||
`gitea_assess_work_issue_duplicate` or `gitea_lock_issue` (which runs the same
|
||||
gate). The gate checks live:
|
||||
|
||||
* open PRs linked to the issue (head branch or Closes/Fixes reference),
|
||||
* remote branches matching `issue-<number>`,
|
||||
* active claim leases from structured heartbeats.
|
||||
|
||||
Re-check immediately before:
|
||||
|
||||
* `gitea_commit_files` (commit),
|
||||
* branch push,
|
||||
* `gitea_create_pr` (PR creation).
|
||||
|
||||
If a concurrent open PR appears after work begins:
|
||||
|
||||
* before commit/push → stop and preserve local work without pushing,
|
||||
* after commit but before push → stop without pushing,
|
||||
* after push but before PR creation → stop and produce a reconciliation
|
||||
handoff instead of opening a PR.
|
||||
|
||||
Final reports must state exactly one duplicate-work outcome:
|
||||
|
||||
* `duplicate PR prevented`
|
||||
* `duplicate branch prevented`
|
||||
* `duplicate commit prevented`
|
||||
* `duplicate work not prevented`
|
||||
|
||||
## 11. Claim or lock the issue before implementation
|
||||
|
||||
Claim/lock the issue before implementation if the project provides a claim/lock mechanism.
|
||||
@@ -563,6 +615,29 @@ After push, report:
|
||||
|
||||
If push fails, stop and produce a recovery handoff.
|
||||
|
||||
## 20A. Conflict-fix lease and push gate (#399)
|
||||
|
||||
When pushing to an existing PR branch to resolve merge conflicts:
|
||||
|
||||
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
|
||||
3. Do not push when a reviewer holds an active lease on the same PR.
|
||||
4. Do not force-push.
|
||||
5. 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.
|
||||
@@ -703,6 +778,12 @@ Use only precise categories:
|
||||
* External-state mutations:
|
||||
* Read-only diagnostics:
|
||||
|
||||
Issue-lock file (`/tmp/gitea_issue_lock.json`) read/write/delete is always an
|
||||
external-state mutation. Never claim `External-state mutations: none` after
|
||||
seeding, restoring, or removing that file. Manual lock seeding is not a normal
|
||||
recovery path (#447); use `gitea_lock_issue` or the #442 adoption recovery path
|
||||
instead. Link broader redesign: #438.
|
||||
|
||||
`git fetch`, `git remote update`, and any command that updates refs must be listed under `Git ref mutations`, not read-only diagnostics.
|
||||
|
||||
If `git reset --hard`, checkout, clean, worktree add/remove, merge simulation, merge abort, or similar commands occurred, report them under `Worktree/index mutations`.
|
||||
|
||||
@@ -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}
|
||||
@@ -72,6 +72,14 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"pr_queue_cleanup": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"pr-queue-cleanup": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"request_changes_pr": {
|
||||
"permission": "gitea.pr.request_changes",
|
||||
"role": "reviewer",
|
||||
@@ -96,6 +104,48 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
},
|
||||
"work_issue": {
|
||||
"permission": "gitea.pr.create",
|
||||
"role": "author",
|
||||
},
|
||||
"work-issue": {
|
||||
"permission": "gitea.pr.create",
|
||||
"role": "author",
|
||||
},
|
||||
"reconcile_landed_pr": {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
},
|
||||
"reconcile-landed-pr": {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
},
|
||||
"reconcile_already_landed_pr": {
|
||||
"permission": "gitea.pr.close",
|
||||
"role": "reconciler",
|
||||
},
|
||||
# #309: dedicated reconciler path for already-landed open PRs. Exact
|
||||
# close capabilities only — never review/approve/request_changes/merge.
|
||||
"reconcile_close_landed_pr": {
|
||||
"permission": "gitea.pr.close",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"reconcile_close_landed_issue": {
|
||||
"permission": "gitea.issue.close",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"post_heartbeat": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
"reconcile_issue_claims": {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
},
|
||||
"cleanup_stale_claims": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
},
|
||||
}
|
||||
|
||||
# Issue-mutating MCP tools and their resolver task keys.
|
||||
|
||||
@@ -62,16 +62,42 @@ class TestPreflightWarnings(unittest.TestCase):
|
||||
self.assertIn("_encode_commit_payload.py", status["preflight_warnings"][0])
|
||||
|
||||
|
||||
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
|
||||
# gitea.issue.comment (see task_capability_map), so the gate must be
|
||||
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TestIssueLockArtifactWarning(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._lock_dir = tempfile.TemporaryDirectory()
|
||||
env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name}
|
||||
self._env_patcher = patch.dict(os.environ, env, clear=True)
|
||||
self._env_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._env_patcher.stop()
|
||||
self._lock_dir.cleanup()
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
@patch("mcp_server.api_get_all", return_value=[])
|
||||
@patch("mcp_server._auth", return_value="token x")
|
||||
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
|
||||
@patch("mcp_server.ISSUE_LOCK_FILE", new_callable=lambda: tempfile.mktemp())
|
||||
@patch("issue_lock_worktree.read_worktree_git_state")
|
||||
def test_lock_success_includes_artifact_warning(self, mock_state, _lock_file, *_mocks):
|
||||
def test_lock_success_includes_artifact_warning(self, mock_state, *_mocks):
|
||||
mock_state.return_value = {
|
||||
"current_branch": "master",
|
||||
"porcelain_status": "?? _emit_payload.py\n",
|
||||
"base_equivalent": True,
|
||||
"inspected_git_root": "/scratch/wt",
|
||||
"base_branch": "origin/master",
|
||||
}
|
||||
result = mcp_server.gitea_lock_issue(
|
||||
issue_number=261,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for already-landed PR reconciliation gates (#310)."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import already_landed_reconcile
|
||||
|
||||
|
||||
class TestAssessAlreadyLandedReconciliation(unittest.TestCase):
|
||||
def test_open_pr_already_landed_allows_close(self):
|
||||
with patch(
|
||||
"already_landed_reconcile.is_head_ancestor_of_ref",
|
||||
return_value=True,
|
||||
):
|
||||
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
|
||||
pr={
|
||||
"number": 278,
|
||||
"state": "open",
|
||||
"title": "Fix thing (Closes #263)",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/x", "sha": "abc123"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": True,
|
||||
"target_branch": "master",
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
"git_fetch_command": "git fetch prgs master",
|
||||
},
|
||||
)
|
||||
self.assertTrue(assessment["close_allowed"])
|
||||
self.assertEqual(
|
||||
assessment["eligibility_class"],
|
||||
already_landed_reconcile.ELIGIBILITY_ALREADY_LANDED,
|
||||
)
|
||||
self.assertEqual(assessment["linked_issue"], 263)
|
||||
self.assertTrue(assessment["ancestor_proof"])
|
||||
|
||||
def test_not_landed_pr_denies_close(self):
|
||||
with patch(
|
||||
"already_landed_reconcile.is_head_ancestor_of_ref",
|
||||
return_value=False,
|
||||
):
|
||||
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
|
||||
pr={
|
||||
"number": 99,
|
||||
"state": "open",
|
||||
"title": "WIP",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/y", "sha": "fff111"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": True,
|
||||
"target_branch": "master",
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
},
|
||||
)
|
||||
self.assertFalse(assessment["close_allowed"])
|
||||
self.assertEqual(
|
||||
assessment["eligibility_class"],
|
||||
already_landed_reconcile.ELIGIBILITY_NOT_LANDED,
|
||||
)
|
||||
|
||||
def test_stale_target_branch_denies_close(self):
|
||||
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
|
||||
pr={
|
||||
"number": 99,
|
||||
"state": "open",
|
||||
"title": "WIP",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/y", "sha": "fff111"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": False,
|
||||
"target_branch": "master",
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": None,
|
||||
"reasons": ["git fetch failed"],
|
||||
},
|
||||
)
|
||||
self.assertFalse(assessment["close_allowed"])
|
||||
self.assertEqual(
|
||||
assessment["eligibility_class"],
|
||||
already_landed_reconcile.ELIGIBILITY_STALE_TARGET,
|
||||
)
|
||||
|
||||
def test_closed_pr_denies_close(self):
|
||||
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
|
||||
pr={
|
||||
"number": 50,
|
||||
"state": "closed",
|
||||
"title": "Done",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/z", "sha": "aaa"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": True,
|
||||
"target_branch": "master",
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
},
|
||||
)
|
||||
self.assertFalse(assessment["close_allowed"])
|
||||
self.assertEqual(
|
||||
assessment["eligibility_class"],
|
||||
already_landed_reconcile.ELIGIBILITY_PR_NOT_OPEN,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Tests for already-landed final-report wording validator (#298).
|
||||
|
||||
An already-landed PR is reconciliation-only, never review/merge
|
||||
eligible, and a head SHA may not be called reviewed unless validation
|
||||
and diff review actually passed. Final reports and controller handoffs
|
||||
must use the reconciliation wording, not the legacy eligible/reviewed
|
||||
fields.
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from review_proofs import assess_already_landed_report_wording # noqa: E402
|
||||
|
||||
|
||||
GOOD_ALREADY_LANDED = (
|
||||
"Controller Handoff\n"
|
||||
"- Oldest open PR requiring action: PR #278\n"
|
||||
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED\n"
|
||||
"- Candidate head SHA: 2a544b7\n"
|
||||
"- Reviewed head SHA: none\n"
|
||||
"- Review worktree used: false\n"
|
||||
)
|
||||
|
||||
|
||||
class TestAlreadyLandedWording(unittest.TestCase):
|
||||
def test_correct_reconciliation_wording_passes(self):
|
||||
result = assess_already_landed_report_wording(
|
||||
GOOD_ALREADY_LANDED, already_landed=True
|
||||
)
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertFalse(result["downgraded"])
|
||||
self.assertEqual(result["reasons"], [])
|
||||
|
||||
def test_oldest_eligible_wording_fails(self):
|
||||
report = GOOD_ALREADY_LANDED + "- oldest eligible PR: PR #278\n"
|
||||
result = assess_already_landed_report_wording(
|
||||
report, already_landed=True
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("oldest eligible pr" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_next_eligible_wording_fails(self):
|
||||
report = GOOD_ALREADY_LANDED + "- next eligible PR: PR #278\n"
|
||||
result = assess_already_landed_report_wording(
|
||||
report, already_landed=True
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
def test_pinned_reviewed_head_fails(self):
|
||||
report = GOOD_ALREADY_LANDED + "- Pinned reviewed head: 2a544b7\n"
|
||||
result = assess_already_landed_report_wording(
|
||||
report, already_landed=True
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("pinned reviewed head" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_scratch_worktree_field_fails(self):
|
||||
report = GOOD_ALREADY_LANDED + "- Scratch worktree used: False\n"
|
||||
result = assess_already_landed_report_wording(
|
||||
report, already_landed=True
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
def test_missing_eligibility_class_fails(self):
|
||||
report = (
|
||||
"Controller Handoff\n"
|
||||
"- Oldest open PR requiring action: PR #278\n"
|
||||
"- Candidate head SHA: 2a544b7\n"
|
||||
"- Reviewed head SHA: none\n"
|
||||
"- Review worktree used: false\n"
|
||||
)
|
||||
result = assess_already_landed_report_wording(
|
||||
report, already_landed=True
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("eligibility class" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_populated_reviewed_head_sha_fails(self):
|
||||
report = GOOD_ALREADY_LANDED.replace(
|
||||
"- Reviewed head SHA: none\n",
|
||||
"- Reviewed head SHA: 2a544b7\n",
|
||||
)
|
||||
result = assess_already_landed_report_wording(
|
||||
report, already_landed=True
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("reviewed head" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
|
||||
class TestNonAlreadyLandedReports(unittest.TestCase):
|
||||
def test_normal_reviewed_report_passes(self):
|
||||
report = (
|
||||
"- Selected PR: 281\n"
|
||||
"- Pinned reviewed head: abc1234\n"
|
||||
"- Review decision: approve\n"
|
||||
)
|
||||
result = assess_already_landed_report_wording(
|
||||
report, already_landed=False, review_validated=True
|
||||
)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
def test_blocked_infra_report_with_pinned_head_fails(self):
|
||||
report = (
|
||||
"- Selected PR: 281\n"
|
||||
"- Pinned reviewed head: abc1234\n"
|
||||
"- Current status: blocked on infra_stop\n"
|
||||
)
|
||||
result = assess_already_landed_report_wording(
|
||||
report, already_landed=False, review_validated=False
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("before validation" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_validation_failed_report_with_reviewed_sha_fails(self):
|
||||
report = (
|
||||
"- Selected PR: 281\n"
|
||||
"- Reviewed head SHA: abc1234\n"
|
||||
"- Validation: full suite failed\n"
|
||||
)
|
||||
result = assess_already_landed_report_wording(
|
||||
report, already_landed=False, review_validated=False
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
def test_validation_failed_report_with_reviewed_none_passes(self):
|
||||
report = (
|
||||
"- Selected PR: 281\n"
|
||||
"- Reviewed head SHA: none\n"
|
||||
"- Validation: full suite failed\n"
|
||||
)
|
||||
result = assess_already_landed_report_wording(
|
||||
report, already_landed=False, review_validated=False
|
||||
)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+46
-7
@@ -284,8 +284,40 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
||||
self.assertEqual(result["number"], 9)
|
||||
|
||||
|
||||
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
|
||||
|
||||
|
||||
class TestGatedToolAudit(_AuditWiringBase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from mcp_server import init_review_decision_lock
|
||||
from tests.test_mcp_server import _install_owned_reviewer_lease
|
||||
import reviewer_pr_lease
|
||||
|
||||
# init_review_decision_lock clears any prior session lease (#407).
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
self._lease_patch = _install_owned_reviewer_lease(8)
|
||||
self._lease_patch.start()
|
||||
self._auth_identity_patch = patch(
|
||||
"mcp_server._authenticated_username", return_value="reviewer-bot"
|
||||
)
|
||||
self._auth_identity_patch.start()
|
||||
self._pr_lease_comments_patch = patch(
|
||||
"mcp_server._list_pr_lease_comments", return_value=[]
|
||||
)
|
||||
self._pr_lease_comments_patch.start()
|
||||
self._pr_work_lease_patch = patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block",
|
||||
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
|
||||
)
|
||||
self._pr_work_lease_patch.start()
|
||||
self.addCleanup(self._auth_identity_patch.stop)
|
||||
self.addCleanup(self._lease_patch.stop)
|
||||
self.addCleanup(self._pr_lease_comments_patch.stop)
|
||||
self.addCleanup(self._pr_work_lease_patch.stop)
|
||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
||||
|
||||
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
||||
return {"user": {"login": author}, "state": state,
|
||||
"head": {"sha": sha}, "mergeable": mergeable}
|
||||
@@ -298,7 +330,8 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
{"login": "merger-bot"}, self._pr("author-bot"),
|
||||
self._pr("author-bot"),
|
||||
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
||||
"commit_id": "abc123", "submitted_at": "2026-07-06T10:00:00Z",
|
||||
"dismissed": False}],
|
||||
{}, {"merged_commit_sha": "c1"},
|
||||
]
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
@@ -334,7 +367,9 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_submit_review_success_audited(self, _auth, mock_api):
|
||||
# mark_final_review_decision and submit each run eligibility (user + PR).
|
||||
mock_api.side_effect = [
|
||||
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
||||
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
||||
{"id": 7, "state": "APPROVED"},
|
||||
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||
@@ -343,12 +378,16 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
||||
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
gitea_mark_final_review_decision(8, "approve", remote="prgs")
|
||||
r = gitea_submit_pr_review(pr_number=8, action="approve",
|
||||
body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True)
|
||||
from mcp_server import gitea_mark_final_review_decision
|
||||
|
||||
gitea_mark_final_review_decision(
|
||||
8, "approve", expected_head_sha="abc123", remote="prgs",
|
||||
)
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve",
|
||||
body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
self.assertTrue(r["performed"])
|
||||
recs = self._records()
|
||||
self.assertEqual(len(recs), 1)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for branches-only author mutation worktree guard (#274)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import author_mutation_worktree as amw # noqa: E402
|
||||
|
||||
|
||||
class TestPathUnderBranches(unittest.TestCase):
|
||||
ROOT = "/repo/Gitea-Tools"
|
||||
|
||||
def test_branches_subdirectory_passes(self):
|
||||
self.assertTrue(
|
||||
amw.is_path_under_branches(f"{self.ROOT}/branches/issue-274", self.ROOT)
|
||||
)
|
||||
|
||||
def test_control_checkout_fails(self):
|
||||
self.assertFalse(amw.is_path_under_branches(self.ROOT, self.ROOT))
|
||||
|
||||
def test_sibling_directory_fails(self):
|
||||
self.assertFalse(
|
||||
amw.is_path_under_branches("/repo/other-checkout", self.ROOT)
|
||||
)
|
||||
|
||||
|
||||
class TestAssessAuthorMutationWorktree(unittest.TestCase):
|
||||
ROOT = "/repo/Gitea-Tools"
|
||||
|
||||
def test_branches_worktree_passes(self):
|
||||
result = amw.assess_author_mutation_worktree(
|
||||
workspace_path=f"{self.ROOT}/branches/issue-274",
|
||||
project_root=self.ROOT,
|
||||
current_branch="feat/issue-274-branches-only-worktrees",
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_control_checkout_blocks(self):
|
||||
result = amw.assess_author_mutation_worktree(
|
||||
workspace_path=self.ROOT,
|
||||
project_root=self.ROOT,
|
||||
current_branch="master",
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("control checkout", result["reasons"][0])
|
||||
|
||||
def test_drifted_control_branch_blocks(self):
|
||||
result = amw.assess_author_mutation_worktree(
|
||||
workspace_path=self.ROOT,
|
||||
project_root=self.ROOT,
|
||||
current_branch="feat/some-task",
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("drift" in r for r in result["reasons"]))
|
||||
|
||||
def test_branches_worktree_as_project_root_passes(self):
|
||||
"""MCP launched from a branches/ worktree uses that path as PROJECT_ROOT."""
|
||||
worktree_root = f"{self.ROOT}/branches/issue-274"
|
||||
result = amw.assess_author_mutation_worktree(
|
||||
workspace_path=worktree_root,
|
||||
project_root=worktree_root,
|
||||
current_branch="feat/issue-274-branches-only-worktrees",
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
class TestPreflightIntegration(unittest.TestCase):
|
||||
def test_verify_preflight_blocks_control_checkout_with_test_porcelain(self):
|
||||
import mcp_server
|
||||
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "author"
|
||||
control_root = "/repo/Gitea-Tools"
|
||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
||||
|
||||
def test_verify_preflight_allows_branches_worktree(self):
|
||||
import mcp_server
|
||||
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "author"
|
||||
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -76,7 +76,14 @@ class CommitFilesCapabilityBase(unittest.TestCase):
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
|
||||
self._dup_fetcher_patcher = patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
self._dup_fetcher_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._dup_fetcher_patcher.stop()
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
|
||||
|
||||
@@ -55,10 +55,30 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
|
||||
self.locked_worktree_dir = tempfile.TemporaryDirectory()
|
||||
repo_root = os.path.realpath(
|
||||
os.path.join(os.path.dirname(__file__), os.pardir)
|
||||
)
|
||||
branches_root = os.path.join(repo_root, "branches")
|
||||
os.makedirs(branches_root, exist_ok=True)
|
||||
self.locked_worktree_dir = tempfile.TemporaryDirectory(
|
||||
dir=branches_root,
|
||||
prefix="test-commit-payloads-",
|
||||
)
|
||||
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
|
||||
|
||||
self.lock_file_path = "/tmp/gitea_issue_lock.json"
|
||||
import issue_lock_store
|
||||
import issue_lock_provenance
|
||||
|
||||
self._lock_dir = tempfile.TemporaryDirectory()
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self._lock_dir.name
|
||||
|
||||
work_lease = {
|
||||
"operation_type": "author_issue_work",
|
||||
"issue_number": 263,
|
||||
"branch": "feat/issue-263-native-commit-payloads",
|
||||
"claimant": {"username": "test-user", "profile": "test-author"},
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
}
|
||||
self.lock_data = {
|
||||
"issue_number": 263,
|
||||
"branch_name": "feat/issue-263-native-commit-payloads",
|
||||
@@ -66,9 +86,13 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
"org": "Example-Org",
|
||||
"repo": "Example-Repo",
|
||||
"worktree_path": self.locked_worktree_path,
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease.get("claimant"),
|
||||
),
|
||||
}
|
||||
with open(self.lock_file_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(self.lock_data))
|
||||
self.lock_file_path = issue_lock_store.bind_session_lock(self.lock_data)
|
||||
|
||||
# Reset preflight status to bypass/pass verification in tests
|
||||
self.orig_whoami_called = mcp_server._preflight_whoami_called
|
||||
@@ -76,7 +100,14 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
|
||||
self._dup_fetcher_patcher = patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
self._dup_fetcher_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._dup_fetcher_patcher.stop()
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
@@ -85,8 +116,7 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
|
||||
self._dir.cleanup()
|
||||
self.locked_worktree_dir.cleanup()
|
||||
if os.path.exists(self.lock_file_path):
|
||||
os.remove(self.lock_file_path)
|
||||
self._lock_dir.cleanup()
|
||||
|
||||
def _env(self, profile: str) -> dict:
|
||||
return {
|
||||
@@ -94,6 +124,8 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
"GITEA_MCP_PROFILE": profile,
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"GITEA_TEST_PORCELAIN": "",
|
||||
"GITEA_AUTHOR_WORKTREE": self.locked_worktree_path,
|
||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||
}
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Ensure we import from the repo root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_mcp_server as srv
|
||||
|
||||
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||
# Stable control checkout (parent of branches/), not the MCP server worktree root.
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
PROJECT_ROOT = srv.PROJECT_ROOT
|
||||
|
||||
|
||||
class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Reset preflight flags
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "author"
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
|
||||
# Disable early return in verify_preflight_purity for testing
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
|
||||
def test_create_issue_stable_checkout_rejected(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
|
||||
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
|
||||
# path is the stable control checkout (not under branches/), mutation must fail.
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue(title="Test issue", body="body text")
|
||||
self.assertIn("stable control checkout", str(ctx.exception))
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("subprocess.run")
|
||||
def test_create_issue_valid_worktree_succeeds(self, mock_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
|
||||
# Mock subprocess.run for git --git-common-dir to return PROJECT_ROOT/.git
|
||||
mock_res = MagicMock()
|
||||
mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
|
||||
mock_run.return_value = mock_res
|
||||
|
||||
mock_api.return_value = {"number": 42, "html_url": "https://gitea.example.com/issues/42"}
|
||||
|
||||
# Provide a valid branches path under the control checkout root
|
||||
valid_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
|
||||
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
res = srv.gitea_create_issue(
|
||||
title="Test issue", body="body", worktree_path=valid_path
|
||||
)
|
||||
|
||||
self.assertEqual(res["number"], 42)
|
||||
mock_api.assert_called_once()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
|
||||
def test_create_issue_missing_worktree_fails_closed(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
|
||||
# Path under branches/ but doesn't exist
|
||||
missing_path = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-worktree-path-999"
|
||||
)
|
||||
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue(
|
||||
title="Test issue", body="body", worktree_path=missing_path
|
||||
)
|
||||
self.assertIn("does not exist (fail closed)", str(ctx.exception))
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("author_mutation_worktree.subprocess.run")
|
||||
@patch("subprocess.run")
|
||||
def test_create_issue_wrong_repo_fails_closed(self, mock_run, mock_amw_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
|
||||
wrong_repo_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
|
||||
|
||||
def _subprocess_side_effect(cmd, *args, **kwargs):
|
||||
mock_res = MagicMock(returncode=0)
|
||||
if "--git-common-dir" in cmd:
|
||||
cwd = cmd[cmd.index("-C") + 1] if "-C" in cmd else ""
|
||||
if cwd == wrong_repo_path:
|
||||
mock_res.stdout = "/Users/jasonwalker/Development/some-other-repo/.git\n"
|
||||
else:
|
||||
mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
|
||||
else:
|
||||
mock_res.stdout = ""
|
||||
return mock_res
|
||||
|
||||
mock_run.side_effect = _subprocess_side_effect
|
||||
mock_amw_run.side_effect = _subprocess_side_effect
|
||||
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue(
|
||||
title="Test issue", body="body", worktree_path=wrong_repo_path
|
||||
)
|
||||
self.assertIn("does not belong to the target repository", str(ctx.exception))
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
def test_create_issue_fails_without_whoami_preflight(self, _ns, _prof, _auth):
|
||||
srv._preflight_whoami_called = False
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue(title="Test issue", body="body")
|
||||
self.assertIn("Identity (gitea_whoami) has not been verified", str(ctx.exception))
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
def test_create_issue_fails_without_capability_preflight(self, _ns, _prof, _auth):
|
||||
srv._preflight_capability_called = False
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue(title="Test issue", body="body")
|
||||
self.assertIn("Task capability (gitea_resolve_task_capability) has not been resolved", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for gitea_delete_branch capability gate (Issue #408).
|
||||
|
||||
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
|
||||
without it the delete fails closed (no preflight, no auth lookup, no API call,
|
||||
structured permission report). With it, deletion proceeds through existing
|
||||
preflight and audit unchanged.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server
|
||||
from mcp_server import gitea_delete_branch
|
||||
|
||||
FAKE_AUTH = "token fake"
|
||||
|
||||
AUTHOR_NO_DELETE = {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.pr.create", "gitea.pr.comment",
|
||||
"gitea.branch.push", "gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
AUTHOR_WITH_DELETE = {
|
||||
"profile_name": "prgs-author-deleter",
|
||||
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"] + [
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
"audit_label": "prgs-author-deleter",
|
||||
}
|
||||
|
||||
CONFIG = {
|
||||
"version": 2,
|
||||
"contexts": {
|
||||
"ctx": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"author-no-delete": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "author-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"],
|
||||
"forbidden_operations": [],
|
||||
"execution_profile": "author-no-delete",
|
||||
},
|
||||
"author-with-delete": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "deleter-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": AUTHOR_WITH_DELETE["allowed_operations"],
|
||||
"forbidden_operations": [],
|
||||
"execution_profile": "author-with-delete",
|
||||
},
|
||||
"reviewer-profile": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "reviewer",
|
||||
"username": "reviewer-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.pr.review", "gitea.pr.merge",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.branch.delete", "gitea.branch.push", "gitea.pr.create",
|
||||
],
|
||||
"execution_profile": "reviewer-profile",
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
}
|
||||
|
||||
|
||||
class TestDeleteBranchToolGate(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._preflight_snapshot = (
|
||||
mcp_server._preflight_whoami_called,
|
||||
mcp_server._preflight_capability_called,
|
||||
)
|
||||
mcp_server._preflight_whoami_called = False
|
||||
mcp_server._preflight_capability_called = False
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||
"repo": "Example-Repo"},
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
patch("gitea_config.load_config", return_value={}).start()
|
||||
patch(
|
||||
"gitea_config.is_runtime_switching_enabled", return_value=False
|
||||
).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch(
|
||||
"mcp_server.get_auth_header", return_value=FAKE_AUTH
|
||||
).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
|
||||
self._preflight_snapshot
|
||||
)
|
||||
|
||||
def _set_profile(self, profile):
|
||||
patch("mcp_server.get_profile", return_value=profile).start()
|
||||
|
||||
def test_blocked_without_delete_capability(self):
|
||||
self._set_profile(AUTHOR_NO_DELETE)
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["required_permission"], "gitea.branch.delete")
|
||||
self.assertTrue(res["reasons"])
|
||||
self.assertEqual(
|
||||
res["permission_report"]["missing_permission"],
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
self.mock_api.assert_not_called()
|
||||
self.mock_auth.assert_not_called()
|
||||
|
||||
def test_allowed_delete_proceeds(self):
|
||||
self._set_profile(AUTHOR_WITH_DELETE)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertIn("deleted", res["message"])
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertTrue(delete_calls)
|
||||
|
||||
def test_allowed_delete_audited_with_capability_proof(self):
|
||||
self._set_profile(AUTHOR_WITH_DELETE)
|
||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||
mock_write = patch("gitea_audit.write_event").start()
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
self.mock_api.return_value = {}
|
||||
gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
mock_write.assert_called()
|
||||
event = mock_write.call_args[0][0]
|
||||
self.assertEqual(event["action"], "delete_branch")
|
||||
self.assertEqual(
|
||||
event["request_metadata"]["required_permission"],
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
|
||||
|
||||
class TestDeleteBranchResolverParity(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||
"repo": "Example-Repo"},
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
patch(
|
||||
"gitea_config.is_runtime_switching_enabled", return_value=False
|
||||
).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir.cleanup()
|
||||
|
||||
def _env(self, profile: str) -> dict:
|
||||
return {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": profile,
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||
}
|
||||
|
||||
def test_reviewer_resolver_denial_blocks_raw_tool(self):
|
||||
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
|
||||
resolve = mcp_server.gitea_resolve_task_capability(
|
||||
task="delete_branch", remote="prgs")
|
||||
self.assertFalse(resolve["allowed_in_current_session"])
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(
|
||||
res["permission_report"]["missing_permission"],
|
||||
resolve["required_operation_permission"],
|
||||
)
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertFalse(delete_calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -25,11 +25,15 @@ def _review_handoff(**overrides):
|
||||
"- Files changed: review_proofs.py",
|
||||
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||
"- Mutations: review only",
|
||||
"- Workspace mutations: none",
|
||||
"- File edits by reviewer: none",
|
||||
"- Worktree/index mutations: none",
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- MCP/Gitea mutations: review submitted",
|
||||
"- Review mutations: submitted request_changes review",
|
||||
"- Merge mutations: none",
|
||||
"- Cleanup mutations: none",
|
||||
"- External-state mutations: none",
|
||||
"- Read-only diagnostics: issue and PR metadata inspected",
|
||||
"- Current status: PR open",
|
||||
"- Blockers: none",
|
||||
"- Next: await author",
|
||||
@@ -37,6 +41,10 @@ def _review_handoff(**overrides):
|
||||
"- Selected PR: #203",
|
||||
"- Reviewer eligibility: eligible",
|
||||
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Push occurred during validation: no",
|
||||
"- Worktree path: branches/review-203",
|
||||
"- Worktree dirty: clean",
|
||||
"- Scratch worktree used: yes (branches/review-203)",
|
||||
@@ -53,26 +61,47 @@ def _review_handoff(**overrides):
|
||||
return text
|
||||
|
||||
|
||||
def _reconcile_handoff(**overrides):
|
||||
def _reconcile_handoff(drop=(), **overrides):
|
||||
lines = [
|
||||
"## Controller Handoff",
|
||||
"",
|
||||
"- Task: reconcile already-landed PR #99",
|
||||
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"- Role: reconciler",
|
||||
"- Identity: sysadmin / prgs-author",
|
||||
"- Role/profile: reconciler / prgs-reconciler",
|
||||
"- Identity: sysadmin",
|
||||
"- Selected PR: #99",
|
||||
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"- PR live state: open",
|
||||
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Target branch: master",
|
||||
"- Target branch SHA: 5e023dc71b0e2b813a0b1eee6b0f42630c47a95c",
|
||||
"- Ancestor proof: candidate head is ancestor of target branch SHA",
|
||||
"- Linked issue: #98",
|
||||
"- Linked issue live status: not verified in this session",
|
||||
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"- Capabilities proven: gitea.read, gitea.pr.comment",
|
||||
"- Missing capabilities: gitea.pr.close",
|
||||
"- PR comments posted: 1 reconciliation comment on PR #99",
|
||||
"- Issue comments posted: none",
|
||||
"- PRs closed: none",
|
||||
"- Issues closed: none",
|
||||
"- File edits by reconciler: none",
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- Worktree mutations: none",
|
||||
"- MCP/Gitea mutations: PR comment posted",
|
||||
"- External-state mutations: none",
|
||||
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
|
||||
"- Reconciliation mutations: PR comment posted",
|
||||
"- Current status: reconciliation complete",
|
||||
"- Safe next action: none",
|
||||
"- Blocker: missing gitea.pr.close capability",
|
||||
"- Safe next action: hand off to a profile with gitea.pr.close",
|
||||
"- No review/merge confirmation: confirmed",
|
||||
]
|
||||
text = "\n".join(lines)
|
||||
kept = [
|
||||
line
|
||||
for line in lines
|
||||
if not any(line.startswith(f"- {name}:") for name in drop)
|
||||
]
|
||||
text = "\n".join(kept)
|
||||
for key, value in overrides.items():
|
||||
if f"- {key}:" in text:
|
||||
text = text.replace(f"- {key}:", f"- {key}: {value}")
|
||||
@@ -190,11 +219,76 @@ class TestReviewPrRules(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_already_landed_oldest_eligible_pr_blocks(self):
|
||||
report = (
|
||||
_review_handoff()
|
||||
+ "\nAlready landed.\nOldest eligible PR: PR #278."
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reviewer.already_landed_eligible_wording"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_already_landed_gate_blocks_review_terminal_states(self):
|
||||
cases = {
|
||||
"APPROVED": ("- Review decision: request_changes", "- Review decision: APPROVED"),
|
||||
"MERGED": ("- Merge result: not attempted", "- Merge result: MERGED"),
|
||||
"READY_TO_MERGE": ("- Current status: PR open", "- Current status: READY_TO_MERGE"),
|
||||
}
|
||||
for state, (old, new) in cases.items():
|
||||
with self.subTest(state=state):
|
||||
report = (
|
||||
_review_handoff()
|
||||
.replace("- Reviewer eligibility: eligible", "- Reviewer eligibility: blocked")
|
||||
.replace(old, new)
|
||||
+ "\n- Already-landed gate: fired"
|
||||
+ "\n- Ancestor proof: passed"
|
||||
+ "\n- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reviewer.already_landed_review_state"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_target_branch_up_to_date_requires_fetch_and_sha(self):
|
||||
report = (
|
||||
_review_handoff()
|
||||
.replace("- Git ref mutations: git fetch prgs master", "- Git ref mutations: none")
|
||||
+ "\nTarget branch up to date."
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reviewer.target_branch_freshness_proof"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_target_branch_up_to_date_with_fetch_and_sha_passes(self):
|
||||
report = (
|
||||
_review_handoff()
|
||||
+ "\n- Target branch SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
||||
+ "\nTarget branch up to date."
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
self.assertEqual(result["grade"], "A")
|
||||
def test_git_fetch_must_not_be_readonly_only(self):
|
||||
report = (
|
||||
_review_handoff()
|
||||
.replace("- Git ref mutations: git fetch prgs master", "- Git ref mutations: none")
|
||||
+ "\n- Read-only diagnostics: git fetch prgs master"
|
||||
.replace(
|
||||
"- Read-only diagnostics: issue and PR metadata inspected",
|
||||
"- Read-only diagnostics: git fetch prgs master",
|
||||
)
|
||||
)
|
||||
result = assess_final_report_validator(
|
||||
report,
|
||||
@@ -251,6 +345,159 @@ class TestReconciliationRules(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestCanonicalReconcileSchema(unittest.TestCase):
|
||||
"""Issue #307: reconciliation workflows emit only the canonical schema."""
|
||||
|
||||
def test_comment_only_reconciliation_passes(self):
|
||||
result = assess_final_report_validator(
|
||||
_reconcile_handoff(),
|
||||
"reconcile_already_landed",
|
||||
)
|
||||
self.assertEqual(result["grade"], "A")
|
||||
self.assertEqual(result["findings"], [])
|
||||
|
||||
def test_successful_close_reconciliation_passes(self):
|
||||
report = _reconcile_handoff().replace(
|
||||
"- Capabilities proven: gitea.read, gitea.pr.comment",
|
||||
"- Capabilities proven: gitea.read, gitea.pr.comment, gitea.pr.close",
|
||||
).replace(
|
||||
"- Missing capabilities: gitea.pr.close",
|
||||
"- Missing capabilities: none",
|
||||
).replace(
|
||||
"- PRs closed: none",
|
||||
"- PRs closed: #99",
|
||||
).replace(
|
||||
"- Blocker: missing gitea.pr.close capability",
|
||||
"- Blocker: none",
|
||||
)
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertEqual(result["grade"], "A")
|
||||
|
||||
def test_blocked_reconciliation_passes(self):
|
||||
report = _reconcile_handoff().replace(
|
||||
"- Capabilities proven: gitea.read, gitea.pr.comment",
|
||||
"- Capabilities proven: gitea.read",
|
||||
).replace(
|
||||
"- Missing capabilities: gitea.pr.close",
|
||||
"- Missing capabilities: gitea.pr.close, gitea.pr.comment",
|
||||
).replace(
|
||||
"- PR comments posted: 1 reconciliation comment on PR #99",
|
||||
"- PR comments posted: none",
|
||||
).replace(
|
||||
"- MCP/Gitea mutations: PR comment posted",
|
||||
"- MCP/Gitea mutations: none",
|
||||
).replace(
|
||||
"- Reconciliation mutations: PR comment posted",
|
||||
"- Reconciliation mutations: none",
|
||||
)
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertEqual(result["grade"], "A")
|
||||
|
||||
def test_missing_capabilities_field_required(self):
|
||||
report = _reconcile_handoff(drop=("Missing capabilities",))
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertEqual(result["grade"], "downgraded")
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.controller_handoff"
|
||||
and "Missing capabilities" in f["reason"]
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_missing_ancestor_proof_and_candidate_sha_downgrade(self):
|
||||
report = _reconcile_handoff(drop=("Ancestor proof", "Candidate head SHA"))
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertEqual(result["grade"], "downgraded")
|
||||
reasons = " ".join(f["reason"] for f in result["findings"])
|
||||
self.assertIn("Ancestor proof", reasons)
|
||||
self.assertIn("Candidate head SHA", reasons)
|
||||
|
||||
def test_stale_issue_lock_proof_blocks(self):
|
||||
report = _reconcile_handoff() + "\n- Issue lock proof: locked before diff"
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
||||
and f["field"] == "issue lock proof"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_stale_claim_comment_status_blocks(self):
|
||||
report = _reconcile_handoff() + "\n- Claim/comment status: claimed"
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
||||
and f["field"] == "claim/comment status"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_stale_workspace_mutations_blocks_without_observed_mutations(self):
|
||||
report = _reconcile_handoff() + "\n- Workspace mutations: None"
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
||||
and f["field"] == "workspace mutations"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_pr_number_opened_allowed_when_session_opened_pr(self):
|
||||
report = _reconcile_handoff() + "\n- PR number opened: #12"
|
||||
result = assess_final_report_validator(
|
||||
report,
|
||||
"reconcile_already_landed",
|
||||
session_pr_opened=True,
|
||||
)
|
||||
self.assertFalse(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
||||
and f["field"] == "pr number opened"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_pr_number_opened_still_blocked_without_session_proof(self):
|
||||
report = _reconcile_handoff() + "\n- PR number opened: #12"
|
||||
result = assess_final_report_validator(
|
||||
report,
|
||||
"reconcile_already_landed",
|
||||
session_pr_opened=False,
|
||||
)
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
||||
and f["field"] == "pr number opened"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_action_log_fetch_requires_git_ref_mutation_entry(self):
|
||||
report = _reconcile_handoff().replace(
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- Git ref mutations: none",
|
||||
)
|
||||
result = assess_final_report_validator(
|
||||
report,
|
||||
"reconcile_already_landed",
|
||||
action_log=[{"command": "git fetch prgs master", "performed": True}],
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reviewer.git_fetch_readonly"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestEntryPoint(unittest.TestCase):
|
||||
def test_unknown_task_kind_blocks(self):
|
||||
result = assess_final_report_validator("report", "unknown_mode")
|
||||
@@ -270,4 +517,4 @@ class TestEntryPoint(unittest.TestCase):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for issue claim heartbeat leases (#268)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_claim_heartbeat as ich # noqa: E402
|
||||
|
||||
|
||||
class TestHeartbeatFormatting(unittest.TestCase):
|
||||
def test_format_and_parse_roundtrip(self):
|
||||
body = ich.format_heartbeat_body(
|
||||
kind="claim",
|
||||
issue_number=268,
|
||||
branch="feat/issue-268-heartbeat-leases",
|
||||
phase="claimed",
|
||||
profile="prgs-author",
|
||||
next_action="implement module",
|
||||
)
|
||||
parsed = ich.parse_heartbeat_comment(body)
|
||||
self.assertIsNotNone(parsed)
|
||||
assert parsed is not None
|
||||
self.assertEqual(parsed["issue_number"], 268)
|
||||
self.assertEqual(parsed["branch"], "feat/issue-268-heartbeat-leases")
|
||||
self.assertEqual(parsed["phase"], "claimed")
|
||||
|
||||
|
||||
class TestClaimClassification(unittest.TestCase):
|
||||
NOW = datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
def _comment(self, *, minutes_ago: int, kind: str = "progress") -> dict:
|
||||
ts = (self.NOW - timedelta(minutes=minutes_ago)).isoformat()
|
||||
body = ich.format_heartbeat_body(
|
||||
kind=kind,
|
||||
issue_number=268,
|
||||
branch="feat/issue-268-heartbeat-leases",
|
||||
phase="implementation",
|
||||
profile="prgs-author",
|
||||
)
|
||||
return {"id": 1, "body": body, "created_at": ts, "updated_at": ts}
|
||||
|
||||
def test_active_claim_within_lease(self):
|
||||
issue = {"number": 268, "title": "t", "labels": [{"name": "status:in-progress"}]}
|
||||
result = ich.classify_issue_claim(
|
||||
issue=issue,
|
||||
comments=[self._comment(minutes_ago=5)],
|
||||
now=self.NOW,
|
||||
)
|
||||
self.assertEqual(result["status"], "active")
|
||||
|
||||
def test_stale_claim_without_branch(self):
|
||||
issue = {"number": 268, "title": "t", "labels": [{"name": "status:in-progress"}]}
|
||||
result = ich.classify_issue_claim(
|
||||
issue=issue,
|
||||
comments=[self._comment(minutes_ago=45)],
|
||||
now=self.NOW,
|
||||
)
|
||||
self.assertEqual(result["status"], "stale")
|
||||
|
||||
def test_reclaimable_after_sixty_minutes_without_branch(self):
|
||||
issue = {"number": 268, "title": "t", "labels": [{"name": "status:in-progress"}]}
|
||||
result = ich.classify_issue_claim(
|
||||
issue=issue,
|
||||
comments=[self._comment(minutes_ago=90)],
|
||||
now=self.NOW,
|
||||
)
|
||||
self.assertEqual(result["status"], "reclaimable")
|
||||
|
||||
def test_awaiting_review_when_open_pr_exists(self):
|
||||
issue = {"number": 268, "title": "t", "labels": [{"name": "status:in-progress"}]}
|
||||
open_prs = [
|
||||
{
|
||||
"number": 400,
|
||||
"title": "feat",
|
||||
"body": "Closes #268",
|
||||
"head": {"ref": "feat/issue-268-heartbeat-leases"},
|
||||
}
|
||||
]
|
||||
result = ich.classify_issue_claim(
|
||||
issue=issue,
|
||||
comments=[self._comment(minutes_ago=120)],
|
||||
open_prs=open_prs,
|
||||
now=self.NOW,
|
||||
)
|
||||
self.assertEqual(result["status"], "awaiting_review")
|
||||
|
||||
def test_phantom_claim_without_heartbeat(self):
|
||||
issue = {"number": 268, "title": "t", "labels": [{"name": "status:in-progress"}]}
|
||||
result = ich.classify_issue_claim(
|
||||
issue=issue,
|
||||
comments=[{"id": 1, "body": "working on it"}],
|
||||
now=self.NOW,
|
||||
)
|
||||
self.assertEqual(result["status"], "phantom")
|
||||
|
||||
|
||||
class TestInventory(unittest.TestCase):
|
||||
def test_build_cleanup_plan_flags_phantom(self):
|
||||
inventory = {
|
||||
"entries": [
|
||||
{"issue_number": 1, "status": "phantom", "reasons": ["no heartbeat"]},
|
||||
{"issue_number": 2, "status": "active", "reasons": []},
|
||||
]
|
||||
}
|
||||
plan = ich.build_cleanup_plan(inventory)
|
||||
self.assertEqual(len(plan), 1)
|
||||
self.assertEqual(plan[0]["issue_number"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Unit tests for own-branch lock adoption decision (#442 / #443)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from issue_lock_adoption import ( # noqa: E402
|
||||
ADOPT,
|
||||
BLOCK_COMPETING,
|
||||
NO_MATCH,
|
||||
assess_own_branch_adoption,
|
||||
build_adoption_proof,
|
||||
build_non_adoption_lock_proof,
|
||||
)
|
||||
|
||||
REQ = "feat/issue-420-server-code-parity"
|
||||
|
||||
|
||||
class TestAssessOwnBranchAdoption(unittest.TestCase):
|
||||
def test_exact_own_branch_is_adopted(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], ADOPT)
|
||||
self.assertTrue(result["adopt"])
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["matched_branch"], REQ)
|
||||
self.assertEqual(result["matched_head_sha"], "934688a")
|
||||
|
||||
def test_exact_own_branch_adopted_when_sha_missing(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420, requested_branch=REQ, existing_branches=[REQ]
|
||||
)
|
||||
self.assertEqual(result["outcome"], ADOPT)
|
||||
self.assertIsNone(result["matched_head_sha"])
|
||||
|
||||
def test_different_branch_same_issue_blocks(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": "feat/issue-420-other-work"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], BLOCK_COMPETING)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertFalse(result["adopt"])
|
||||
self.assertIn("feat/issue-420-other-work", result["competing_branches"])
|
||||
self.assertIn("fail closed", result["reason"])
|
||||
|
||||
def test_own_branch_plus_competing_branch_blocks(self):
|
||||
# Ambiguous ownership: fail closed even though the exact branch exists.
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": REQ}, {"name": "feat/issue-420-rogue"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], BLOCK_COMPETING)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["competing_branches"], ["feat/issue-420-rogue"])
|
||||
|
||||
def test_no_matching_branch_is_normal_path(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": "feat/issue-999-unrelated"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], NO_MATCH)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertFalse(result["adopt"])
|
||||
|
||||
def test_empty_branch_list_is_normal_path(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420, requested_branch=REQ, existing_branches=[]
|
||||
)
|
||||
self.assertEqual(result["outcome"], NO_MATCH)
|
||||
|
||||
def test_higher_issue_number_branch_does_not_block_lower_issue_adoption(self):
|
||||
# issue-420 must not be treated as competing work for issue #42.
|
||||
own_branch = "feat/issue-42-widget"
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=42,
|
||||
requested_branch=own_branch,
|
||||
existing_branches=[
|
||||
{"name": own_branch, "commit_sha": "abc1234"},
|
||||
{"name": "feat/issue-420-server-code-parity"},
|
||||
],
|
||||
)
|
||||
self.assertEqual(result["outcome"], ADOPT)
|
||||
self.assertTrue(result["adopt"])
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["matched_branch"], own_branch)
|
||||
|
||||
def test_unrelated_higher_number_branch_is_ignored_without_own_branch(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=42,
|
||||
requested_branch="feat/issue-42-thing",
|
||||
existing_branches=[{"name": "feat/issue-420-server-code-parity"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], NO_MATCH)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertFalse(result["adopt"])
|
||||
|
||||
|
||||
class TestBuildAdoptionProof(unittest.TestCase):
|
||||
def test_proof_has_all_required_fields(self):
|
||||
assessment = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
|
||||
)
|
||||
proof = build_adoption_proof(
|
||||
issue_number=420,
|
||||
branch_name=REQ,
|
||||
assessment=assessment,
|
||||
open_pr_checked=True,
|
||||
competing_lock_checked=True,
|
||||
lock_file_path="/tmp/example-lock.json",
|
||||
lock_file_status="written",
|
||||
)
|
||||
for key in (
|
||||
"issue_number",
|
||||
"branch_name",
|
||||
"branch_head_commit",
|
||||
"adoption_reason",
|
||||
"no_existing_pr_proof",
|
||||
"no_competing_live_lock_proof",
|
||||
"lock_file_path",
|
||||
"lock_file_status",
|
||||
):
|
||||
self.assertIn(key, proof)
|
||||
self.assertEqual(proof["branch_head_commit"], "934688a")
|
||||
self.assertTrue(proof["no_existing_pr_proof"])
|
||||
self.assertTrue(proof["no_competing_live_lock_proof"])
|
||||
|
||||
|
||||
class TestExplicitAdoptionProofFields(unittest.TestCase):
|
||||
"""#477: explicit, citable adoption-proof fields for all outcomes."""
|
||||
|
||||
def _proof(self, assessment, branch):
|
||||
return build_adoption_proof(
|
||||
issue_number=420,
|
||||
branch_name=branch,
|
||||
assessment=assessment,
|
||||
open_pr_checked=True,
|
||||
competing_lock_checked=True,
|
||||
lock_file_path="/tmp/example-lock.json",
|
||||
lock_file_status="written",
|
||||
)
|
||||
|
||||
def test_adopt_proof_exposes_explicit_fields(self):
|
||||
assessment = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
|
||||
)
|
||||
proof = self._proof(assessment, REQ)
|
||||
self.assertEqual(proof["adoption_decision"], "ADOPT")
|
||||
self.assertTrue(proof["adopted"])
|
||||
self.assertEqual(proof["adopted_branch"], REQ)
|
||||
self.assertEqual(proof["adopted_branch_head"], "934688a")
|
||||
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
|
||||
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
|
||||
self.assertIn("gitea_create_pr", proof["safe_next_action"])
|
||||
self.assertIn("exactly matches", proof["matcher_summary"])
|
||||
|
||||
def test_block_proof_reports_competing_and_does_not_claim_adoption(self):
|
||||
assessment = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": "feat/issue-420-rogue"}],
|
||||
)
|
||||
proof = self._proof(assessment, REQ)
|
||||
self.assertEqual(proof["adoption_decision"], "BLOCK_COMPETING")
|
||||
self.assertFalse(proof["adopted"])
|
||||
self.assertIsNone(proof["adopted_branch"])
|
||||
self.assertIsNone(proof["adopted_branch_head"])
|
||||
self.assertEqual(proof["competing_branch_check"]["result"], "blocked")
|
||||
self.assertIn(
|
||||
"feat/issue-420-rogue",
|
||||
proof["competing_branch_check"]["competing_branches"],
|
||||
)
|
||||
self.assertIn("fail closed", proof["safe_next_action"])
|
||||
|
||||
def test_no_match_proof_does_not_claim_adoption(self):
|
||||
assessment = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": "feat/issue-999-unrelated"}],
|
||||
)
|
||||
proof = self._proof(assessment, REQ)
|
||||
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
|
||||
self.assertFalse(proof["adopted"])
|
||||
self.assertIsNone(proof["adopted_branch"])
|
||||
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
|
||||
|
||||
def test_substring_collision_stays_boundary_safe(self):
|
||||
# issue-42 must not adopt/claim against an issue-420 branch (#440/#477).
|
||||
own = "feat/issue-42-widget"
|
||||
assessment = assess_own_branch_adoption(
|
||||
issue_number=42,
|
||||
requested_branch=own,
|
||||
existing_branches=[
|
||||
{"name": own, "commit_sha": "abc1234"},
|
||||
{"name": "feat/issue-420-server-code-parity"},
|
||||
],
|
||||
)
|
||||
proof = self._proof(assessment, own)
|
||||
self.assertEqual(proof["adoption_decision"], "ADOPT")
|
||||
self.assertEqual(proof["adopted_branch"], own)
|
||||
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
|
||||
|
||||
def test_non_adoption_lock_proof_is_adoption_free(self):
|
||||
proof = build_non_adoption_lock_proof(
|
||||
issue_number=196, branch_name="feat/issue-196-mutations"
|
||||
)
|
||||
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
|
||||
self.assertFalse(proof["adopted"])
|
||||
self.assertIsNone(proof["adopted_branch"])
|
||||
self.assertIsNone(proof["adopted_branch_head"])
|
||||
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
|
||||
self.assertIn("no adoption", proof["safe_next_action"].lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for issue-lock provenance and external-state disclosure (#447)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_lock_provenance as ilp # noqa: E402
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
|
||||
|
||||
def _sanctioned_lock(**overrides):
|
||||
work_lease = {
|
||||
"operation_type": "author_issue_work",
|
||||
"issue_number": 447,
|
||||
"branch": "feat/issue-447-lock-provenance",
|
||||
"claimant": {"username": "jcwalker3", "profile": "prgs-author"},
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
}
|
||||
data = {
|
||||
"issue_number": 447,
|
||||
"branch_name": "feat/issue-447-lock-provenance",
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": ilp.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease["claimant"],
|
||||
),
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
class TestLockProvenanceForCreatePr(unittest.TestCase):
|
||||
def test_sanctioned_lock_passes(self):
|
||||
result = ilp.assess_lock_file_for_create_pr(_sanctioned_lock())
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_manual_seed_without_provenance_blocked(self):
|
||||
result = ilp.assess_lock_file_for_create_pr(
|
||||
{"issue_number": 420, "branch_name": "feat/x", "work_lease": {}}
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("lock_provenance", result["reasons"][0])
|
||||
|
||||
def test_operator_override_requires_reason(self):
|
||||
result = ilp.assess_lock_file_for_create_pr(
|
||||
_sanctioned_lock(
|
||||
lock_provenance=ilp.build_sanctioned_lock_provenance(
|
||||
tool="operator_override",
|
||||
source=ilp.SOURCE_OPERATOR_OVERRIDE,
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestExternalStateReportRules(unittest.TestCase):
|
||||
def test_seed_with_external_none_blocked(self):
|
||||
report = (
|
||||
"Restored /tmp/gitea_issue_lock.json to unblock PR creation.\n"
|
||||
"- External-state mutations: none\n"
|
||||
)
|
||||
result = ilp.assess_issue_lock_external_state_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_seed_with_disclosure_passes(self):
|
||||
report = (
|
||||
"Restored /tmp/gitea_issue_lock.json after MCP restart.\n"
|
||||
"- External-state mutations: wrote /tmp/gitea_issue_lock.json\n"
|
||||
)
|
||||
result = ilp.assess_issue_lock_external_state_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_remove_claimed_as_cleanup_only_blocked(self):
|
||||
report = (
|
||||
"rm /tmp/gitea_issue_lock.json after PR creation.\n"
|
||||
"- Cleanup mutations: lock removed\n"
|
||||
"- External-state mutations: none\n"
|
||||
)
|
||||
result = ilp.assess_issue_lock_external_state_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_manual_lock_pr_without_override_blocked(self):
|
||||
report = (
|
||||
"Programmatically seeded gitea_issue_lock.json then gitea_create_pr.\n"
|
||||
"PR #444 created.\n"
|
||||
)
|
||||
result = ilp.assess_manual_lock_pr_without_override(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_author_reviewer_same_run_blocked(self):
|
||||
report = (
|
||||
"gitea_create_pr opened PR #444.\n"
|
||||
"Submitted approve review on PR #444.\n"
|
||||
)
|
||||
result = ilp.assess_author_reviewer_same_run_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestFinalReportValidatorIntegration(unittest.TestCase):
|
||||
def test_work_issue_blocks_hidden_lock_mutation(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Task: work issue #420\n"
|
||||
"- External-state mutations: none\n"
|
||||
"Restored /tmp/gitea_issue_lock.json before PR creation.\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, "work_issue")
|
||||
rule_ids = {f["rule_id"] for f in result["findings"]}
|
||||
self.assertIn("shared.issue_lock_external_state", rule_ids)
|
||||
self.assertTrue(result["blocked"])
|
||||
|
||||
def test_review_pr_blocks_create_and_approve(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Task: review PR #444\n"
|
||||
"- Review decision: approve\n"
|
||||
"Created PR #444 via gitea_create_pr earlier in this run.\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
rule_ids = {f["rule_id"] for f in result["findings"]}
|
||||
self.assertIn("shared.author_reviewer_same_run", rule_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Unit tests for keyed issue-lock storage (#443) and flock hardening (#438)."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_lock_store as ils # noqa: E402
|
||||
|
||||
|
||||
def _lease(expires_at: str) -> dict:
|
||||
return {
|
||||
"operation_type": ils.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"expires_at": expires_at,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"last_heartbeat_at": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def _lock_record(**overrides) -> dict:
|
||||
record = {
|
||||
"issue_number": 420,
|
||||
"branch_name": "feat/issue-420-server-code-parity",
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"worktree_path": "/tmp/wt-420",
|
||||
"work_lease": _lease("2999-01-01T00:00:00Z"),
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
class TestIssueLockStore(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.lock_dir = self._dir.name
|
||||
self._env = mock.patch.dict(os.environ, {"GITEA_ISSUE_LOCK_DIR": self.lock_dir})
|
||||
self._env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._env.stop()
|
||||
self._dir.cleanup()
|
||||
|
||||
def test_concurrent_repo_locks_do_not_overwrite(self):
|
||||
lock_a = _lock_record(
|
||||
issue_number=108,
|
||||
branch_name="feat/issue-108-root-menu",
|
||||
repo="mcp-control-plane",
|
||||
worktree_path="/tmp/wt-108",
|
||||
)
|
||||
lock_b = _lock_record(
|
||||
issue_number=420,
|
||||
branch_name="feat/issue-420-server-code-parity",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/tmp/wt-420",
|
||||
)
|
||||
path_a = ils.bind_session_lock(lock_a)
|
||||
with mock.patch("os.getpid", return_value=9999):
|
||||
path_b = ils.bind_session_lock(lock_b)
|
||||
|
||||
self.assertNotEqual(path_a, path_b)
|
||||
self.assertTrue(os.path.exists(path_a))
|
||||
self.assertTrue(os.path.exists(path_b))
|
||||
stored_a = ils.read_lock_file(path_a)
|
||||
stored_b = ils.read_lock_file(path_b)
|
||||
self.assertEqual(stored_a["issue_number"], 108)
|
||||
self.assertEqual(stored_b["issue_number"], 420)
|
||||
|
||||
def test_concurrent_issue_locks_same_repo_do_not_overwrite(self):
|
||||
lock_a = _lock_record(issue_number=427, branch_name="feat/issue-427-a")
|
||||
lock_b = _lock_record(issue_number=428, branch_name="feat/issue-428-b")
|
||||
path_a = ils.bind_session_lock(lock_a)
|
||||
with mock.patch("os.getpid", return_value=4242):
|
||||
path_b = ils.bind_session_lock(lock_b)
|
||||
|
||||
self.assertNotEqual(path_a, path_b)
|
||||
self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 427)
|
||||
self.assertEqual(ils.read_lock_file(path_b)["issue_number"], 428)
|
||||
|
||||
def test_foreign_live_lease_blocks_overwrite(self):
|
||||
existing = _lock_record(
|
||||
branch_name="feat/issue-420-other",
|
||||
worktree_path="/tmp/other",
|
||||
work_lease=_lease("2999-01-01T00:00:00Z"),
|
||||
)
|
||||
path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=420,
|
||||
)
|
||||
ils.save_lock_file(path, existing)
|
||||
|
||||
incoming = _lock_record(worktree_path="/tmp/mine")
|
||||
block = ils.assess_foreign_lock_overwrite(existing, incoming)
|
||||
self.assertIn("live foreign issue lock", block or "")
|
||||
|
||||
def test_expired_lease_allows_takeover_with_conflict_check(self):
|
||||
existing = _lock_record(
|
||||
branch_name="feat/issue-420-other",
|
||||
worktree_path="/tmp/other",
|
||||
work_lease=_lease("2000-01-01T00:00:00Z"),
|
||||
)
|
||||
incoming = _lock_record(worktree_path="/tmp/mine")
|
||||
self.assertIsNone(ils.assess_foreign_lock_overwrite(existing, incoming))
|
||||
block = ils.assess_same_issue_lease_conflict(
|
||||
existing,
|
||||
issue_number=420,
|
||||
branch_name="feat/issue-420-server-code-parity",
|
||||
worktree_path="/tmp/mine",
|
||||
)
|
||||
self.assertIn("Recovery review is required", block or "")
|
||||
|
||||
def test_same_owner_lease_conflict_allows_refresh(self):
|
||||
worktree = "/tmp/wt-420"
|
||||
existing = _lock_record(worktree_path=worktree)
|
||||
block = ils.assess_same_issue_lease_conflict(
|
||||
existing,
|
||||
issue_number=420,
|
||||
branch_name="feat/issue-420-server-code-parity",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
self.assertIsNone(block)
|
||||
|
||||
def test_find_lock_for_branch_after_restart(self):
|
||||
record = _lock_record()
|
||||
path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=420,
|
||||
)
|
||||
ils.save_lock_file(path, record)
|
||||
|
||||
with mock.patch("os.getpid", return_value=5555):
|
||||
self.assertIsNone(ils.read_session_issue_lock())
|
||||
|
||||
found = ils.find_lock_for_branch(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
branch_name="feat/issue-420-server-code-parity",
|
||||
)
|
||||
self.assertEqual(found["issue_number"], 420)
|
||||
|
||||
def test_has_active_issue_lock_scans_keyed_store(self):
|
||||
ils.bind_session_lock(_lock_record())
|
||||
self.assertTrue(
|
||||
ils.has_active_issue_lock("feat/issue-420-server-code-parity")
|
||||
)
|
||||
self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other"))
|
||||
|
||||
def test_approved_stacked_base_survives_round_trip(self):
|
||||
# #484: the approved stacked base recorded on the lock must persist so
|
||||
# gitea_create_pr can validate the non-master base at PR time.
|
||||
record = _lock_record(
|
||||
issue_number=482,
|
||||
branch_name="feat/issue-482-skip-stale-request-changes-pr",
|
||||
approved_stacked_base={
|
||||
"branch": "feat/issue-478-mcp-menu-shell",
|
||||
"pr_number": 479,
|
||||
"verified_open": True,
|
||||
},
|
||||
)
|
||||
path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=482,
|
||||
)
|
||||
ils.save_lock_file(path, record)
|
||||
stored = ils.read_lock_file(path)
|
||||
self.assertEqual(stored["approved_stacked_base"]["branch"], "feat/issue-478-mcp-menu-shell")
|
||||
self.assertEqual(stored["approved_stacked_base"]["pr_number"], 479)
|
||||
self.assertTrue(stored["approved_stacked_base"]["verified_open"])
|
||||
|
||||
def test_atomic_write_preserves_unrelated_lock(self):
|
||||
path_a = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=108,
|
||||
)
|
||||
ils.save_lock_file(path_a, _lock_record(issue_number=108, repo="mcp-control-plane"))
|
||||
path_b = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=420,
|
||||
)
|
||||
ils.save_lock_file(path_b, _lock_record())
|
||||
|
||||
self.assertTrue(os.path.exists(path_a))
|
||||
self.assertTrue(os.path.exists(path_b))
|
||||
self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 108)
|
||||
|
||||
def test_concurrent_bind_same_issue_only_one_wins(self):
|
||||
barrier = threading.Barrier(2)
|
||||
results: list[str | Exception] = []
|
||||
|
||||
def worker():
|
||||
barrier.wait()
|
||||
try:
|
||||
ils.bind_session_lock(
|
||||
_lock_record(worktree_path=f"/tmp/wt-{threading.get_ident()}")
|
||||
)
|
||||
results.append("ok")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(2)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
successes = [item for item in results if item == "ok"]
|
||||
failures = [item for item in results if isinstance(item, Exception)]
|
||||
self.assertEqual(len(successes), 1)
|
||||
self.assertEqual(len(failures), 1)
|
||||
failure_text = str(failures[0]).lower()
|
||||
self.assertTrue(
|
||||
"active" in failure_text or "lock contention" in failure_text,
|
||||
failures[0],
|
||||
)
|
||||
|
||||
def test_verify_lock_for_mutation_blocks_stale_lock(self):
|
||||
record = _lock_record(
|
||||
work_lease=_lease("2000-01-01T00:00:00Z"),
|
||||
)
|
||||
record["pid"] = 999999
|
||||
record["session_pid"] = 999999
|
||||
result = ils.verify_lock_for_mutation(
|
||||
record,
|
||||
issue_number=420,
|
||||
branch_name="feat/issue-420-server-code-parity",
|
||||
worktree_path="/tmp/wt-420",
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("not live", result["reasons"][0])
|
||||
|
||||
def test_list_live_locks_excludes_stale_records(self):
|
||||
live_path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=420,
|
||||
)
|
||||
ils.save_lock_file(
|
||||
live_path,
|
||||
_lock_record(worktree_path="/tmp/wt-420"),
|
||||
)
|
||||
stale_path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=440,
|
||||
)
|
||||
ils.save_lock_file(
|
||||
stale_path,
|
||||
_lock_record(
|
||||
issue_number=440,
|
||||
branch_name="feat/issue-440-recovery",
|
||||
work_lease=_lease("2000-01-01T00:00:00Z"),
|
||||
worktree_path="/tmp/wt-440",
|
||||
),
|
||||
)
|
||||
live = ils.list_live_locks(lock_dir=self.lock_dir)
|
||||
self.assertEqual([entry["issue_number"] for entry in live], [420])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -72,6 +72,59 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase):
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
|
||||
class TestStackedBaseEquivalence(unittest.TestCase):
|
||||
"""extra_bases (an approved stacked base) can anchor base-equivalence (#484)."""
|
||||
|
||||
def _git(self, *args):
|
||||
import subprocess
|
||||
|
||||
subprocess.run(
|
||||
["git", "-C", self.repo, *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.repo = self.tmp.name
|
||||
subprocess.run(["git", "init", "-q", self.repo], check=True, capture_output=True)
|
||||
self._git("config", "user.email", "t@t")
|
||||
self._git("config", "user.name", "t")
|
||||
self._git("commit", "--allow-empty", "-q", "-m", "base")
|
||||
# Rename the default branch away from master/main/dev so no *base* branch
|
||||
# exists at HEAD — otherwise HEAD would be base-equivalent for free.
|
||||
self._git("branch", "-m", "trunk")
|
||||
# Create a non-master "dependency" branch at the same commit, then a
|
||||
# feature branch off it — mirrors a stacked worktree.
|
||||
self._git("branch", "feat/issue-100-dep")
|
||||
self._git("checkout", "-q", "-b", "feat/issue-101-stacked")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_stacked_base_not_equivalent_without_extra_bases(self):
|
||||
state = issue_lock_worktree.read_worktree_git_state(self.repo)
|
||||
# HEAD does not match master/main/dev, so base-equivalence is False.
|
||||
self.assertFalse(state["base_equivalent"])
|
||||
|
||||
def test_stacked_base_equivalent_with_extra_bases(self):
|
||||
state = issue_lock_worktree.read_worktree_git_state(
|
||||
self.repo, extra_bases=("feat/issue-100-dep",)
|
||||
)
|
||||
self.assertTrue(state["base_equivalent"])
|
||||
self.assertEqual(state["base_branch"], "feat/issue-100-dep")
|
||||
|
||||
def test_unrelated_extra_base_does_not_anchor(self):
|
||||
state = issue_lock_worktree.read_worktree_git_state(
|
||||
self.repo, extra_bases=("feat/does-not-exist",)
|
||||
)
|
||||
self.assertFalse(state["base_equivalent"])
|
||||
|
||||
|
||||
class TestIssueLockWorktreeResolution(unittest.TestCase):
|
||||
def test_explicit_path_wins(self):
|
||||
resolved = issue_lock_worktree.resolve_author_worktree_path(
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Tests for early duplicate-work detection (#400)."""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_lock_provenance
|
||||
import issue_lock_store
|
||||
import issue_work_duplicate_gate as dup_gate
|
||||
import mcp_server
|
||||
from issue_work_duplicate_gate import (
|
||||
OUTCOME_DUPLICATE_BRANCH_PREVENTED,
|
||||
OUTCOME_DUPLICATE_COMMIT_PREVENTED,
|
||||
OUTCOME_DUPLICATE_PR_PREVENTED,
|
||||
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
|
||||
PHASE_COMMIT,
|
||||
PHASE_CREATE_PR,
|
||||
PHASE_LOCK,
|
||||
assess_work_issue_duplicate_gate,
|
||||
assess_work_issue_duplicate_report,
|
||||
)
|
||||
|
||||
|
||||
class TestDuplicateGateAssessment(unittest.TestCase):
|
||||
def test_clear_issue_passes(self):
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
400,
|
||||
open_prs=[],
|
||||
branch_names=["feat/other-issue-99"],
|
||||
claim_entry={"status": "not_claimed"},
|
||||
locked_branch="feat/issue-400-duplicate-work-preflight",
|
||||
phase=PHASE_LOCK,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
|
||||
|
||||
def test_open_pr_blocks(self):
|
||||
prs = [{
|
||||
"number": 397,
|
||||
"title": "feat: handoff",
|
||||
"body": "Closes #395",
|
||||
"head": {"ref": "feat/issue-395-proof-backed-review-handoff"},
|
||||
}]
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
395,
|
||||
open_prs=prs,
|
||||
branch_names=[],
|
||||
phase=PHASE_LOCK,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED)
|
||||
|
||||
def test_conflicting_remote_branch_blocks(self):
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
395,
|
||||
open_prs=[],
|
||||
branch_names=["feat/issue-395-proof-backed-handoff-claims"],
|
||||
locked_branch="feat/issue-395-new-attempt",
|
||||
phase=PHASE_LOCK,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_BRANCH_PREVENTED)
|
||||
|
||||
def test_active_claim_on_other_branch_blocks(self):
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
398,
|
||||
open_prs=[],
|
||||
branch_names=[],
|
||||
claim_entry={
|
||||
"status": "active",
|
||||
"latest_heartbeat": {
|
||||
"branch": "feat/issue-398-validation-cwd-proof",
|
||||
},
|
||||
},
|
||||
locked_branch="feat/issue-398-other-branch",
|
||||
phase=PHASE_LOCK,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_commit_phase_maps_to_commit_outcome(self):
|
||||
prs = [{
|
||||
"number": 411,
|
||||
"title": "x",
|
||||
"body": "Closes #398",
|
||||
"head": {"ref": "feat/issue-398-validation-cwd-proof"},
|
||||
}]
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
398,
|
||||
open_prs=prs,
|
||||
branch_names=[],
|
||||
locked_branch="feat/issue-398-alt",
|
||||
phase=PHASE_COMMIT,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_COMMIT_PREVENTED)
|
||||
|
||||
def test_stale_claim_does_not_block_by_status_alone(self):
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
400,
|
||||
open_prs=[],
|
||||
branch_names=[],
|
||||
claim_entry={"status": "reclaimable", "reasons": ["stale"]},
|
||||
locked_branch="feat/issue-400-duplicate-work-preflight",
|
||||
phase=PHASE_LOCK,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
class TestDuplicateReportOutcome(unittest.TestCase):
|
||||
def test_requires_exactly_one_outcome(self):
|
||||
bad = assess_work_issue_duplicate_report("work finished")
|
||||
self.assertFalse(bad["complete"])
|
||||
|
||||
good = assess_work_issue_duplicate_report(
|
||||
"Duplicate work not prevented for issue #400."
|
||||
)
|
||||
self.assertTrue(good["complete"])
|
||||
self.assertEqual(good["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
|
||||
|
||||
|
||||
class TestInjectableDuplicateFetcher(unittest.TestCase):
|
||||
@patch("mcp_server.api_get_all", return_value=[])
|
||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||
def test_lock_issue_uses_injected_fetcher(self, _auth, _api):
|
||||
seen = {}
|
||||
|
||||
def fetcher(h, o, r, auth, issue_number):
|
||||
seen["issue_number"] = issue_number
|
||||
return [], [], {"status": "not_claimed"}
|
||||
|
||||
with patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
side_effect=fetcher,
|
||||
), patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={
|
||||
"current_branch": "master",
|
||||
"porcelain_status": "",
|
||||
"base_equivalent": True,
|
||||
},
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
with patch.dict(os.environ, {
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
||||
"GITEA_ISSUE_LOCK_DIR": lock_dir,
|
||||
}, clear=True):
|
||||
mcp_server.gitea_lock_issue(
|
||||
issue_number=400,
|
||||
branch_name="feat/issue-400-duplicate-work-preflight",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertEqual(seen["issue_number"], 400)
|
||||
|
||||
|
||||
class TestMcpDuplicateRecheck(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self._env_patch = patch.dict(
|
||||
os.environ,
|
||||
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
|
||||
clear=False,
|
||||
)
|
||||
self._env_patch.start()
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||
"repo": "Example-Repo"},
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
self._dir.cleanup()
|
||||
|
||||
def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
|
||||
worktree_path = os.path.realpath(os.getcwd())
|
||||
work_lease = {
|
||||
"operation_type": "author_issue_work",
|
||||
"issue_number": issue_number,
|
||||
"branch": branch,
|
||||
"claimant": {"username": "test-user", "profile": "test-author"},
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
}
|
||||
issue_lock_store.bind_session_lock({
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch,
|
||||
"remote": "prgs",
|
||||
"org": "Example-Org",
|
||||
"repo": "Example-Repo",
|
||||
"worktree_path": worktree_path,
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease.get("claimant"),
|
||||
),
|
||||
})
|
||||
|
||||
@patch("mcp_server._assess_issue_duplicate_gate")
|
||||
@patch("mcp_server.get_profile", return_value={
|
||||
"profile_name": "test-author",
|
||||
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "test-author",
|
||||
})
|
||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
|
||||
self._write_lock()
|
||||
mock_gate.return_value = {
|
||||
"block": True,
|
||||
"reasons": ["open PR #412 already covers issue #400"],
|
||||
"outcome": OUTCOME_DUPLICATE_COMMIT_PREVENTED,
|
||||
"safe_next_action": "stop",
|
||||
}
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
with patch(
|
||||
"mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
):
|
||||
result = mcp_server.gitea_commit_files(
|
||||
files=[{
|
||||
"operation": "create",
|
||||
"path": "a.txt",
|
||||
"content_plain": "hi",
|
||||
}],
|
||||
message="test",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("duplicate_gate", result)
|
||||
|
||||
@patch("mcp_server._assess_issue_duplicate_gate")
|
||||
@patch("mcp_server.get_profile", return_value={
|
||||
"profile_name": "test-author",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "test-author",
|
||||
})
|
||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
|
||||
self._write_lock()
|
||||
mock_gate.return_value = {
|
||||
"block": True,
|
||||
"reasons": ["open PR #412 already covers issue #400"],
|
||||
"outcome": OUTCOME_DUPLICATE_PR_PREVENTED,
|
||||
"safe_next_action": "reconciliation handoff",
|
||||
}
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
with patch(
|
||||
"mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
):
|
||||
result = mcp_server.gitea_create_pr(
|
||||
title="feat: x (Closes #400)",
|
||||
head="feat/issue-400-x",
|
||||
base="master",
|
||||
body="Closes #400",
|
||||
remote="prgs",
|
||||
worktree_path=os.path.realpath(os.getcwd()),
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIsNone(result.get("number"))
|
||||
self.assertIn("duplicate_gate", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -172,6 +172,8 @@ class TestAllowedIssueWritesProceed(IssueWriteGateBase):
|
||||
return [{"id": 10, "name": "status:in-progress"}]
|
||||
if method == "POST" and "/issues/9/labels" in url:
|
||||
return [{"name": "status:in-progress"}]
|
||||
if method == "POST" and "/issues/9/comments" in url:
|
||||
return {"id": 501}
|
||||
if method == "GET" and url.endswith("/user"):
|
||||
return {"login": "author-user"}
|
||||
return {}
|
||||
|
||||
@@ -122,15 +122,19 @@ class TestShaCannotBypassSelfReview(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_review_tool_refuses_self_approval_despite_sha(self, _auth, mock_api, mock_get_all):
|
||||
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": "abc1234"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
||||
from mcp_server import init_review_decision_lock
|
||||
from tests.test_mcp_server import FULL_HEAD_SHA, _seed_ready_review_decision
|
||||
|
||||
head_sha = FULL_HEAD_SHA
|
||||
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
||||
mock_api.side_effect = [
|
||||
{"login": "jcwalker3"}, # /user (inventory)
|
||||
{"login": "jcwalker3"}, # /user (submit eligibility)
|
||||
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9
|
||||
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": head_sha}, "mergeable": True}, # /pulls/9
|
||||
]
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
gitea_mark_final_review_decision(9, "approve", remote="prgs")
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]):
|
||||
_seed_ready_review_decision(9, "approve", sha=head_sha, remote="prgs")
|
||||
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_review_pr(
|
||||
|
||||
@@ -16,6 +16,7 @@ def test_all_workflow_files_exist():
|
||||
"reconcile-landed-pr.md",
|
||||
"create-issue.md",
|
||||
"work-issue.md",
|
||||
"pr-queue-cleanup.md",
|
||||
):
|
||||
assert (SKILL_DIR / "workflows" / name).is_file(), name
|
||||
|
||||
@@ -26,6 +27,7 @@ def test_skill_references_all_workflow_files():
|
||||
"workflows/reconcile-landed-pr.md",
|
||||
"workflows/create-issue.md",
|
||||
"workflows/work-issue.md",
|
||||
"workflows/pr-queue-cleanup.md",
|
||||
):
|
||||
assert name in SKILL
|
||||
|
||||
@@ -36,6 +38,7 @@ def test_skill_contains_mode_isolation_language():
|
||||
assert "reconcile-landed-pr" in SKILL
|
||||
assert "create-issue" in SKILL
|
||||
assert "work-issue" in SKILL
|
||||
assert "pr-queue-cleanup" in SKILL
|
||||
assert "Do not mix modes" in SKILL or "do not mix modes" in SKILL.lower()
|
||||
|
||||
|
||||
@@ -78,6 +81,12 @@ def test_reconcile_landed_workflow_contract():
|
||||
assert "canonical: true" in text
|
||||
assert "Do not review or merge normal PRs" in text
|
||||
assert "Already-landed proof" in text
|
||||
# Issue #302: partial reconciliation policy must stay documented.
|
||||
assert "## 12A. Partial reconciliation policy (#302)" in text
|
||||
assert "comment-then-stop" in text
|
||||
assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text
|
||||
assert "RECOVERY_HANDOFF_ONLY" in text
|
||||
assert "resolve_partial_reconciliation_plan" in text
|
||||
|
||||
|
||||
def test_create_issue_workflow_contract():
|
||||
@@ -91,6 +100,19 @@ def test_work_issue_workflow_contract():
|
||||
assert "canonical: true" in text
|
||||
assert "Do not merge your own PR" in text
|
||||
assert "## 21. PR creation rules" in text
|
||||
assert "gitea_route_task_session" in text
|
||||
assert "work-issue" in text
|
||||
|
||||
|
||||
def test_pr_queue_cleanup_workflow_contract():
|
||||
text = (SKILL_DIR / "workflows" / "pr-queue-cleanup.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "canonical: true" in text
|
||||
assert "exactly one" in text
|
||||
assert "check_cleanup_task_allowed" in text
|
||||
assert "resolve_cleanup_run_state" in text
|
||||
assert "Next suggested PR" in text
|
||||
|
||||
|
||||
def test_final_report_schemas_exist():
|
||||
@@ -99,6 +121,7 @@ def test_final_report_schemas_exist():
|
||||
"reconcile-landed-final-report.md",
|
||||
"create-issue-final-report.md",
|
||||
"work-issue-final-report.md",
|
||||
"pr-queue-cleanup-final-report.md",
|
||||
):
|
||||
assert (SKILL_DIR / "schemas" / name).is_file(), name
|
||||
|
||||
@@ -118,6 +141,30 @@ def test_start_issue_template_references_work_issue_workflow():
|
||||
assert "workflows/work-issue.md" in text
|
||||
|
||||
|
||||
def test_reconcile_skill_registered_in_mcp_server():
|
||||
from mcp_server import _PROJECT_SKILLS
|
||||
|
||||
assert "gitea-reconcile-landed-pr" in _PROJECT_SKILLS
|
||||
steps = _PROJECT_SKILLS["gitea-reconcile-landed-pr"]["steps"]
|
||||
joined = " ".join(steps).lower()
|
||||
assert "gitea_scan_already_landed_open_prs" in joined
|
||||
assert "gitea_assess_already_landed_reconciliation" in joined
|
||||
|
||||
|
||||
def test_pr_queue_cleanup_template_references_workflow():
|
||||
text = (SKILL_DIR / "templates" / "pr-queue-cleanup.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "workflows/pr-queue-cleanup.md" in text
|
||||
assert "pr_queue_cleanup" in text
|
||||
|
||||
|
||||
def test_pr_queue_cleanup_verifier_exported():
|
||||
from review_proofs import assess_pr_queue_cleanup_report
|
||||
|
||||
assert callable(assess_pr_queue_cleanup_report)
|
||||
|
||||
|
||||
def test_reviewer_fallback_verifier_exported():
|
||||
from review_proofs import assess_reviewer_fallback_report
|
||||
|
||||
@@ -130,12 +177,48 @@ def test_final_report_validator_exported():
|
||||
assert callable(assess_final_report_validator)
|
||||
|
||||
|
||||
def test_review_final_report_schema_verifier_exported():
|
||||
from review_proofs import assess_review_final_report_schema
|
||||
|
||||
assert callable(assess_review_final_report_schema)
|
||||
|
||||
|
||||
def test_create_issue_final_report_verifier_exported():
|
||||
from review_proofs import assess_create_issue_final_report
|
||||
|
||||
assert callable(assess_create_issue_final_report)
|
||||
|
||||
|
||||
def test_work_issue_final_report_verifier_exported():
|
||||
from review_proofs import assess_work_issue_final_report
|
||||
|
||||
assert callable(assess_work_issue_final_report)
|
||||
|
||||
|
||||
def test_merge_simulation_verifier_exported():
|
||||
from review_proofs import assess_merge_simulation_report
|
||||
|
||||
assert callable(assess_merge_simulation_report)
|
||||
|
||||
|
||||
def test_validation_integrity_verifier_exported():
|
||||
from review_proofs import assess_validation_integrity_report
|
||||
|
||||
assert callable(assess_validation_integrity_report)
|
||||
|
||||
|
||||
def test_validation_failure_history_verifier_exported():
|
||||
from review_proofs import assess_validation_failure_history_report
|
||||
|
||||
assert callable(assess_validation_failure_history_report)
|
||||
|
||||
|
||||
def test_validation_cwd_proof_verifier_exported():
|
||||
from review_proofs import assess_validation_cwd_proof_report
|
||||
|
||||
assert callable(assess_validation_cwd_proof_report)
|
||||
|
||||
|
||||
def test_prior_blocker_skip_verifier_exported():
|
||||
from review_proofs import assess_prior_blocker_skip_proof
|
||||
|
||||
@@ -146,3 +229,82 @@ def test_non_mergeable_skip_verifier_exported():
|
||||
from review_proofs import assess_non_mergeable_skip_proof
|
||||
|
||||
assert callable(assess_non_mergeable_skip_proof)
|
||||
|
||||
|
||||
def test_validation_worktree_edit_verifier_exported():
|
||||
from review_proofs import assess_validation_worktree_edit_report
|
||||
|
||||
assert callable(assess_validation_worktree_edit_report)
|
||||
|
||||
|
||||
def test_reconcile_inventory_verifier_exported():
|
||||
from review_proofs import assess_reconcile_inventory_report
|
||||
|
||||
assert callable(assess_reconcile_inventory_report)
|
||||
|
||||
|
||||
def test_reconcile_linked_issue_verifier_exported():
|
||||
from review_proofs import assess_reconcile_linked_issue_report
|
||||
|
||||
assert callable(assess_reconcile_linked_issue_report)
|
||||
|
||||
|
||||
def test_already_landed_handoff_verifier_exported():
|
||||
from review_proofs import assess_already_landed_handoff_report
|
||||
|
||||
assert callable(assess_already_landed_handoff_report)
|
||||
|
||||
|
||||
def test_inventory_worktree_verifier_exported():
|
||||
from review_proofs import assess_inventory_worktree_report
|
||||
|
||||
assert callable(assess_inventory_worktree_report)
|
||||
|
||||
|
||||
def test_proof_backed_handoff_verifier_exported():
|
||||
from review_proofs import assess_proof_backed_handoff_report
|
||||
|
||||
assert callable(assess_proof_backed_handoff_report)
|
||||
|
||||
|
||||
def test_infra_stop_handoff_verifier_exported():
|
||||
from review_proofs import assess_infra_stop_handoff_report
|
||||
|
||||
assert callable(assess_infra_stop_handoff_report)
|
||||
|
||||
|
||||
def test_mutation_categories_verifier_exported():
|
||||
from review_proofs import assess_mutation_categories_report
|
||||
|
||||
assert callable(assess_mutation_categories_report)
|
||||
|
||||
|
||||
def test_worktree_ownership_verifier_exported():
|
||||
from review_proofs import assess_worktree_ownership_report
|
||||
|
||||
assert callable(assess_worktree_ownership_report)
|
||||
|
||||
|
||||
def test_already_landed_classification_verifier_exported():
|
||||
from review_proofs import assess_already_landed_classification_report
|
||||
|
||||
assert callable(assess_already_landed_classification_report)
|
||||
|
||||
|
||||
def test_pr_queue_cleanup_verifier_exported():
|
||||
from review_proofs import assess_pr_queue_cleanup_report
|
||||
|
||||
assert callable(assess_pr_queue_cleanup_report)
|
||||
|
||||
|
||||
def test_pr_queue_cleanup_workflow_contract():
|
||||
text = (SKILL_DIR / "workflows" / "pr-queue-cleanup.md").read_text(encoding="utf-8")
|
||||
assert "canonical: true" in text
|
||||
assert "task_mode: pr-queue-cleanup" in text
|
||||
assert "## 1. Mode isolation" in text
|
||||
assert "## 4. Terminal mutation chain" in text
|
||||
assert "## 5. Fresh run per PR" in text
|
||||
assert "exactly one" in text.lower()
|
||||
assert "review-merge-pr.md" in text
|
||||
assert "Next suggested PR" in text
|
||||
assert "schemas/pr-queue-cleanup-final-report.md" in text
|
||||
|
||||
+675
-144
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
"""Hermetic tests for merge approval head pinning (#471)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from merge_approval_gate import assess_merge_approval_head # noqa: E402
|
||||
|
||||
HEAD_OLD = "8b61c4b41f1b49b271ed3b99657431cf06eeda3e"
|
||||
HEAD_NEW = "3e4b721d60e97147ba0704773cf57cd0d42cbe31"
|
||||
|
||||
|
||||
class TestMergeApprovalGate(unittest.TestCase):
|
||||
def test_fresh_approval_at_current_head(self):
|
||||
result = assess_merge_approval_head(
|
||||
current_head_sha=HEAD_NEW,
|
||||
latest_by_reviewer={
|
||||
"reviewer1": {
|
||||
"verdict": "APPROVED",
|
||||
"dismissed": False,
|
||||
"reviewed_head_sha": HEAD_NEW,
|
||||
"submitted_at": "2026-07-06T12:00:00Z",
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["approval_at_current_head"])
|
||||
self.assertIsNone(result["stale_approval_block_reason"])
|
||||
|
||||
def test_stale_approval_after_rebase(self):
|
||||
result = assess_merge_approval_head(
|
||||
current_head_sha=HEAD_NEW,
|
||||
latest_by_reviewer={
|
||||
"reviewer1": {
|
||||
"verdict": "APPROVED",
|
||||
"dismissed": False,
|
||||
"reviewed_head_sha": HEAD_OLD,
|
||||
"submitted_at": "2026-07-06T10:00:00Z",
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertFalse(result["approval_at_current_head"])
|
||||
self.assertEqual(result["latest_approved_head_sha"], HEAD_OLD)
|
||||
self.assertIn("stale approval", result["stale_approval_block_reason"])
|
||||
self.assertIn(HEAD_OLD, result["stale_approval_block_reason"])
|
||||
self.assertIn(HEAD_NEW, result["stale_approval_block_reason"])
|
||||
self.assertIn("re-review PR at current head", result["stale_approval_block_reason"])
|
||||
|
||||
def test_no_approval_entries(self):
|
||||
result = assess_merge_approval_head(
|
||||
current_head_sha=HEAD_NEW,
|
||||
latest_by_reviewer={},
|
||||
)
|
||||
self.assertFalse(result["approval_at_current_head"])
|
||||
self.assertIsNone(result["latest_approved_head_sha"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for native MCP preference gate and shell circuit breaker (#270)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import native_mcp_preference as nmp # noqa: E402
|
||||
|
||||
|
||||
class TestShellHealthCircuitBreaker(unittest.TestCase):
|
||||
def setUp(self):
|
||||
nmp.clear_shell_health_for_tests()
|
||||
|
||||
def test_two_spawn_failures_hard_stop(self):
|
||||
nmp.record_shell_spawn_outcome(exit_code=-1, stdout="", stderr="")
|
||||
status = nmp.record_shell_spawn_outcome(exit_code=-1, stdout="", stderr="")
|
||||
self.assertTrue(status["hard_stopped"])
|
||||
self.assertFalse(status["shell_use_allowed"])
|
||||
self.assertEqual(status["consecutive_spawn_failures"], 2)
|
||||
|
||||
def test_success_resets_breaker(self):
|
||||
nmp.record_shell_spawn_outcome(exit_code=-1, stdout="", stderr="")
|
||||
status = nmp.record_shell_spawn_outcome(exit_code=0, stdout="ok", stderr="")
|
||||
self.assertFalse(status["hard_stopped"])
|
||||
self.assertEqual(status["consecutive_spawn_failures"], 0)
|
||||
|
||||
|
||||
class TestNativeMcpPreferenceGate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
nmp.clear_shell_health_for_tests()
|
||||
|
||||
def test_mcp_available_blocks_local_script(self):
|
||||
result = nmp.assess_gitea_operation_path(
|
||||
task="create_pr",
|
||||
command_or_detail="python create_pr.py --remote prgs --title T --head h",
|
||||
mcp_available=True,
|
||||
mcp_tool_visible=True,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["path_kind"], "shell_script")
|
||||
|
||||
def test_mcp_native_path_allowed(self):
|
||||
result = nmp.assess_gitea_operation_path(
|
||||
task="comment_issue",
|
||||
path_kind="mcp_native",
|
||||
mcp_available=True,
|
||||
mcp_tool_visible=True,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
def test_mcp_unavailable_requires_terminal_report(self):
|
||||
result = nmp.assess_gitea_operation_path(
|
||||
task="merge_pr",
|
||||
path_kind="shell_script",
|
||||
mcp_available=False,
|
||||
mcp_tool_visible=False,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(
|
||||
any("terminal recovery report" in r for r in result["reasons"])
|
||||
)
|
||||
self.assertIn(nmp.TERMINAL_REPORT_HEADING, result["terminal_report"])
|
||||
|
||||
def test_recovery_fallback_allowed_with_proof(self):
|
||||
result = nmp.assess_gitea_operation_path(
|
||||
task="merge_pr",
|
||||
path_kind="shell_script",
|
||||
command_or_detail="Recovery mode: MCP tools unavailable in this session.",
|
||||
mcp_available=False,
|
||||
recovery_mode=True,
|
||||
recovery_proof_complete=True,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_cli_auth_divergence_blocked(self):
|
||||
result = nmp.assess_gitea_operation_path(
|
||||
task="create_pr",
|
||||
command_or_detail="GITEA_MCP_PROFILE=prgs-reviewer python create_pr.py",
|
||||
mcp_available=True,
|
||||
active_profile="prgs-author",
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("CLI auth divergence" in r for r in result["reasons"]))
|
||||
|
||||
def test_unsafe_helper_fallback_denied(self):
|
||||
result = nmp.assess_gitea_operation_path(
|
||||
task="commit_files",
|
||||
command_or_detail="python _encode_payload.py",
|
||||
mcp_available=True,
|
||||
mcp_tool_visible=True,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["path_kind"], "unsafe_helper")
|
||||
|
||||
def test_reviewer_mcp_server_touch_blocked(self):
|
||||
result = nmp.assess_gitea_operation_path(
|
||||
task="review_pr",
|
||||
command_or_detail="pkill -f gitea_mcp_server.py",
|
||||
mcp_available=True,
|
||||
role="reviewer",
|
||||
active_profile="prgs-reviewer",
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["path_kind"], "mcp_server_touch")
|
||||
|
||||
def test_hard_stopped_shell_blocks_non_mcp_path(self):
|
||||
nmp.record_shell_spawn_outcome(exit_code=-1, stdout="", stderr="")
|
||||
nmp.record_shell_spawn_outcome(exit_code=-1, stdout="", stderr="")
|
||||
result = nmp.assess_gitea_operation_path(
|
||||
task="lock_issue",
|
||||
path_kind="shell_script",
|
||||
mcp_available=True,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("circuit breaker" in r for r in result["reasons"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -230,7 +230,9 @@ class TestEligibilityDenialReport(PermissionReportBase):
|
||||
return PR_PAYLOAD
|
||||
mock_api.side_effect = fake_api
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
mcp_server.gitea_mark_final_review_decision(42, "approve", remote="prgs")
|
||||
from tests.test_mcp_server import _seed_ready_review_decision
|
||||
|
||||
_seed_ready_review_decision(42, "approve", remote="prgs")
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_submit_pr_review(
|
||||
pr_number=42, action="approve", body="lgtm", remote="prgs",
|
||||
@@ -272,7 +274,9 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase):
|
||||
return PR_PAYLOAD
|
||||
mock_api.side_effect = fake_api
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
mcp_server.gitea_mark_final_review_decision(42, "comment", remote="prgs")
|
||||
from tests.test_mcp_server import _seed_ready_review_decision
|
||||
|
||||
_seed_ready_review_decision(42, "comment", remote="prgs")
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_submit_pr_review(
|
||||
pr_number=42, action="comment", body="finding", remote="prgs",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
from mcp_server import ( # noqa: E402
|
||||
_list_pr_lease_comments,
|
||||
gitea_list_issue_comments,
|
||||
)
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
AUTHOR_ENV = {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||
}
|
||||
|
||||
|
||||
class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_pr_lease_comments_non_list_payload_returns_empty(self, _auth, mock_api):
|
||||
mock_api.return_value = {"message": "Unauthorized"}
|
||||
result = _list_pr_lease_comments(
|
||||
12, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_pr_lease_comments_none_returns_empty(self, _auth, mock_api):
|
||||
mock_api.return_value = None
|
||||
result = _list_pr_lease_comments(
|
||||
12, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_pr_lease_comments_list_payload_unchanged(self, _auth, mock_api):
|
||||
comment = {"id": 7, "body": "<!-- mcp-review-lease:v1 -->"}
|
||||
mock_api.return_value = [comment]
|
||||
result = _list_pr_lease_comments(
|
||||
12, remote="prgs", host=None, org=None, repo=None, limit=5)
|
||||
self.assertEqual(result, [comment])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_issue_comments_non_list_payload_returns_empty(self, _auth, mock_api):
|
||||
mock_api.return_value = {"message": "Unauthorized"}
|
||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["comments"], [])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_issue_comments_list_payload_unchanged(self, _auth, mock_api):
|
||||
mock_api.return_value = [
|
||||
{
|
||||
"id": 101,
|
||||
"user": {"login": "alice"},
|
||||
"body": "hello",
|
||||
"created_at": "2026-07-03T00:00:00Z",
|
||||
"updated_at": "2026-07-03T01:00:00Z",
|
||||
}
|
||||
]
|
||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(len(result["comments"]), 1)
|
||||
self.assertEqual(result["comments"][0]["author"], "alice")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Tests for PR-only queue cleanup mode (#390)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from pr_queue_cleanup import (
|
||||
CLEANUP_FORBIDDEN_TASKS,
|
||||
CLEANUP_WORKFLOW_PATH,
|
||||
CONTINUE_TO_SAME_PR_MERGE,
|
||||
STOP_AFTER_DECISION,
|
||||
STOP_AFTER_MERGE,
|
||||
STOP_AFTER_MERGE_BLOCKER,
|
||||
STOP_AFTER_REQUEST_CHANGES,
|
||||
STOP_APPROVED_MERGE_GATES_FAILED,
|
||||
STOP_APPROVED_NO_MERGE_AUTH,
|
||||
STOP_GATE_NOT_PROVEN,
|
||||
assess_pr_queue_cleanup_report,
|
||||
check_cleanup_task_allowed,
|
||||
resolve_cleanup_run_state,
|
||||
)
|
||||
from review_proofs import assess_pr_queue_cleanup_report as proofs_assess
|
||||
from role_session_router import REVIEWER_TASKS, route_task_session
|
||||
from task_capability_map import required_permission, required_role
|
||||
|
||||
|
||||
class TestCleanupCapabilityMapping(unittest.TestCase):
|
||||
def test_cleanup_task_is_reviewer_role(self):
|
||||
for key in ("pr_queue_cleanup", "pr-queue-cleanup"):
|
||||
self.assertEqual(required_permission(key), "gitea.pr.review")
|
||||
self.assertEqual(required_role(key), "reviewer")
|
||||
|
||||
def test_cleanup_task_registered_as_reviewer_route(self):
|
||||
self.assertIn("pr_queue_cleanup", REVIEWER_TASKS)
|
||||
|
||||
def test_author_session_gets_wrong_role_stop(self):
|
||||
result = route_task_session(
|
||||
"pr_queue_cleanup",
|
||||
active_profile="prgs-author",
|
||||
active_role_kind="author",
|
||||
allowed_in_current_session=False,
|
||||
)
|
||||
self.assertEqual(result["route_result"], "wrong_role_stop")
|
||||
self.assertFalse(result["downstream_allowed"])
|
||||
|
||||
def test_reviewer_session_allowed(self):
|
||||
result = route_task_session(
|
||||
"pr_queue_cleanup",
|
||||
active_profile="prgs-reviewer",
|
||||
active_role_kind="reviewer",
|
||||
allowed_in_current_session=True,
|
||||
)
|
||||
self.assertEqual(result["route_result"], "allowed_current_session")
|
||||
self.assertTrue(result["downstream_allowed"])
|
||||
|
||||
|
||||
class TestCleanupTaskGate(unittest.TestCase):
|
||||
def test_author_tasks_blocked(self):
|
||||
for task in (
|
||||
"claim_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"create_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
):
|
||||
allowed, reasons = check_cleanup_task_allowed(task)
|
||||
self.assertFalse(allowed, task)
|
||||
self.assertTrue(reasons, task)
|
||||
|
||||
def test_review_task_allowed(self):
|
||||
allowed, reasons = check_cleanup_task_allowed("review_pr")
|
||||
self.assertTrue(allowed)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
def test_forbidden_set_covers_issue_filing_and_impl(self):
|
||||
self.assertIn("create_issue", CLEANUP_FORBIDDEN_TASKS)
|
||||
self.assertIn("create_branch", CLEANUP_FORBIDDEN_TASKS)
|
||||
self.assertIn("commit_files", CLEANUP_FORBIDDEN_TASKS)
|
||||
|
||||
|
||||
class TestCleanupRunState(unittest.TestCase):
|
||||
def test_request_changes_stops_run(self):
|
||||
state = resolve_cleanup_run_state("request_changes")
|
||||
self.assertEqual(state["outcome"], STOP_AFTER_REQUEST_CHANGES)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_comment_and_skip_stop_run(self):
|
||||
for decision in ("comment", "skip"):
|
||||
state = resolve_cleanup_run_state(decision)
|
||||
self.assertEqual(state["outcome"], STOP_AFTER_DECISION)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_approved_without_merge_auth_stops(self):
|
||||
state = resolve_cleanup_run_state("approved", merge_gates_passed=True)
|
||||
self.assertEqual(state["outcome"], STOP_APPROVED_NO_MERGE_AUTH)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_approved_with_merge_auth_continues_to_merge(self):
|
||||
state = resolve_cleanup_run_state(
|
||||
"approved",
|
||||
merge_authorized_for_pr=True,
|
||||
merge_gates_passed=True,
|
||||
)
|
||||
self.assertEqual(state["outcome"], CONTINUE_TO_SAME_PR_MERGE)
|
||||
self.assertTrue(state["further_mutation_allowed"])
|
||||
self.assertEqual(state["allowed_mutation"], "merge same PR only")
|
||||
|
||||
def test_approved_with_auth_but_failed_gates_stops(self):
|
||||
state = resolve_cleanup_run_state(
|
||||
"approved",
|
||||
merge_authorized_for_pr=True,
|
||||
merge_gates_passed=False,
|
||||
)
|
||||
self.assertEqual(state["outcome"], STOP_APPROVED_MERGE_GATES_FAILED)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_merge_completed_stops(self):
|
||||
state = resolve_cleanup_run_state(
|
||||
"approved",
|
||||
merge_authorized_for_pr=True,
|
||||
merge_gates_passed=True,
|
||||
merge_completed=True,
|
||||
)
|
||||
self.assertEqual(state["outcome"], STOP_AFTER_MERGE)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_merge_blocker_stops(self):
|
||||
state = resolve_cleanup_run_state(
|
||||
"approved",
|
||||
merge_authorized_for_pr=True,
|
||||
merge_gates_passed=True,
|
||||
merge_blocker=True,
|
||||
)
|
||||
self.assertEqual(state["outcome"], STOP_AFTER_MERGE_BLOCKER)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_unknown_decision_fails_closed(self):
|
||||
state = resolve_cleanup_run_state("ship_it")
|
||||
self.assertEqual(state["outcome"], STOP_GATE_NOT_PROVEN)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
|
||||
def _clean_report(**overrides):
|
||||
lines = {
|
||||
"task": "Task: pr-queue-cleanup",
|
||||
"workflow": f"Workflow source: {CLEANUP_WORKFLOW_PATH} (hash abc123def456)",
|
||||
"pagination": (
|
||||
"PR inventory pagination proof: inventory_complete=true, final "
|
||||
"page, total_count 15"
|
||||
),
|
||||
"selected": "Selected PR: #371",
|
||||
"decision": "Review decision: approved",
|
||||
"merge_auth": "Merge authorized for PR: false",
|
||||
"merge_result": "Merge result: not attempted",
|
||||
"next": "Next suggested PR: #372",
|
||||
"issue_mut": "Issue mutations: none",
|
||||
"branch_mut": "Branch mutations: none",
|
||||
}
|
||||
lines.update(overrides)
|
||||
return "\n".join(v for v in lines.values() if v)
|
||||
|
||||
|
||||
class TestCleanupReportVerifier(unittest.TestCase):
|
||||
def test_clean_single_pr_report_passes(self):
|
||||
result = assess_pr_queue_cleanup_report(_clean_report())
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_approved_and_merged_with_authorization_passes(self):
|
||||
report = _clean_report(
|
||||
merge_auth="Merge authorized for PR: true",
|
||||
merge_result="Merge result: merged 0123456789ab",
|
||||
)
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_request_changes_then_stop_passes(self):
|
||||
report = _clean_report(decision="Review decision: request_changes")
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_multi_pr_selection_blocked(self):
|
||||
report = _clean_report() + "\nSelected PR: #403\n"
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("multiple prs" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_two_terminal_mutations_blocked(self):
|
||||
report = _clean_report() + "\nReview decision: request_changes"
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("terminal review mutations" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_missing_pagination_proof_blocked(self):
|
||||
report = _clean_report(
|
||||
pagination="PR inventory pagination proof: inventory listed"
|
||||
)
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("pagination" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_missing_next_suggested_pr_blocked(self):
|
||||
report = _clean_report(next="")
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("Next suggested PR" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_merge_without_authorization_blocked(self):
|
||||
report = _clean_report(
|
||||
merge_result="Merge result: merged abc123abc123",
|
||||
)
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("authorization" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_issue_mutations_forbidden(self):
|
||||
report = _clean_report(issue_mut="Issue mutations: gitea_mark_issue")
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("issue mutations" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_branch_mutations_forbidden(self):
|
||||
report = _clean_report(branch_mut="Branch mutations: created feat/x")
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("branch mutations" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_missing_workflow_citation_blocked(self):
|
||||
report = _clean_report(workflow="Workflow source: none")
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("pr-queue-cleanup.md" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_review_proofs_wrapper_matches_module(self):
|
||||
direct = assess_pr_queue_cleanup_report(_clean_report())
|
||||
wrapped = proofs_assess(_clean_report())
|
||||
self.assertEqual(direct["proven"], wrapped["proven"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -128,18 +128,29 @@ class TestPRQueueInventory(unittest.TestCase):
|
||||
"forbidden_operations": [],
|
||||
"base_url": None,
|
||||
}
|
||||
|
||||
from mcp_server import init_review_decision_lock
|
||||
from tests.test_mcp_server import (
|
||||
FULL_HEAD_SHA,
|
||||
_install_owned_reviewer_lease,
|
||||
_seed_ready_review_decision,
|
||||
)
|
||||
import reviewer_pr_lease
|
||||
|
||||
head_sha = FULL_HEAD_SHA
|
||||
mock_fetch.return_value = _final_page_fetch([
|
||||
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}}
|
||||
{"number": 1, "title": "PR 1", "state": "open",
|
||||
"head": {"ref": "branch1", "sha": head_sha},
|
||||
"base": {"ref": "master"}, "mergeable": True,
|
||||
"user": {"login": "other_user"}}
|
||||
])
|
||||
# mock_api: inventory whoami, eligibility whoami, eligibility PR,
|
||||
# POST review (#244: state + visible-verdict GET reviews).
|
||||
mock_api.side_effect = [
|
||||
{"login": "reviewer1"},
|
||||
{"login": "reviewer1"},
|
||||
{
|
||||
"user": {"login": "other_user"},
|
||||
"state": "open",
|
||||
"head": {"sha": "abc1"},
|
||||
"head": {"sha": head_sha},
|
||||
"mergeable": True,
|
||||
},
|
||||
{"id": 100, "state": "APPROVED"},
|
||||
@@ -148,17 +159,27 @@ class TestPRQueueInventory(unittest.TestCase):
|
||||
"id": 100,
|
||||
"user": {"login": "reviewer1"},
|
||||
"state": "APPROVED",
|
||||
"commit_id": "abc1",
|
||||
"commit_id": head_sha,
|
||||
"submitted_at": "2026-07-06T10:00:00Z",
|
||||
"dismissed": False,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
gitea_mark_final_review_decision(1, "approve", remote="prgs")
|
||||
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs", final_review_decision_ready=True)
|
||||
with patch("mcp_server._authenticated_username", return_value="reviewer1"), \
|
||||
patch("mcp_server._list_pr_lease_comments", return_value=[]):
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
_seed_ready_review_decision(1, "approve", sha=head_sha, remote="prgs")
|
||||
lease_patch = _install_owned_reviewer_lease(
|
||||
1, head_sha=head_sha, session_id="inventory-review-lease",
|
||||
)
|
||||
lease_patch.start()
|
||||
self.addCleanup(lease_patch.stop)
|
||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
||||
result = gitea_review_pr(
|
||||
pr_number=1, event="APPROVE", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("=== PR Queue Inventory ===", result["message"])
|
||||
self.assertIn("Repository:", result["message"])
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for conflict-fix and reviewer PR work leases (#399)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from pr_work_lease import ( # noqa: E402
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
REVIEWER_LEASE_MARKER,
|
||||
assess_conflict_fix_final_report,
|
||||
assess_conflict_fix_push,
|
||||
assess_head_sha_equality,
|
||||
assess_reviewer_mutation_blocked,
|
||||
assess_reviewer_stale_head_final_report,
|
||||
format_conflict_fix_lease_body,
|
||||
parse_conflict_fix_lease_comment,
|
||||
parse_reviewer_lease_comment,
|
||||
)
|
||||
|
||||
HEAD_A = "a" * 40
|
||||
HEAD_B = "b" * 40
|
||||
NOW = datetime(2026, 7, 7, 15, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _reviewer_lease_body(*, phase: str = "validating", expires_minutes: int = 60) -> str:
|
||||
expires = (NOW + timedelta(minutes=expires_minutes)).isoformat().replace("+00:00", "Z")
|
||||
return "\n".join([
|
||||
REVIEWER_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
"phase: " + phase,
|
||||
f"candidate_head: {HEAD_A}",
|
||||
f"expires_at: {expires}",
|
||||
"profile: prgs-reviewer",
|
||||
])
|
||||
|
||||
|
||||
def _conflict_fix_body(*, phase: str = "claimed", worktree: str = "branches/fix-376") -> str:
|
||||
expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z")
|
||||
return "\n".join([
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
f"phase: {phase}",
|
||||
f"worktree: {worktree}",
|
||||
f"head_before: {HEAD_A}",
|
||||
f"expires_at: {expires}",
|
||||
"profile: prgs-author",
|
||||
])
|
||||
|
||||
|
||||
class TestLeaseParsing(unittest.TestCase):
|
||||
def test_parse_reviewer_lease(self):
|
||||
parsed = parse_reviewer_lease_comment(_reviewer_lease_body())
|
||||
self.assertEqual(parsed["pr_number"], 376)
|
||||
self.assertEqual(parsed["phase"], "validating")
|
||||
self.assertEqual(parsed["candidate_head"], HEAD_A)
|
||||
|
||||
def test_parse_conflict_fix_lease(self):
|
||||
parsed = parse_conflict_fix_lease_comment(_conflict_fix_body())
|
||||
self.assertEqual(parsed["pr_number"], 376)
|
||||
self.assertEqual(parsed["phase"], "claimed")
|
||||
|
||||
|
||||
class TestConflictFixPushGate(unittest.TestCase):
|
||||
def test_blocks_push_during_active_reviewer_lease(self):
|
||||
comments = [{"body": _reviewer_lease_body()}]
|
||||
result = assess_conflict_fix_push(
|
||||
pr_number=376,
|
||||
comments=comments,
|
||||
branch_head_before=HEAD_A,
|
||||
branch_head_after=HEAD_B,
|
||||
worktree_path="branches/fix-376",
|
||||
push_cwd="/proj/branches/fix-376",
|
||||
is_fast_forward=True,
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["push_allowed"])
|
||||
self.assertTrue(any("reviewer lease" in r for r in result["reasons"]))
|
||||
|
||||
def test_rejects_non_fast_forward(self):
|
||||
result = assess_conflict_fix_push(
|
||||
pr_number=376,
|
||||
comments=[],
|
||||
branch_head_before=HEAD_A,
|
||||
branch_head_after=HEAD_B,
|
||||
worktree_path="branches/fix-376",
|
||||
push_cwd="/proj/branches/fix-376",
|
||||
is_fast_forward=False,
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["push_allowed"])
|
||||
self.assertTrue(any("non-fast-forward" in r for r in result["reasons"]))
|
||||
|
||||
def test_wrong_cwd_push_attempt(self):
|
||||
result = assess_conflict_fix_push(
|
||||
pr_number=376,
|
||||
comments=[],
|
||||
branch_head_before=HEAD_A,
|
||||
branch_head_after=HEAD_B,
|
||||
worktree_path="branches/fix-376",
|
||||
push_cwd="/proj/master",
|
||||
is_fast_forward=True,
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["push_allowed"])
|
||||
self.assertTrue(any("cwd" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_sibling_conflict_fix_collision(self):
|
||||
comments = [{"body": _conflict_fix_body(phase="pushing", worktree="branches/other")}]
|
||||
result = assess_conflict_fix_push(
|
||||
pr_number=376,
|
||||
comments=comments,
|
||||
branch_head_before=HEAD_A,
|
||||
branch_head_after=HEAD_B,
|
||||
worktree_path="branches/fix-376",
|
||||
push_cwd="/proj/branches/fix-376",
|
||||
is_fast_forward=True,
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["push_allowed"])
|
||||
self.assertTrue(any("sibling conflict-fix" in r for r in result["reasons"]))
|
||||
|
||||
|
||||
class TestReviewerMutationGate(unittest.TestCase):
|
||||
def test_blocks_review_during_conflict_fix(self):
|
||||
comments = [{"body": _conflict_fix_body(phase="pushing")}]
|
||||
result = assess_reviewer_mutation_blocked(
|
||||
pr_number=376,
|
||||
comments=comments,
|
||||
reviewed_head_sha=HEAD_A,
|
||||
live_head_sha=HEAD_A,
|
||||
mutation="approve",
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(any("conflict-fix lease" in r for r in result["reasons"]))
|
||||
|
||||
def test_stale_head_blocks_approval(self):
|
||||
result = assess_reviewer_mutation_blocked(
|
||||
pr_number=376,
|
||||
comments=[],
|
||||
reviewed_head_sha=HEAD_A,
|
||||
live_head_sha=HEAD_B,
|
||||
mutation="merge",
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(result["head_check"]["head_changed"])
|
||||
|
||||
def test_head_equality_required_fields(self):
|
||||
result = assess_head_sha_equality(HEAD_A, HEAD_B)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["head_changed"])
|
||||
|
||||
|
||||
class TestFinalReportProof(unittest.TestCase):
|
||||
def test_reviewer_stale_head_report_requires_fields(self):
|
||||
result = assess_reviewer_stale_head_final_report("no head proof here")
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_reviewer_stale_head_report_passes(self):
|
||||
report = "\n".join([
|
||||
f"Reviewed head SHA: {HEAD_A}",
|
||||
f"Final live head SHA before approval: {HEAD_A}",
|
||||
f"Final live head SHA before merge: {HEAD_A}",
|
||||
"Push occurred during validation: no",
|
||||
])
|
||||
result = assess_reviewer_stale_head_final_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_conflict_fix_report_requires_fields(self):
|
||||
result = assess_conflict_fix_final_report("incomplete")
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_conflict_fix_report_passes(self):
|
||||
report = "\n".join([
|
||||
f"Branch head before push: {HEAD_A}",
|
||||
f"Branch head after push: {HEAD_B}",
|
||||
"Active reviewer lease status: none",
|
||||
"Whether push was fast-forward: yes",
|
||||
"Whether any reviewer was active: no",
|
||||
])
|
||||
result = assess_conflict_fix_final_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
|
||||
class TestFormatLease(unittest.TestCase):
|
||||
def test_format_conflict_fix_lease_includes_marker(self):
|
||||
body = format_conflict_fix_lease_body(
|
||||
pr_number=376,
|
||||
branch="feat/x",
|
||||
worktree="branches/fix-376",
|
||||
profile="prgs-author",
|
||||
head_before=HEAD_A,
|
||||
)
|
||||
self.assertIn(CONFLICT_FIX_LEASE_MARKER, body)
|
||||
parsed = parse_conflict_fix_lease_comment(body)
|
||||
self.assertEqual(parsed["pr_number"], 376)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""#469/#470: capability preflight lifetime across safe read-only calls."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
|
||||
|
||||
class TestPreflightReadSurvival(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.orig_whoami = mcp_server._preflight_whoami_called
|
||||
self.orig_capability = mcp_server._preflight_capability_called
|
||||
self.orig_whoami_violation = mcp_server._preflight_whoami_violation
|
||||
self.orig_capability_violation = mcp_server._preflight_capability_violation
|
||||
self.orig_resolved_role = mcp_server._preflight_resolved_role
|
||||
self.orig_resolved_task = mcp_server._preflight_resolved_task
|
||||
self.orig_process_start = mcp_server._process_start_porcelain
|
||||
self.orig_whoami_baseline = mcp_server._preflight_whoami_baseline_porcelain
|
||||
self.orig_capability_baseline = mcp_server._preflight_capability_baseline_porcelain
|
||||
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
os.environ["GITEA_TEST_PORCELAIN"] = ""
|
||||
mcp_server._preflight_whoami_called = False
|
||||
mcp_server._preflight_capability_called = False
|
||||
mcp_server._preflight_whoami_violation = False
|
||||
mcp_server._preflight_capability_violation = False
|
||||
mcp_server._preflight_resolved_role = None
|
||||
mcp_server._preflight_resolved_task = None
|
||||
mcp_server._process_start_porcelain = ""
|
||||
mcp_server._preflight_whoami_baseline_porcelain = None
|
||||
mcp_server._preflight_capability_baseline_porcelain = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._preflight_whoami_called = self.orig_whoami
|
||||
mcp_server._preflight_capability_called = self.orig_capability
|
||||
mcp_server._preflight_whoami_violation = self.orig_whoami_violation
|
||||
mcp_server._preflight_capability_violation = self.orig_capability_violation
|
||||
mcp_server._preflight_resolved_role = self.orig_resolved_role
|
||||
mcp_server._preflight_resolved_task = self.orig_resolved_task
|
||||
mcp_server._process_start_porcelain = self.orig_process_start
|
||||
mcp_server._preflight_whoami_baseline_porcelain = self.orig_whoami_baseline
|
||||
mcp_server._preflight_capability_baseline_porcelain = self.orig_capability_baseline
|
||||
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
def test_interleaved_whoami_preserves_capability(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="reconciler", resolved_task="close_pr"
|
||||
)
|
||||
self.assertTrue(mcp_server._preflight_capability_called)
|
||||
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
|
||||
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
self.assertTrue(mcp_server._preflight_capability_called)
|
||||
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
|
||||
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
self.assertFalse(mcp_server._preflight_capability_called)
|
||||
|
||||
def test_missing_capability_still_fails_closed(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
self.assertIn("has not been resolved", str(ctx.exception))
|
||||
|
||||
def test_task_mismatch_fails_closed(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="author", resolved_task="create_issue"
|
||||
)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
self.assertIn("task mismatch", str(ctx.exception))
|
||||
|
||||
def test_capability_consumed_after_mutation_gate(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="author", resolved_task="create_issue"
|
||||
)
|
||||
mcp_server.verify_preflight_purity(task="create_issue")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity(task="create_issue")
|
||||
self.assertIn("has not been resolved", str(ctx.exception))
|
||||
|
||||
def test_whoami_recovery_after_violation_clears_capability(self):
|
||||
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
self.assertTrue(mcp_server._preflight_whoami_violation)
|
||||
|
||||
del os.environ["GITEA_TEST_FORCE_DIRTY"]
|
||||
os.environ["GITEA_TEST_PORCELAIN"] = ""
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
self.assertFalse(mcp_server._preflight_whoami_violation)
|
||||
self.assertFalse(mcp_server._preflight_capability_called)
|
||||
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="reviewer", resolved_task="review_pr"
|
||||
)
|
||||
mcp_server.verify_preflight_purity(task="review_pr")
|
||||
|
||||
def test_close_pr_sequence_with_interleaved_reads(self):
|
||||
"""resolve(close_pr) → whoami/view reads → close_pr gate (#470)."""
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="reconciler", resolved_task="close_pr"
|
||||
)
|
||||
# Simulate live-state revalidation between resolve and mutation.
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
self.assertTrue(mcp_server._preflight_capability_called)
|
||||
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
self.assertFalse(mcp_server._preflight_capability_called)
|
||||
|
||||
def test_fresh_capability_resolve_replaces_prior_task(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="author", resolved_task="create_issue"
|
||||
)
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="reconciler", resolved_task="close_pr"
|
||||
)
|
||||
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
|
||||
def test_missing_capability_error_names_re_resolve(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("gitea_resolve_task_capability", msg)
|
||||
self.assertIn('task="close_pr"', msg)
|
||||
|
||||
def test_task_mismatch_error_names_re_resolve(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="author", resolved_task="create_issue"
|
||||
)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("task mismatch", msg)
|
||||
self.assertIn('task="close_pr"', msg)
|
||||
|
||||
def test_consumed_capability_error_names_re_resolve(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="reconciler", resolved_task="close_pr"
|
||||
)
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
self.assertIn('task="close_pr"', str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for gitea_reconcile_already_landed_pr MCP tool (#310)."""
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server
|
||||
from mcp_server import gitea_reconcile_already_landed_pr
|
||||
|
||||
RECONCILER_PROFILE = {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.pr.close",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
],
|
||||
"audit_label": "prgs-reconciler",
|
||||
}
|
||||
|
||||
AUTHOR_PROFILE = {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.close", "gitea.pr.approve", "gitea.pr.merge"],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
OPEN_LANDED_PR = {
|
||||
"number": 278,
|
||||
"title": "Already landed (Closes #263)",
|
||||
"body": "",
|
||||
"state": "open",
|
||||
"head": {"ref": "feat/issue-263", "sha": "2a544b78d1371925761d16f1c451bb3b2984470e"},
|
||||
"base": {"ref": "master"},
|
||||
}
|
||||
|
||||
|
||||
class TestReconcileAlreadyLandedPrTool(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch(
|
||||
"mcp_server.get_auth_header", return_value="token test"
|
||||
).start()
|
||||
patch("mcp_server.verify_preflight_purity").start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def _set_profile(self, profile):
|
||||
patch("mcp_server.get_profile", return_value=profile).start()
|
||||
|
||||
def test_close_blocked_without_pr_close_capability(self):
|
||||
self._set_profile(AUTHOR_PROFILE)
|
||||
res = gitea_reconcile_already_landed_pr(
|
||||
pr_number=278,
|
||||
close_pr=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["required_permission"], "gitea.pr.close")
|
||||
self.mock_api.assert_not_called()
|
||||
|
||||
def test_not_landed_pr_close_denied_after_live_fetch(self):
|
||||
self._set_profile(RECONCILER_PROFILE)
|
||||
self.mock_api.return_value = OPEN_LANDED_PR
|
||||
with patch(
|
||||
"mcp_server.already_landed_reconcile.assess_already_landed_reconciliation",
|
||||
return_value={
|
||||
"pr_state": "open",
|
||||
"candidate_head_sha": OPEN_LANDED_PR["head"]["sha"],
|
||||
"target_branch": "master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
"ancestor_proof": False,
|
||||
"eligibility_class": "NOT_ALREADY_LANDED",
|
||||
"linked_issue": 263,
|
||||
"close_allowed": False,
|
||||
"git_ref_mutations": ["git fetch prgs master"],
|
||||
"reasons": ["not ancestor"],
|
||||
},
|
||||
):
|
||||
res = gitea_reconcile_already_landed_pr(
|
||||
pr_number=278,
|
||||
close_pr=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res.get("pr_closed"))
|
||||
patch_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "PATCH"
|
||||
]
|
||||
self.assertEqual(patch_calls, [])
|
||||
|
||||
def test_already_landed_close_allowed_with_capability(self):
|
||||
self._set_profile(RECONCILER_PROFILE)
|
||||
|
||||
def api_side_effect(method, url, auth, payload=None):
|
||||
if method == "GET" and "/pulls/278" in url:
|
||||
return dict(OPEN_LANDED_PR)
|
||||
if method == "PATCH" and "/pulls/278" in url:
|
||||
closed = dict(OPEN_LANDED_PR)
|
||||
closed["state"] = "closed"
|
||||
return closed
|
||||
return {}
|
||||
|
||||
self.mock_api.side_effect = api_side_effect
|
||||
with patch(
|
||||
"mcp_server.already_landed_reconcile.assess_already_landed_reconciliation",
|
||||
return_value={
|
||||
"pr_state": "open",
|
||||
"candidate_head_sha": OPEN_LANDED_PR["head"]["sha"],
|
||||
"target_branch": "master",
|
||||
"target_branch_sha": "e017d80256217f17b0f421cd051cfa5cbcf5ae0f",
|
||||
"ancestor_proof": True,
|
||||
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"linked_issue": 263,
|
||||
"close_allowed": True,
|
||||
"git_ref_mutations": ["git fetch prgs master"],
|
||||
"reasons": [],
|
||||
},
|
||||
):
|
||||
res = gitea_reconcile_already_landed_pr(
|
||||
pr_number=278,
|
||||
close_pr=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertTrue(res["performed"])
|
||||
self.assertTrue(res["pr_closed"])
|
||||
self.assertEqual(res["eligibility_class"], "ALREADY_LANDED_RECONCILE_REQUIRED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Issue #309: dedicated reconciler capability to close already-landed PRs.
|
||||
|
||||
The reconciler path must prove exact ``gitea.pr.close`` capability, must
|
||||
never imply review/approve/request-changes/merge capability, and may close
|
||||
a PR only after full already-landed proof.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import role_session_router # noqa: E402
|
||||
import task_capability_map # noqa: E402
|
||||
from review_proofs import assess_reconciler_close_gate # noqa: E402
|
||||
|
||||
PINNED = "0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
||||
OTHER = "a4060c5de00f2b1c9e88f4f6f0f3f9a7b2c1d0e9"
|
||||
|
||||
|
||||
class TestReconcilerCapabilityMap(unittest.TestCase):
|
||||
def test_reconcile_close_pr_task_maps_to_pr_close(self):
|
||||
self.assertEqual(
|
||||
task_capability_map.required_permission(
|
||||
"reconcile_close_landed_pr"),
|
||||
"gitea.pr.close",
|
||||
)
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role("reconcile_close_landed_pr"),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_reconcile_close_issue_task_maps_to_issue_close(self):
|
||||
self.assertEqual(
|
||||
task_capability_map.required_permission(
|
||||
"reconcile_close_landed_issue"),
|
||||
"gitea.issue.close",
|
||||
)
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role(
|
||||
"reconcile_close_landed_issue"),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_reconciler_tasks_do_not_grant_review_permissions(self):
|
||||
for task in ("reconcile_close_landed_pr",
|
||||
"reconcile_close_landed_issue"):
|
||||
permission = task_capability_map.required_permission(task)
|
||||
self.assertNotIn("review", permission)
|
||||
self.assertNotIn("approve", permission)
|
||||
self.assertNotIn("merge", permission)
|
||||
self.assertNotIn("request_changes", permission)
|
||||
|
||||
|
||||
class TestReconcilerRouting(unittest.TestCase):
|
||||
def test_reconciler_task_requires_reconciler_role(self):
|
||||
self.assertEqual(
|
||||
role_session_router.required_role_for_task(
|
||||
"reconcile_close_landed_pr"),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_reconciler_task_blocked_in_author_session(self):
|
||||
result = role_session_router.route_task_session(
|
||||
"reconcile_close_landed_pr",
|
||||
active_profile="prgs-author",
|
||||
active_role_kind="author",
|
||||
allowed_in_current_session=False,
|
||||
)
|
||||
self.assertEqual(result["route_result"], "wrong_role_stop")
|
||||
self.assertFalse(result["downstream_allowed"])
|
||||
|
||||
def test_reconciler_task_allowed_in_matching_session(self):
|
||||
result = role_session_router.route_task_session(
|
||||
"reconcile_close_landed_pr",
|
||||
active_profile="prgs-reconciler",
|
||||
active_role_kind="reconciler",
|
||||
allowed_in_current_session=True,
|
||||
)
|
||||
self.assertEqual(result["route_result"], "allowed_current_session")
|
||||
self.assertTrue(result["downstream_allowed"])
|
||||
|
||||
|
||||
class TestReconcilerCloseGate(unittest.TestCase):
|
||||
def _gate(self, **overrides):
|
||||
kwargs = {
|
||||
"pr_number": 278,
|
||||
"pr_state": "open",
|
||||
"candidate_head_sha": PINNED,
|
||||
"live_head_sha": PINNED,
|
||||
"target_branch": "master",
|
||||
"target_branch_sha": OTHER,
|
||||
"head_is_ancestor_of_target": True,
|
||||
"close_pr_capability": True,
|
||||
"close_issue_capability": False,
|
||||
"linked_issue_state": "closed",
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return assess_reconciler_close_gate(**kwargs)
|
||||
|
||||
def test_already_landed_open_pr_close_allowed(self):
|
||||
result = self._gate()
|
||||
self.assertEqual(result["outcome"], "CLOSE_ALLOWED")
|
||||
self.assertTrue(result["pr_close_allowed"])
|
||||
|
||||
def test_not_landed_pr_cannot_be_closed(self):
|
||||
result = self._gate(head_is_ancestor_of_target=False)
|
||||
self.assertEqual(result["outcome"], "NOT_LANDED_CLOSE_BLOCKED")
|
||||
self.assertFalse(result["pr_close_allowed"])
|
||||
|
||||
def test_unchecked_ancestry_blocks_close(self):
|
||||
result = self._gate(head_is_ancestor_of_target=None)
|
||||
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
||||
self.assertFalse(result["pr_close_allowed"])
|
||||
|
||||
def test_stale_target_branch_sha_blocks_close(self):
|
||||
result = self._gate(target_branch_sha=None)
|
||||
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
||||
self.assertFalse(result["pr_close_allowed"])
|
||||
|
||||
def test_closed_pr_state_blocks_close(self):
|
||||
result = self._gate(pr_state="closed")
|
||||
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
||||
self.assertFalse(result["pr_close_allowed"])
|
||||
|
||||
def test_changed_head_blocks_close(self):
|
||||
result = self._gate(live_head_sha=OTHER)
|
||||
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
||||
self.assertFalse(result["pr_close_allowed"])
|
||||
|
||||
def test_missing_close_capability_produces_recovery_handoff(self):
|
||||
result = self._gate(close_pr_capability=False)
|
||||
self.assertEqual(result["outcome"], "RECOVERY_HANDOFF_REQUIRED")
|
||||
self.assertFalse(result["pr_close_allowed"])
|
||||
self.assertTrue(any(
|
||||
"gitea.pr.close" in reason for reason in result["reasons"]
|
||||
))
|
||||
|
||||
def test_linked_issue_already_closed_skips_issue_close(self):
|
||||
result = self._gate(linked_issue_state="closed",
|
||||
close_issue_capability=True)
|
||||
self.assertFalse(result["issue_close_allowed"])
|
||||
|
||||
def test_linked_open_issue_close_requires_capability(self):
|
||||
allowed = self._gate(
|
||||
linked_issue_state="open",
|
||||
issue_resolved_by_landing=True,
|
||||
close_issue_capability=True,
|
||||
)
|
||||
self.assertTrue(allowed["issue_close_allowed"])
|
||||
|
||||
blocked = self._gate(
|
||||
linked_issue_state="open",
|
||||
issue_resolved_by_landing=True,
|
||||
close_issue_capability=False,
|
||||
)
|
||||
self.assertFalse(blocked["issue_close_allowed"])
|
||||
|
||||
def test_gate_never_allows_review_mutations(self):
|
||||
for result in (
|
||||
self._gate(),
|
||||
self._gate(head_is_ancestor_of_target=False),
|
||||
self._gate(close_pr_capability=False),
|
||||
):
|
||||
self.assertFalse(result["review_allowed"])
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertFalse(result["request_changes_allowed"])
|
||||
self.assertFalse(result["merge_allowed"])
|
||||
|
||||
def test_report_fields_listed_for_close(self):
|
||||
result = self._gate()
|
||||
for field in (
|
||||
"identity/profile",
|
||||
"close capability proof",
|
||||
"PR live state",
|
||||
"candidate head SHA",
|
||||
"target branch SHA",
|
||||
"ancestor proof",
|
||||
"linked issue status",
|
||||
"PR close result",
|
||||
"issue close result",
|
||||
"no review/merge confirmation",
|
||||
):
|
||||
self.assertIn(field, result["required_report_fields"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Reconciler close_pr must not require author branches/ worktree (#468)."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_mcp_server as srv
|
||||
|
||||
FAKE_AUTH = "token test"
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
|
||||
RECONCILER_PROFILE = {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment"],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"audit_label": "prgs-reconciler",
|
||||
}
|
||||
|
||||
|
||||
class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
def test_reconciler_close_pr_from_control_checkout_succeeds(
|
||||
self, mock_api, _profile, _ns, _auth
|
||||
):
|
||||
srv._preflight_resolved_role = "reconciler"
|
||||
mock_api.return_value = {
|
||||
"number": 414,
|
||||
"title": "old",
|
||||
"body": "",
|
||||
"state": "closed",
|
||||
"html_url": "https://gitea.example.com/pulls/414",
|
||||
}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
|
||||
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
||||
result = srv.gitea_edit_pr(414, state="closed", remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["state"], "closed")
|
||||
mock_api.assert_called_once()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch(
|
||||
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
)
|
||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||
@patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={"current_branch": "master"},
|
||||
)
|
||||
def test_author_create_issue_still_blocked_on_control_checkout(
|
||||
self, _git, _get_all, _role, _ns, _prof, _auth
|
||||
):
|
||||
srv._preflight_resolved_role = "author"
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue(title="Test", body="body")
|
||||
self.assertIn("stable control checkout", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for reconciler profile model (#304)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
import migrate_profiles
|
||||
import reconciler_profile
|
||||
|
||||
|
||||
PRGS_RECONCILER_ALLOWED = [
|
||||
"gitea.read",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.pr.close",
|
||||
]
|
||||
PRGS_RECONCILER_FORBIDDEN = [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
]
|
||||
|
||||
|
||||
class TestReconcilerProfileModel(unittest.TestCase):
|
||||
def test_prgs_reconciler_shape_valid(self):
|
||||
result = reconciler_profile.assess_reconciler_profile(
|
||||
PRGS_RECONCILER_ALLOWED,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertTrue(result["is_reconciler_profile"])
|
||||
self.assertTrue(result["valid"])
|
||||
self.assertEqual(result["reasons"], [])
|
||||
|
||||
def test_author_profile_not_reconciler(self):
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.issue.comment",
|
||||
]
|
||||
forbidden = ["gitea.pr.approve", "gitea.pr.merge"]
|
||||
self.assertFalse(
|
||||
reconciler_profile.is_reconciler_profile(allowed, forbidden)
|
||||
)
|
||||
|
||||
def test_reviewer_profile_not_reconciler(self):
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
]
|
||||
forbidden = ["gitea.pr.create", "gitea.branch.push"]
|
||||
self.assertFalse(
|
||||
reconciler_profile.is_reconciler_profile(allowed, forbidden)
|
||||
)
|
||||
|
||||
def test_broad_close_on_author_invalid(self):
|
||||
allowed = PRGS_RECONCILER_ALLOWED + ["gitea.pr.create"]
|
||||
result = reconciler_profile.assess_reconciler_profile(
|
||||
allowed,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertFalse(result["valid"])
|
||||
self.assertFalse(result["is_reconciler_profile"])
|
||||
|
||||
def test_role_kind_detects_reconciler(self):
|
||||
role = mcp_server._role_kind(
|
||||
PRGS_RECONCILER_ALLOWED,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertEqual(role, "reconciler")
|
||||
|
||||
def test_migrate_infer_role_reconciler(self):
|
||||
self.assertEqual(
|
||||
migrate_profiles.infer_role("prgs-reconciler", "prgs-reconciler"),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for the dedicated reconciliation controller-handoff schema (#303).
|
||||
|
||||
Already-landed PR reconciliation runs must emit the reconciliation
|
||||
schema, not stale author/reviewer handoff fields; observed git ref
|
||||
mutations (fetch) must be reported, and legacy fields fail validation.
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from review_proofs import assess_reconciliation_handoff # noqa: E402
|
||||
|
||||
|
||||
def _good_handoff(**overrides):
|
||||
fields = {
|
||||
"Task": "Reconcile already-landed open PRs",
|
||||
"Repo": "prgs/Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"Role/profile": "prgs-reconciler",
|
||||
"Identity": "jcwalker3",
|
||||
"Selected PR": "278",
|
||||
"PR live state": "open",
|
||||
"Candidate head SHA": "2a544b7",
|
||||
"Target branch": "master",
|
||||
"Target branch SHA": "fa0cb12",
|
||||
"Ancestor proof": "candidate head is ancestor of target branch SHA",
|
||||
"Linked issue": "263",
|
||||
"Linked issue live status": "open (fetched live this session)",
|
||||
"Eligibility class": "ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"Capabilities proven": "gitea.pr.comment",
|
||||
"Missing capabilities": "gitea.pr.close",
|
||||
"PR comments posted": "1 (PR #278 reconciliation notice)",
|
||||
"Issue comments posted": "none",
|
||||
"PRs closed": "none",
|
||||
"Issues closed": "none",
|
||||
"Git ref mutations": "git fetch prgs master",
|
||||
"Worktree mutations": "none",
|
||||
"MCP/Gitea mutations": "PR comment on #278",
|
||||
"External-state mutations": "none",
|
||||
"Read-only diagnostics": "gitea_view_pr 278, gitea_view_issue 263",
|
||||
"Blocker": "PR close capability missing",
|
||||
"Safe next action": "operator closes PR #278 or grants close capability",
|
||||
"No review/merge confirmation": "no review or merge mutations performed",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["Controller Handoff"]
|
||||
lines += [f"- {k}: {v}" for k, v in fields.items() if v is not None]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class TestReconciliationSchemaPasses(unittest.TestCase):
|
||||
def test_blocked_reconciliation_passes(self):
|
||||
result = assess_reconciliation_handoff(
|
||||
_good_handoff(),
|
||||
observed_commands=["git fetch prgs master"],
|
||||
)
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertEqual(result["reasons"], [])
|
||||
|
||||
def test_successful_pr_close_passes(self):
|
||||
report = _good_handoff(**{
|
||||
"Missing capabilities": "none",
|
||||
"PRs closed": "PR #278",
|
||||
"Blocker": "none",
|
||||
"Safe next action": "none; reconciliation complete",
|
||||
})
|
||||
result = assess_reconciliation_handoff(
|
||||
report, observed_commands=["git fetch prgs master"]
|
||||
)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
def test_comment_only_reconciliation_passes(self):
|
||||
report = _good_handoff(**{
|
||||
"Git ref mutations": "none",
|
||||
})
|
||||
result = assess_reconciliation_handoff(report, observed_commands=[])
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
def test_noop_already_closed_passes(self):
|
||||
report = _good_handoff(**{
|
||||
"PR live state": "closed",
|
||||
"PR comments posted": "none",
|
||||
"MCP/Gitea mutations": "none",
|
||||
"Git ref mutations": "none",
|
||||
"Blocker": "none",
|
||||
"Safe next action": "none; PR already closed",
|
||||
})
|
||||
result = assess_reconciliation_handoff(report, observed_commands=[])
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
|
||||
class TestSchemaViolationsFail(unittest.TestCase):
|
||||
def test_missing_linked_issue_proof_fails(self):
|
||||
report = _good_handoff(**{"Linked issue live status": None})
|
||||
result = assess_reconciliation_handoff(report, observed_commands=[])
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("linked issue live status" in r.lower()
|
||||
for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_wrong_eligibility_class_fails(self):
|
||||
report = _good_handoff(**{"Eligibility class": "REVIEW_ELIGIBLE"})
|
||||
result = assess_reconciliation_handoff(report, observed_commands=[])
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
def test_missing_handoff_section_fails(self):
|
||||
result = assess_reconciliation_handoff(
|
||||
"no handoff here", observed_commands=[]
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
|
||||
class TestStaleFieldsFail(unittest.TestCase):
|
||||
def test_pr_number_opened_fails(self):
|
||||
report = _good_handoff() + "- PR number opened: 278\n"
|
||||
result = assess_reconciliation_handoff(report, observed_commands=[])
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("pr number opened" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_pinned_reviewed_head_fails(self):
|
||||
report = _good_handoff() + "- Pinned reviewed head: 2a544b7\n"
|
||||
result = assess_reconciliation_handoff(report, observed_commands=[])
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
def test_scratch_worktree_used_fails(self):
|
||||
report = _good_handoff() + "- Scratch worktree used: False\n"
|
||||
result = assess_reconciliation_handoff(report, observed_commands=[])
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
def test_workspace_mutations_fails(self):
|
||||
report = _good_handoff() + "- Workspace mutations: None\n"
|
||||
result = assess_reconciliation_handoff(report, observed_commands=[])
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
def test_author_lock_fields_fail(self):
|
||||
report = (
|
||||
_good_handoff()
|
||||
+ "- Issue lock proof: locked before diff\n"
|
||||
+ "- Claim/comment status: claimed\n"
|
||||
)
|
||||
result = assess_reconciliation_handoff(report, observed_commands=[])
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
|
||||
class TestObservedFetchMustBeReported(unittest.TestCase):
|
||||
def test_fetch_with_ref_mutations_none_fails(self):
|
||||
report = _good_handoff(**{"Git ref mutations": "none"})
|
||||
result = assess_reconciliation_handoff(
|
||||
report, observed_commands=["git fetch prgs master"]
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("git ref mutation" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Tests for reconciliation PR-inventory pagination proof (#308).
|
||||
|
||||
Already-landed reconciliation reports may not claim a complete queue
|
||||
scan (or that all already-landed PRs were found) unless the report
|
||||
carries pagination proof fields and the per-page evidence proves the
|
||||
final page was reached or the page limit was not hit.
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from review_proofs import assess_reconciliation_inventory_proof # noqa: E402
|
||||
|
||||
|
||||
def _report(claim=True, fields=True, pages="1", counts="12",
|
||||
final_proof="is_final_page=true, has_more=false"):
|
||||
lines = []
|
||||
if claim:
|
||||
lines.append(
|
||||
"Inventory scope: complete queue scan; all already-landed "
|
||||
"PRs were found."
|
||||
)
|
||||
if fields:
|
||||
lines += [
|
||||
"- Requested page size: 50",
|
||||
f"- Pages fetched: {pages}",
|
||||
f"- Returned PR count per page: {counts}",
|
||||
f"- Final page proof: {final_proof}",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _page(page, returned, per_page=50, has_more=False, final=True,
|
||||
inventory_complete=None):
|
||||
d = {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"returned_count": returned,
|
||||
"has_more": has_more,
|
||||
"is_final_page": final,
|
||||
}
|
||||
if inventory_complete is not None:
|
||||
d["inventory_complete"] = inventory_complete
|
||||
return d
|
||||
|
||||
|
||||
class TestCompleteScanProven(unittest.TestCase):
|
||||
def test_first_page_below_limit_passes(self):
|
||||
result = assess_reconciliation_inventory_proof(
|
||||
_report(),
|
||||
pagination_evidence=[_page(1, 12)],
|
||||
)
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertEqual(result["reasons"], [])
|
||||
|
||||
def test_multiple_pages_to_final_passes(self):
|
||||
report = _report(
|
||||
pages="3", counts="50, 50, 12",
|
||||
final_proof="has_more=false on page 3",
|
||||
)
|
||||
evidence = [
|
||||
_page(1, 50, has_more=True, final=False),
|
||||
_page(2, 50, has_more=True, final=False),
|
||||
_page(3, 12),
|
||||
]
|
||||
result = assess_reconciliation_inventory_proof(
|
||||
report, pagination_evidence=evidence
|
||||
)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
def test_no_completeness_claim_needs_no_proof(self):
|
||||
result = assess_reconciliation_inventory_proof(
|
||||
_report(claim=False, fields=False),
|
||||
pagination_evidence=None,
|
||||
)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
|
||||
class TestUnprovenScanFails(unittest.TestCase):
|
||||
def test_first_page_exactly_at_limit_fails(self):
|
||||
# 50 returned with per_page 50 and no final-page signal: the page
|
||||
# limit was hit, so completeness is unproven.
|
||||
evidence = [_page(1, 50, has_more=True, final=False)]
|
||||
result = assess_reconciliation_inventory_proof(
|
||||
_report(counts="50", final_proof="none"),
|
||||
pagination_evidence=evidence,
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("final page" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_missing_pagination_evidence_fails(self):
|
||||
result = assess_reconciliation_inventory_proof(
|
||||
_report(), pagination_evidence=None
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("pagination" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_missing_report_fields_fails(self):
|
||||
result = assess_reconciliation_inventory_proof(
|
||||
_report(fields=False),
|
||||
pagination_evidence=[_page(1, 12)],
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("requested page size" in r.lower()
|
||||
for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_page_count_mismatch_fails(self):
|
||||
# Report claims 1 page; evidence shows 2 were fetched.
|
||||
evidence = [
|
||||
_page(1, 50, has_more=True, final=False),
|
||||
_page(2, 3),
|
||||
]
|
||||
result = assess_reconciliation_inventory_proof(
|
||||
_report(pages="1", counts="50"),
|
||||
pagination_evidence=evidence,
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("pages fetched" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_per_page_count_mismatch_fails(self):
|
||||
evidence = [_page(1, 12)]
|
||||
result = assess_reconciliation_inventory_proof(
|
||||
_report(counts="11"),
|
||||
pagination_evidence=evidence,
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("count per page" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_evidence_page_missing_metadata_fails(self):
|
||||
result = assess_reconciliation_inventory_proof(
|
||||
_report(),
|
||||
pagination_evidence=[{"page": 1}],
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for reconciliation MCP assessment tools (#301)."""
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server
|
||||
from mcp_server import (
|
||||
gitea_assess_already_landed_reconciliation,
|
||||
gitea_scan_already_landed_open_prs,
|
||||
)
|
||||
|
||||
AUTHOR_PROFILE = {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.close"],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
OPEN_PR = {
|
||||
"number": 278,
|
||||
"title": "Landed (Closes #263)",
|
||||
"body": "",
|
||||
"state": "open",
|
||||
"head": {"ref": "feat/x", "sha": "a" * 40},
|
||||
"base": {"ref": "master"},
|
||||
}
|
||||
|
||||
|
||||
class TestReconciliationMcpTools(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch(
|
||||
"mcp_server.get_auth_header", return_value="token test"
|
||||
).start()
|
||||
patch("mcp_server.get_profile", return_value=AUTHOR_PROFILE).start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def test_assess_returns_plan_without_mutations(self):
|
||||
self.mock_api.return_value = OPEN_PR
|
||||
with patch(
|
||||
"mcp_server.reconciliation_workflow.assess_open_pr_reconciliation",
|
||||
return_value={
|
||||
"reconciliation_allowed": True,
|
||||
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"candidate_head_sha": OPEN_PR["head"]["sha"],
|
||||
"target_branch_sha": "deadbeef",
|
||||
"linked_issue": 263,
|
||||
"review_merge_allowed": False,
|
||||
},
|
||||
):
|
||||
res = gitea_assess_already_landed_reconciliation(
|
||||
pr_number=278, remote="prgs"
|
||||
)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["plan"]["outcome"], "PARTIAL_RECONCILE_COMMENT_THEN_STOP")
|
||||
self.assertFalse(res["review_merge_allowed"])
|
||||
patch_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "PATCH"
|
||||
]
|
||||
self.assertEqual(patch_calls, [])
|
||||
|
||||
def test_scan_returns_candidates_with_pagination(self):
|
||||
with patch(
|
||||
"mcp_server.api_fetch_page",
|
||||
return_value=([OPEN_PR], {
|
||||
"page": 1,
|
||||
"per_page": 50,
|
||||
"is_final_page": True,
|
||||
"has_more": False,
|
||||
"next_page": None,
|
||||
}),
|
||||
), patch(
|
||||
"mcp_server.reconciliation_workflow.fetch_target_branch",
|
||||
return_value={
|
||||
"success": True,
|
||||
"target_branch_sha": "deadbeef",
|
||||
"git_fetch_command": "git fetch prgs master",
|
||||
},
|
||||
), patch(
|
||||
"mcp_server.reconciliation_workflow.assess_open_pr_reconciliation",
|
||||
return_value={
|
||||
"pr_number": 278,
|
||||
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"reconciliation_allowed": True,
|
||||
},
|
||||
):
|
||||
res = gitea_scan_already_landed_open_prs(remote="prgs", limit=50)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["candidate_count"], 1)
|
||||
self.assertTrue(res["pagination"]["inventory_complete"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for already-landed reconciliation workflow helpers (#301)."""
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import reconciliation_workflow
|
||||
from review_proofs import assess_reconcile_workflow_source
|
||||
|
||||
|
||||
class TestReconciliationAssessment(unittest.TestCase):
|
||||
def test_already_landed_open_pr(self):
|
||||
with patch(
|
||||
"reconciliation_workflow.is_head_ancestor_of_ref",
|
||||
return_value=True,
|
||||
):
|
||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
||||
pr={
|
||||
"number": 278,
|
||||
"state": "open",
|
||||
"title": "Fix (Closes #263)",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/x", "sha": "abc" * 13 + "a"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": True,
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
"git_fetch_command": "git fetch prgs master",
|
||||
},
|
||||
)
|
||||
self.assertTrue(assessment["reconciliation_allowed"])
|
||||
self.assertEqual(
|
||||
assessment["eligibility_class"],
|
||||
reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
||||
)
|
||||
|
||||
def test_not_landed_pr(self):
|
||||
with patch(
|
||||
"reconciliation_workflow.is_head_ancestor_of_ref",
|
||||
return_value=False,
|
||||
):
|
||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
||||
pr={
|
||||
"number": 1,
|
||||
"state": "open",
|
||||
"title": "WIP",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/y", "sha": "fff" * 13 + "f"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": True,
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
},
|
||||
)
|
||||
self.assertFalse(assessment["reconciliation_allowed"])
|
||||
|
||||
|
||||
class TestReconciliationPlan(unittest.TestCase):
|
||||
def test_full_close_when_close_pr_allowed(self):
|
||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
||||
assessment={
|
||||
"reconciliation_allowed": True,
|
||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
||||
},
|
||||
capabilities={
|
||||
"close_pr": True,
|
||||
"comment_pr": True,
|
||||
"close_issue": True,
|
||||
"comment_issue": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
plan["outcome"],
|
||||
reconciliation_workflow.OUTCOME_FULL_RECONCILE,
|
||||
)
|
||||
self.assertTrue(plan["close_pr_allowed"])
|
||||
self.assertFalse(plan["review_merge_allowed"])
|
||||
|
||||
def test_comment_then_stop_without_close(self):
|
||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
||||
assessment={
|
||||
"reconciliation_allowed": True,
|
||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
||||
},
|
||||
capabilities={
|
||||
"close_pr": False,
|
||||
"comment_pr": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
plan["outcome"],
|
||||
reconciliation_workflow.OUTCOME_PARTIAL_COMMENT,
|
||||
)
|
||||
|
||||
def test_recovery_handoff_without_comment_or_close(self):
|
||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
||||
assessment={
|
||||
"reconciliation_allowed": True,
|
||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
||||
},
|
||||
capabilities={"close_pr": False, "comment_pr": False},
|
||||
)
|
||||
self.assertEqual(
|
||||
plan["outcome"],
|
||||
reconciliation_workflow.OUTCOME_RECOVERY_HANDOFF,
|
||||
)
|
||||
|
||||
def test_not_landed_no_action(self):
|
||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
||||
assessment={
|
||||
"reconciliation_allowed": False,
|
||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_NOT_LANDED,
|
||||
"reasons": ["not ancestor"],
|
||||
},
|
||||
capabilities={"close_pr": True},
|
||||
)
|
||||
self.assertEqual(
|
||||
plan["outcome"],
|
||||
reconciliation_workflow.OUTCOME_NOT_LANDED,
|
||||
)
|
||||
|
||||
|
||||
class TestReconcileWorkflowSource(unittest.TestCase):
|
||||
def test_valid_report_passes(self):
|
||||
report = (
|
||||
"Task mode: reconcile-landed-pr\n"
|
||||
"Workflow source: workflows/reconcile-landed-pr.md\n"
|
||||
)
|
||||
result = assess_reconcile_workflow_source(report)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
def test_missing_workflow_fails(self):
|
||||
result = assess_reconcile_workflow_source("Task mode: reconcile-landed-pr")
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -121,6 +121,32 @@ class TestResolveTaskCapability(unittest.TestCase):
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertFalse(res["different_mcp_namespace_required"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_work_issue_author_profile_allowed(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
for task in ("work_issue", "work-issue"):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task=task, remote="prgs"
|
||||
)
|
||||
self.assertEqual(res["requested_task"], task)
|
||||
self.assertEqual(
|
||||
res["required_operation_permission"], "gitea.pr.create"
|
||||
)
|
||||
self.assertEqual(res["required_role_kind"], "author")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="work_issue", remote="prgs"
|
||||
)
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertEqual(res["required_role_kind"], "author")
|
||||
self.assertTrue(res["different_mcp_namespace_required"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_issue_comment_task(self, _auth, _api):
|
||||
@@ -275,6 +301,40 @@ class TestResolveTaskCapability(unittest.TestCase):
|
||||
self.assertEqual(res["required_role_kind"], "author")
|
||||
self.assertIn("author", res["exact_safe_next_action"].lower())
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_reconcile_already_landed_pr_requires_close(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="reconcile_already_landed_pr", remote="prgs"
|
||||
)
|
||||
self.assertEqual(res["requested_task"], "reconcile_already_landed_pr")
|
||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
|
||||
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_pr_queue_cleanup_author_profile_blocked(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="pr_queue_cleanup", remote="prgs"
|
||||
)
|
||||
self.assertEqual(res["required_role_kind"], "reviewer")
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertTrue(res["stop_required"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||
def test_resolve_pr_queue_cleanup_reviewer_profile_allowed(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="pr_queue_cleanup", remote="prgs"
|
||||
)
|
||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.review")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertFalse(res["stop_required"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_close_pr_known_and_lookalike_tasks_still_fail_closed(self, _auth, _api):
|
||||
|
||||
@@ -115,6 +115,22 @@ class TestPRReviewFeedbackDiscovery(unittest.TestCase):
|
||||
result["latest_review_state_by_reviewer"], {"reviewer1": "APPROVED"})
|
||||
self.assertFalse(result["has_blocking_change_requests"])
|
||||
self.assertTrue(result["approval_visible"])
|
||||
self.assertTrue(result["approval_at_current_head"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("mcp_server.get_profile")
|
||||
def test_stale_approval_not_at_current_head(self, mock_get_profile, _auth, mock_api):
|
||||
mock_get_profile.return_value = self._profile()
|
||||
mock_api.side_effect = [
|
||||
_pr_details(head_sha="newhead3"),
|
||||
[_review("reviewer1", "APPROVED", commit_id="oldhead1")],
|
||||
]
|
||||
result = gitea_get_pr_review_feedback(pr_number=5, remote="prgs")
|
||||
self.assertTrue(result["approval_visible"])
|
||||
self.assertFalse(result["approval_at_current_head"])
|
||||
self.assertEqual(result["latest_approved_head_sha"], "oldhead1")
|
||||
self.assertIn("stale approval", result["stale_approval_block_reason"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for reviewer final-report schema verification (#391)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from review_final_report_schema import assess_review_final_report_schema # noqa: E402
|
||||
|
||||
|
||||
def _minimal_review_report(**overrides):
|
||||
lines = [
|
||||
"## Controller Handoff",
|
||||
"",
|
||||
"- Task: review PR #203",
|
||||
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"- Role: reviewer",
|
||||
"- Identity: sysadmin / prgs-reviewer",
|
||||
"- Issue/PR: #182 / PR #203",
|
||||
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Files changed: review_proofs.py",
|
||||
"- Validation: pass: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Push occurred during validation: no",
|
||||
"- Mutations: review only",
|
||||
"- File edits by reviewer: none",
|
||||
"- Worktree/index mutations: none",
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- MCP/Gitea mutations: review submitted",
|
||||
"- Review mutations: request_changes submitted",
|
||||
"- Merge mutations: none",
|
||||
"- Cleanup mutations: none",
|
||||
"- External-state mutations: none",
|
||||
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
|
||||
"- Current status: PR open",
|
||||
"- Blockers: none",
|
||||
"- Next: await author",
|
||||
"- Safety: no self-review; no self-merge",
|
||||
"- Selected PR: #203",
|
||||
"- Reviewer eligibility: eligible",
|
||||
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Worktree path: branches/review-203",
|
||||
"- Worktree dirty: clean",
|
||||
"- Unrelated local mutations: none",
|
||||
"- Review decision: request_changes",
|
||||
"- Merge result: not attempted",
|
||||
"- Linked issue: #182",
|
||||
"- Linked issue live status: open (gitea_view_issue)",
|
||||
"- Cleanup status: none",
|
||||
]
|
||||
text = "\n".join(lines)
|
||||
for key, value in overrides.items():
|
||||
prefix = f"- {key}:"
|
||||
replaced = False
|
||||
new_lines = []
|
||||
for line in text.splitlines():
|
||||
if line.strip().startswith(prefix):
|
||||
new_lines.append(f"{prefix} {value}")
|
||||
replaced = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
text = "\n".join(new_lines)
|
||||
if not replaced:
|
||||
text += f"\n{prefix} {value}"
|
||||
return text
|
||||
|
||||
|
||||
class TestReviewFinalReportSchema(unittest.TestCase):
|
||||
def test_clean_report_passes(self):
|
||||
result = assess_review_final_report_schema(
|
||||
_minimal_review_report(),
|
||||
linked_issue_lock={"issue_number": 182, "pr_number": 203},
|
||||
)
|
||||
self.assertFalse(result["blocked"])
|
||||
self.assertIn(result["grade"], ("A", "downgraded"))
|
||||
|
||||
def test_legacy_pinned_reviewed_head_blocks(self):
|
||||
report = _minimal_review_report(**{"Pinned reviewed head": "abc123"})
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.legacy_stale_field", rule_ids)
|
||||
|
||||
def test_reviewed_head_without_validation_blocks(self):
|
||||
report = _minimal_review_report().replace(
|
||||
"- Validation: pass: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||
"- Validation: not run",
|
||||
)
|
||||
report += "\n- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.reviewed_head_without_validation", rule_ids)
|
||||
|
||||
def test_merged_without_proof_blocks(self):
|
||||
report = _minimal_review_report() + "\n\nPR #203 merged successfully."
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.merged_without_proof", rule_ids)
|
||||
|
||||
def test_issue_closed_without_live_proof_blocks(self):
|
||||
report = _minimal_review_report(
|
||||
**{
|
||||
"Linked issue live status": "closed",
|
||||
"Read-only diagnostics": "gitea_view_pr only",
|
||||
}
|
||||
)
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.issue_closed_without_live_proof", rule_ids)
|
||||
|
||||
def test_full_suite_pass_with_ignored_tests_blocks(self):
|
||||
report = (
|
||||
_minimal_review_report()
|
||||
+ "\nFull test suite passed with 0 failed; some tests ignored."
|
||||
)
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.full_suite_pass_ignored_tests", rule_ids)
|
||||
|
||||
def test_blocked_handoff_replay_blocks(self):
|
||||
report = (
|
||||
_minimal_review_report(**{"Blockers": "capability stop"})
|
||||
+ "\nSafe next action: run gitea_merge_pr to finish."
|
||||
)
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.blocked_handoff_replay", rule_ids)
|
||||
|
||||
def test_narrative_handoff_drift_blocks(self):
|
||||
report = (
|
||||
_minimal_review_report()
|
||||
+ "\n## Summary\nVerdict: APPROVE this PR."
|
||||
)
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.narrative_handoff_drift", rule_ids)
|
||||
|
||||
def test_pending_vs_approved_blocks(self):
|
||||
report = _minimal_review_report(
|
||||
**{"Review decision": "PENDING official review submitted approve"}
|
||||
)
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.pending_vs_approved", rule_ids)
|
||||
|
||||
def test_exported_from_review_proofs(self):
|
||||
from review_proofs import assess_review_final_report_schema as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
class TestMcpValidateReviewFinalReport(unittest.TestCase):
|
||||
def test_mcp_tool_callable(self):
|
||||
import gitea_mcp_server
|
||||
|
||||
result = gitea_mcp_server.gitea_validate_review_final_report(
|
||||
_minimal_review_report()
|
||||
)
|
||||
self.assertIn("grade", result)
|
||||
self.assertIn("findings", result)
|
||||
self.assertFalse(result["blocked"])
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for enforced PR review/merge state machine (#290)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import review_merge_state_machine as rmsm # noqa: E402
|
||||
|
||||
FULL_REVIEW_COMPLETION = {state: True for state in rmsm.REVIEW_MERGE_STATES}
|
||||
|
||||
|
||||
def _completion_through(state_name: str) -> dict[str, bool]:
|
||||
idx = rmsm.state_index(state_name)
|
||||
return {state: i <= idx for i, state in enumerate(rmsm.REVIEW_MERGE_STATES)}
|
||||
|
||||
|
||||
class TestWorkflowBlockers(unittest.TestCase):
|
||||
def test_infra_stop_blocks_all_states(self):
|
||||
result = rmsm.assess_workflow_blockers(infra_stop=True)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["forbidden_states"], list(rmsm.REVIEW_MERGE_STATES))
|
||||
|
||||
def test_infra_stop_forbids_advancement(self):
|
||||
result = rmsm.assess_state_advancement(
|
||||
{},
|
||||
target_state="INVENTORY",
|
||||
infra_stop=True,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertFalse(result["allowed"])
|
||||
|
||||
|
||||
class TestStateAdvancement(unittest.TestCase):
|
||||
def test_cannot_skip_inventory(self):
|
||||
result = rmsm.assess_state_advancement(
|
||||
{"PRECHECK": True},
|
||||
target_state="SELECT_PR",
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["next_allowed_state"], "INVENTORY")
|
||||
|
||||
def test_sequential_advance_allowed(self):
|
||||
completion = _completion_through("INVENTORY")
|
||||
result = rmsm.assess_state_advancement(
|
||||
completion,
|
||||
target_state="SELECT_PR",
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
|
||||
class TestApproveAndMergeGates(unittest.TestCase):
|
||||
def test_approve_blocked_without_validate(self):
|
||||
completion = _completion_through("PIN_HEAD_SHA")
|
||||
result = rmsm.can_approve(completion)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("VALIDATE", ",".join(result["missing_states"]))
|
||||
|
||||
def test_approve_allowed_when_review_path_complete(self):
|
||||
completion = _completion_through("REVIEW_DECISION")
|
||||
result = rmsm.can_approve(completion)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
def test_merge_blocked_without_pre_merge_gates(self):
|
||||
completion = _completion_through("APPROVE_OR_REQUEST_CHANGES")
|
||||
result = rmsm.can_merge(completion)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(result["missing_pre_merge_gates"])
|
||||
|
||||
def test_merge_allowed_with_pre_merge_gates(self):
|
||||
completion = _completion_through("PRE_MERGE_RECHECK")
|
||||
gates = {gate: True for gate in rmsm._PRE_MERGE_REQUIRED_GATES}
|
||||
result = rmsm.can_merge(completion, pre_merge_gates=gates)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
def test_merge_blocked_when_head_sha_gate_fails(self):
|
||||
completion = _completion_through("PRE_MERGE_RECHECK")
|
||||
gates = {gate: True for gate in rmsm._PRE_MERGE_REQUIRED_GATES}
|
||||
gates["reviewed_head_sha_unchanged"] = False
|
||||
result = rmsm.can_merge(completion, pre_merge_gates=gates)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("reviewed_head_sha_unchanged", result["missing_pre_merge_gates"])
|
||||
|
||||
|
||||
class TestRecoveryHandoff(unittest.TestCase):
|
||||
def test_blocked_handoff_without_replay_passes(self):
|
||||
report = (
|
||||
"Blocked recovery handoff.\n"
|
||||
"Restart the full workflow after infra_stop clears.\n"
|
||||
"No approve/merge performed."
|
||||
)
|
||||
result = rmsm.assess_blocked_recovery_handoff(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_blocked_handoff_with_merge_replay_fails(self):
|
||||
report = "Please merge PR #12 now using gitea_merge_pr."
|
||||
result = rmsm.assess_blocked_recovery_handoff(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestFinalReportClaims(unittest.TestCase):
|
||||
def test_ready_to_merge_claim_without_gates_fails(self):
|
||||
result = rmsm.assess_final_report_state_claims(
|
||||
"PR is ready to merge after validation.",
|
||||
state_completion=_completion_through("VALIDATE"),
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_reviewed_claim_without_validate_fails(self):
|
||||
result = rmsm.assess_final_report_state_claims(
|
||||
"PR reviewed and looks good.",
|
||||
state_completion={"PRECHECK": True},
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+585
-3
@@ -23,7 +23,13 @@ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.par
|
||||
from review_proofs import ( # noqa: E402
|
||||
ISSUE_SELECTION_CONTINUATION_EXPLICIT,
|
||||
ISSUE_SELECTION_REPRESENTED_BY_OPEN_PR,
|
||||
assess_already_landed_report_state,
|
||||
assess_already_landed_review_gate,
|
||||
assess_author_pr_report,
|
||||
assess_partial_reconciliation_report,
|
||||
resolve_partial_reconciliation_plan,
|
||||
assess_validation_environment_proof,
|
||||
assess_full_suite_failure_approval_gate,
|
||||
assess_queue_ordering_proof,
|
||||
assess_reviewer_baseline_validation_proof,
|
||||
assess_capability_evidence,
|
||||
@@ -34,6 +40,9 @@ from review_proofs import ( # noqa: E402
|
||||
assess_create_issue_final_report,
|
||||
assess_create_issue_mode_isolation,
|
||||
assess_create_issue_workflow_source,
|
||||
assess_work_issue_final_report,
|
||||
assess_work_issue_mode_isolation,
|
||||
assess_work_issue_workflow_source,
|
||||
assess_edited_pr_inventory_coverage,
|
||||
assess_empty_queue_report,
|
||||
assess_fresh_issue_selection,
|
||||
@@ -2314,10 +2323,155 @@ class TestCreateIssueFinalReport(unittest.TestCase):
|
||||
self.assertTrue(result["downgraded"])
|
||||
|
||||
|
||||
class TestQueueOrderingProof(unittest.TestCase):
|
||||
"""Issue #321: reviewer queue selection must prove ordering."""
|
||||
class TestWorkIssueFinalReport(unittest.TestCase):
|
||||
"""#139: work-issue final-report verifier coverage."""
|
||||
|
||||
def _good_report(self):
|
||||
return "\n".join([
|
||||
"Task mode: work-issue",
|
||||
"Workflow source: skills/llm-project-workflow/workflows/work-issue.md",
|
||||
"## Controller Handoff",
|
||||
"- Task: work-issue",
|
||||
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"- Role: author",
|
||||
"- Identity: jcwalker3 / prgs-author",
|
||||
"- Active profile: prgs-author",
|
||||
"- Runtime context: prgs-author on prgs",
|
||||
"- Selected issue: #139",
|
||||
"- Branch name: feat/issue-139-role-aware-work-issue-routing",
|
||||
"- PR number: not created in this session",
|
||||
"- File edits by author: role_session_router.py",
|
||||
"- Blockers: none",
|
||||
"- Current status: implementing routing",
|
||||
"- Safe next action: open PR",
|
||||
"- Next: open PR",
|
||||
"- Safety statement: no review/merge",
|
||||
"- Duplicate work outcome: duplicate work not prevented",
|
||||
])
|
||||
|
||||
def test_complete_work_issue_report_earns_a(self):
|
||||
result = assess_work_issue_final_report(self._good_report())
|
||||
self.assertEqual(result["grade"], "A")
|
||||
self.assertFalse(result["downgraded"])
|
||||
|
||||
def test_missing_workflow_source_downgrades(self):
|
||||
report = self._good_report().replace(
|
||||
"workflows/work-issue.md", "SKILL.md only"
|
||||
)
|
||||
result = assess_work_issue_workflow_source(report)
|
||||
self.assertTrue(result["downgraded"])
|
||||
|
||||
def test_review_field_in_handoff_downgrades(self):
|
||||
report = self._good_report().replace(
|
||||
"- Safety statement:",
|
||||
"- Review decision: APPROVE\n- Safety statement:",
|
||||
)
|
||||
result = assess_work_issue_mode_isolation(report)
|
||||
self.assertTrue(result["downgraded"])
|
||||
|
||||
|
||||
class TestValidationEnvironmentProof(unittest.TestCase):
|
||||
"""Issue #311: exact validation command and execution environment."""
|
||||
|
||||
ROOT = "/repo/Gitea-Tools/branches/review-pr-280"
|
||||
|
||||
def test_venv_pytest_with_full_report_passes(self):
|
||||
result = assess_validation_environment_proof(
|
||||
report_text=(
|
||||
"Validation command: venv/bin/pytest tests/ --ignore=branches\n"
|
||||
f"Working directory: {self.ROOT}\n"
|
||||
"Result: 1014 passed, 6 skipped"
|
||||
),
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_bare_pytest_without_path_proof_fails(self):
|
||||
result = assess_validation_environment_proof(
|
||||
report_text=(
|
||||
"Validation command: pytest\n"
|
||||
f"Working directory: {self.ROOT}\n"
|
||||
"1014 passed, 6 skipped"
|
||||
),
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_bare_pytest_with_path_and_version_proof_passes(self):
|
||||
result = assess_validation_environment_proof(
|
||||
report_text=(
|
||||
"Validation command: pytest tests/\n"
|
||||
f"Working directory: {self.ROOT}\n"
|
||||
"which pytest: /repo/Gitea-Tools/venv/bin/pytest\n"
|
||||
"pytest --version: pytest 8.3.4\n"
|
||||
"Resolves to the project venv: yes\n"
|
||||
"1014 passed, 6 skipped"
|
||||
),
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_vague_pytest_summary_without_command_fails(self):
|
||||
result = assess_validation_environment_proof(
|
||||
report_text=(
|
||||
f"Working directory: {self.ROOT}\n"
|
||||
"pytest executed inside the worktree: 1014 passed, 6 skipped"
|
||||
),
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_structured_environment_proof_passes(self):
|
||||
result = assess_validation_environment_proof(
|
||||
validation_environment={
|
||||
"command": "pytest tests/",
|
||||
"working_directory": self.ROOT,
|
||||
"which_pytest": "/repo/Gitea-Tools/venv/bin/pytest",
|
||||
"pytest_version": "pytest 8.3.4",
|
||||
"venv_resolved": True,
|
||||
"passed": 1014,
|
||||
"skipped": 6,
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_missing_working_directory_fails(self):
|
||||
result = assess_validation_environment_proof(
|
||||
report_text=(
|
||||
"Validation command: venv/bin/pytest tests/\n"
|
||||
"1014 passed, 6 skipped"
|
||||
),
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_build_final_report_downgrades_missing_environment_proof(self):
|
||||
report = build_final_report(
|
||||
checkout_proof=_good_checkout(),
|
||||
inventory=_good_inventory(),
|
||||
validation=_good_validation(),
|
||||
contamination=_good_contamination(),
|
||||
identity_eligible=True,
|
||||
merge_performed=False,
|
||||
issue_status_verified=True,
|
||||
capability_evidence=_good_capability_evidence(),
|
||||
sweep=_good_sweep(),
|
||||
live_state=_good_live_state(),
|
||||
role_boundary=_good_role_boundary(),
|
||||
review_mutation=_good_review_mutation(),
|
||||
controller_handoff=_good_handoff(),
|
||||
capability_proof=_good_capability_proof(),
|
||||
sweep_proof=_good_secret_sweep(),
|
||||
worktree_proof={
|
||||
"worktree_path": "/repo/branches/review-feat-issue-224",
|
||||
"porcelain_status": "",
|
||||
"pr_scope_files": ["docs/wiki/Repositories.md"],
|
||||
"scratch_used": True,
|
||||
},
|
||||
report_text=(
|
||||
f"Working directory: {self.ROOT}\n"
|
||||
"pytest executed: 1014 passed, 6 skipped"
|
||||
),
|
||||
)
|
||||
self.assertNotEqual(report["grade"], "A")
|
||||
self.assertFalse(report["validation_environment_proven"])
|
||||
|
||||
|
||||
def test_next_eligible_claim_without_policy_fails(self):
|
||||
result = assess_queue_ordering_proof(
|
||||
report_text="Selected the next eligible PR: PR #286.",
|
||||
)
|
||||
@@ -2539,5 +2693,433 @@ class TestReviewerBaselineValidationProof(unittest.TestCase):
|
||||
self.assertFalse(report["baseline_validation_proven"])
|
||||
|
||||
|
||||
class TestPartialReconciliationPlan(unittest.TestCase):
|
||||
"""Issue #302: policy when PR-close capability is missing (Option B)."""
|
||||
|
||||
def _plan(self, **overrides):
|
||||
kwargs = {
|
||||
"ancestor_proven": True,
|
||||
"capabilities": {
|
||||
"close_pr": False,
|
||||
"comment_pr": True,
|
||||
"close_issue": True,
|
||||
"comment_issue": True,
|
||||
},
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return resolve_partial_reconciliation_plan(**kwargs)
|
||||
|
||||
def test_close_capability_present_allows_full_reconcile(self):
|
||||
plan = self._plan(capabilities={
|
||||
"close_pr": True, "comment_pr": True,
|
||||
"close_issue": True, "comment_issue": True,
|
||||
})
|
||||
self.assertEqual(plan["outcome"], "FULL_RECONCILE_CLOSE_ALLOWED")
|
||||
self.assertIn("close_pr", plan["allowed_mutations"])
|
||||
|
||||
def test_close_missing_with_comment_posts_comment_then_stops(self):
|
||||
plan = self._plan()
|
||||
self.assertEqual(
|
||||
plan["outcome"], "PARTIAL_RECONCILE_COMMENT_THEN_STOP")
|
||||
self.assertEqual(plan["allowed_mutations"], ("comment_pr",))
|
||||
for field in (
|
||||
"PR head SHA",
|
||||
"target branch SHA",
|
||||
"ancestor proof",
|
||||
"linked issue status",
|
||||
"required missing capability",
|
||||
):
|
||||
self.assertIn(field, plan["required_comment_fields"])
|
||||
|
||||
def test_comment_capability_missing_produces_handoff_only(self):
|
||||
plan = self._plan(capabilities={
|
||||
"close_pr": False, "comment_pr": False,
|
||||
"close_issue": True, "comment_issue": True,
|
||||
})
|
||||
self.assertEqual(plan["outcome"], "RECOVERY_HANDOFF_ONLY")
|
||||
self.assertEqual(plan["allowed_mutations"], ())
|
||||
|
||||
def test_all_capabilities_missing_produces_handoff_only(self):
|
||||
plan = self._plan(capabilities={
|
||||
"close_pr": False, "comment_pr": False,
|
||||
"close_issue": False, "comment_issue": False,
|
||||
})
|
||||
self.assertEqual(plan["outcome"], "RECOVERY_HANDOFF_ONLY")
|
||||
self.assertEqual(plan["allowed_mutations"], ())
|
||||
|
||||
def test_unproven_ancestry_blocks_all_mutations(self):
|
||||
plan = self._plan(ancestor_proven=False, capabilities={
|
||||
"close_pr": True, "comment_pr": True,
|
||||
"close_issue": True, "comment_issue": True,
|
||||
})
|
||||
self.assertEqual(plan["outcome"], "GATE_NOT_PROVEN")
|
||||
self.assertEqual(plan["allowed_mutations"], ())
|
||||
|
||||
def test_missing_capability_dict_fails_closed(self):
|
||||
plan = self._plan(capabilities=None)
|
||||
self.assertEqual(plan["outcome"], "RECOVERY_HANDOFF_ONLY")
|
||||
self.assertEqual(plan["allowed_mutations"], ())
|
||||
|
||||
|
||||
class TestPartialReconciliationReport(unittest.TestCase):
|
||||
"""Issue #302: reports must explain why comments were or were not posted."""
|
||||
|
||||
def _plan(self, outcome):
|
||||
capabilities = {
|
||||
"FULL_RECONCILE_CLOSE_ALLOWED": {
|
||||
"close_pr": True, "comment_pr": True,
|
||||
"close_issue": True, "comment_issue": True,
|
||||
},
|
||||
"PARTIAL_RECONCILE_COMMENT_THEN_STOP": {
|
||||
"close_pr": False, "comment_pr": True,
|
||||
"close_issue": True, "comment_issue": True,
|
||||
},
|
||||
"RECOVERY_HANDOFF_ONLY": {
|
||||
"close_pr": False, "comment_pr": False,
|
||||
"close_issue": False, "comment_issue": False,
|
||||
},
|
||||
}[outcome]
|
||||
return resolve_partial_reconciliation_plan(
|
||||
ancestor_proven=True, capabilities=capabilities)
|
||||
|
||||
def test_partial_report_requires_comment_and_missing_capability(self):
|
||||
plan = self._plan("PARTIAL_RECONCILE_COMMENT_THEN_STOP")
|
||||
good = (
|
||||
"Reconciliation comment posted on PR #278 with ancestor proof. "
|
||||
"PR close skipped: close capability gitea.pr.close missing; "
|
||||
"stopped for authorized close."
|
||||
)
|
||||
result = assess_partial_reconciliation_report(good, plan=plan)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
bad = "Stopped. Nothing done."
|
||||
result = assess_partial_reconciliation_report(bad, plan=plan)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_handoff_only_report_requires_no_comment_explanation(self):
|
||||
plan = self._plan("RECOVERY_HANDOFF_ONLY")
|
||||
good = (
|
||||
"No reconciliation comment posted: comment capability missing. "
|
||||
"No Gitea mutation performed; recovery handoff produced."
|
||||
)
|
||||
result = assess_partial_reconciliation_report(good, plan=plan)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
bad = "Recovery handoff produced."
|
||||
result = assess_partial_reconciliation_report(bad, plan=plan)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_full_reconcile_report_requires_close_result(self):
|
||||
plan = self._plan("FULL_RECONCILE_CLOSE_ALLOWED")
|
||||
good = "PR close result: closed PR #278 after ancestor proof."
|
||||
result = assess_partial_reconciliation_report(good, plan=plan)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
bad = "Reconciliation done."
|
||||
result = assess_partial_reconciliation_report(bad, plan=plan)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestAlreadyLandedReviewGate(unittest.TestCase):
|
||||
"""Issue #292: PR head already on target blocks approval and merge."""
|
||||
|
||||
def _gate(self, **overrides):
|
||||
kwargs = {
|
||||
"pr_number": 278,
|
||||
"candidate_head_sha": PINNED,
|
||||
"target_branch": "master",
|
||||
"target_branch_sha": OTHER,
|
||||
"head_is_ancestor_of_target": True,
|
||||
"live_head_sha": PINNED,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return assess_already_landed_review_gate(**kwargs)
|
||||
|
||||
def test_already_landed_blocks_approval_and_merge(self):
|
||||
result = self._gate()
|
||||
self.assertEqual(result["state"], "ALREADY_LANDED_RECONCILE_REQUIRED")
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertFalse(result["merge_api_allowed"])
|
||||
self.assertFalse(result["request_changes_allowed"])
|
||||
self.assertTrue(result["reconciliation_handoff_required"])
|
||||
|
||||
def test_already_landed_with_real_blocker_allows_request_changes(self):
|
||||
result = self._gate(real_blocker=True)
|
||||
self.assertEqual(result["state"], "ALREADY_LANDED_RECONCILE_REQUIRED")
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertFalse(result["merge_api_allowed"])
|
||||
self.assertTrue(result["request_changes_allowed"])
|
||||
|
||||
def test_normal_candidate_passes_gate(self):
|
||||
result = self._gate(head_is_ancestor_of_target=False)
|
||||
self.assertEqual(result["state"], "NORMAL_REVIEW_CANDIDATE")
|
||||
self.assertTrue(result["approve_allowed"])
|
||||
self.assertTrue(result["merge_api_allowed"])
|
||||
self.assertFalse(result["reconciliation_handoff_required"])
|
||||
|
||||
def test_non_mergeable_normal_pr_still_passes_this_gate(self):
|
||||
# Mergeability/conflicts are separate gates; ancestry gate only
|
||||
# decides already-landed vs normal candidate.
|
||||
result = self._gate(
|
||||
head_is_ancestor_of_target=False, mergeable=False)
|
||||
self.assertEqual(result["state"], "NORMAL_REVIEW_CANDIDATE")
|
||||
self.assertTrue(result["request_changes_allowed"])
|
||||
|
||||
def test_unchecked_ancestry_blocks_review_mutations(self):
|
||||
result = self._gate(head_is_ancestor_of_target=None)
|
||||
self.assertEqual(result["state"], "GATE_NOT_PROVEN")
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertFalse(result["merge_api_allowed"])
|
||||
|
||||
def test_changed_head_since_pin_blocks_until_recheck(self):
|
||||
result = self._gate(
|
||||
head_is_ancestor_of_target=False, live_head_sha=OTHER)
|
||||
self.assertEqual(result["state"], "GATE_NOT_PROVEN")
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertTrue(any(
|
||||
"head" in reason.lower() for reason in result["reasons"]
|
||||
))
|
||||
|
||||
def test_invalid_shas_block_gate(self):
|
||||
result = self._gate(candidate_head_sha="deadbeef")
|
||||
self.assertEqual(result["state"], "GATE_NOT_PROVEN")
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
|
||||
result = self._gate(target_branch_sha=None)
|
||||
self.assertEqual(result["state"], "GATE_NOT_PROVEN")
|
||||
|
||||
def test_already_landed_handoff_lists_required_fields(self):
|
||||
result = self._gate()
|
||||
for field in (
|
||||
"PR number/title",
|
||||
"candidate head SHA",
|
||||
"target branch",
|
||||
"target branch SHA",
|
||||
"ancestor proof",
|
||||
"linked issue status",
|
||||
"recommended reconciliation action",
|
||||
"capability proof for close/comment mutations",
|
||||
):
|
||||
self.assertIn(field, result["required_handoff_fields"])
|
||||
|
||||
|
||||
class TestAlreadyLandedReportState(unittest.TestCase):
|
||||
"""Issue #292: reports cannot say APPROVED after the gate fired."""
|
||||
|
||||
GOOD_REPORT = (
|
||||
"Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED\n"
|
||||
"Candidate head SHA: " + PINNED + "\n"
|
||||
"Review decision: none\n"
|
||||
"Merge result: none\n"
|
||||
)
|
||||
|
||||
def test_gate_fired_with_reconcile_state_passes(self):
|
||||
result = assess_already_landed_report_state(
|
||||
self.GOOD_REPORT, gate_fired=True)
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_gate_fired_with_approved_state_blocks(self):
|
||||
report = self.GOOD_REPORT.replace(
|
||||
"Review decision: none", "Review decision: APPROVED")
|
||||
result = assess_already_landed_report_state(report, gate_fired=True)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_gate_fired_with_merged_state_blocks(self):
|
||||
report = self.GOOD_REPORT.replace(
|
||||
"Merge result: none", "Merge result: MERGED")
|
||||
result = assess_already_landed_report_state(report, gate_fired=True)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_gate_fired_with_ready_to_merge_blocks(self):
|
||||
result = assess_already_landed_report_state(
|
||||
self.GOOD_REPORT + "Current status: READY_TO_MERGE\n",
|
||||
gate_fired=True)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_gate_fired_without_reconcile_state_blocks(self):
|
||||
result = assess_already_landed_report_state(
|
||||
"Current status: review complete.\n", gate_fired=True)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any(
|
||||
"already_landed_reconcile_required" in reason.lower()
|
||||
for reason in result["reasons"]
|
||||
))
|
||||
|
||||
def test_gate_not_fired_reports_pass_untouched(self):
|
||||
result = assess_already_landed_report_state(
|
||||
"Review decision: APPROVED\nMerge result: MERGED\n",
|
||||
gate_fired=False)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
|
||||
class TestFullSuiteFailureApprovalGate(unittest.TestCase):
|
||||
"""Issue #323: full-suite failure blocks approval without baseline proof."""
|
||||
|
||||
ROOT = "/repo/Gitea-Tools"
|
||||
|
||||
def _good_proof(self, **overrides):
|
||||
proof = {
|
||||
"worktree_path": f"{self.ROOT}/branches/baseline-master-pr280",
|
||||
"baseline_target_sha": PINNED,
|
||||
"pr_head_sha": OTHER,
|
||||
"baseline_failures": ["tests/test_foo.py::test_bar"],
|
||||
"pr_failures": ["tests/test_foo.py::test_bar"],
|
||||
"failure_signatures_match": True,
|
||||
"clean_before": True,
|
||||
"clean_after": True,
|
||||
"pr_suite_command": "venv/bin/pytest tests/",
|
||||
"baseline_suite_command": "venv/bin/pytest tests/",
|
||||
"new_tests_passed": True,
|
||||
"unrelated_explanation": (
|
||||
"failures touch agent_temp_artifacts only; PR changes "
|
||||
"review_proofs handoff fields"
|
||||
),
|
||||
}
|
||||
proof.update(overrides)
|
||||
return proof
|
||||
|
||||
def _good_report(self):
|
||||
return (
|
||||
"Full suite failed on PR worktree and on baseline.\n"
|
||||
"- Validation command: venv/bin/pytest tests/\n"
|
||||
f"- Working directory: {self.ROOT}/branches/review-pr-280\n"
|
||||
"Failures are pre-existing on master.\n"
|
||||
)
|
||||
|
||||
def test_full_suite_pass_allows_approval_without_baseline(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="approve",
|
||||
full_suite_passed=True,
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertTrue(result["approve_allowed"])
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["default_action"], "proceed")
|
||||
|
||||
def test_full_suite_failure_defaults_to_request_changes(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="request_changes",
|
||||
full_suite_passed=False,
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["default_action"], "request_changes")
|
||||
|
||||
def test_approval_with_failure_and_no_proof_blocks(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="approve",
|
||||
full_suite_passed=False,
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["default_action"], "request_changes")
|
||||
|
||||
def test_approval_with_matching_baseline_proof_allowed(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="approve",
|
||||
full_suite_passed=False,
|
||||
baseline_proof=self._good_proof(),
|
||||
report_text=self._good_report(),
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertTrue(result["approve_allowed"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_mismatched_failure_signatures_block_approval(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="approve",
|
||||
full_suite_passed=False,
|
||||
baseline_proof=self._good_proof(
|
||||
failure_signatures_match=False,
|
||||
pr_failures=["tests/test_other.py::test_other"],
|
||||
),
|
||||
report_text=self._good_report(),
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_main_checkout_baseline_blocks_approval(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="approve",
|
||||
full_suite_passed=False,
|
||||
baseline_proof=self._good_proof(worktree_path=self.ROOT),
|
||||
report_text=self._good_report(),
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_missing_new_tests_proof_blocks_approval(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="approve",
|
||||
full_suite_passed=False,
|
||||
baseline_proof=self._good_proof(new_tests_passed=None),
|
||||
report_text=self._good_report(),
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertTrue(any(
|
||||
"new" in reason.lower() and "test" in reason.lower()
|
||||
for reason in result["reasons"]
|
||||
))
|
||||
|
||||
def test_missing_unrelated_explanation_blocks_approval(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="approve",
|
||||
full_suite_passed=False,
|
||||
baseline_proof=self._good_proof(unrelated_explanation=""),
|
||||
report_text=self._good_report(),
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
|
||||
def test_missing_suite_commands_block_approval(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="approve",
|
||||
full_suite_passed=False,
|
||||
baseline_proof=self._good_proof(
|
||||
pr_suite_command="", baseline_suite_command=""),
|
||||
report_text=self._good_report(),
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertTrue(any(
|
||||
"command" in reason.lower() for reason in result["reasons"]
|
||||
))
|
||||
|
||||
def test_unknown_suite_result_blocks_approval(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="approve",
|
||||
full_suite_passed=None,
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_vague_same_as_master_claim_without_proof_blocks(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="approve",
|
||||
full_suite_passed=False,
|
||||
report_text="Validation: failures same as master; approving.",
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertFalse(result["approve_allowed"])
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_non_approve_decision_never_blocks(self):
|
||||
result = assess_full_suite_failure_approval_gate(
|
||||
review_decision="comment",
|
||||
full_suite_passed=None,
|
||||
project_root=self.ROOT,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for canonical review-workflow citation and version proof (#296).
|
||||
|
||||
PR review/merge reports must prove they loaded the canonical workflow
|
||||
(workflows/review-merge-pr.md) and cite the workflow version/hash used;
|
||||
a stale or missing hash fails so prompt drift is caught.
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from review_proofs import ( # noqa: E402
|
||||
assess_review_workflow_source,
|
||||
compute_workflow_hash,
|
||||
)
|
||||
|
||||
|
||||
CANONICAL_TEXT = "# review-merge-pr workflow v1\nstep one\n"
|
||||
CANONICAL_HASH = compute_workflow_hash(CANONICAL_TEXT)
|
||||
|
||||
|
||||
def _report(citation=True, version=CANONICAL_HASH):
|
||||
lines = ["Task: review next eligible PR"]
|
||||
if citation:
|
||||
lines.append(
|
||||
"Workflow source: skills/llm-project-workflow/"
|
||||
"workflows/review-merge-pr.md (loaded via mcp_get_skill_guide)"
|
||||
)
|
||||
if version is not None:
|
||||
lines.append(f"- Workflow version: {version}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class TestComputeWorkflowHash(unittest.TestCase):
|
||||
def test_deterministic(self):
|
||||
self.assertEqual(
|
||||
compute_workflow_hash(CANONICAL_TEXT),
|
||||
compute_workflow_hash(CANONICAL_TEXT),
|
||||
)
|
||||
|
||||
def test_changes_with_content(self):
|
||||
self.assertNotEqual(
|
||||
compute_workflow_hash(CANONICAL_TEXT),
|
||||
compute_workflow_hash(CANONICAL_TEXT + "extra rule\n"),
|
||||
)
|
||||
|
||||
def test_short_hex(self):
|
||||
h = compute_workflow_hash(CANONICAL_TEXT)
|
||||
self.assertEqual(len(h), 12)
|
||||
int(h, 16) # raises if not hex
|
||||
|
||||
|
||||
class TestReviewWorkflowSource(unittest.TestCase):
|
||||
def test_citation_and_matching_hash_passes(self):
|
||||
result = assess_review_workflow_source(
|
||||
_report(), canonical_hash=CANONICAL_HASH
|
||||
)
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertEqual(result["reasons"], [])
|
||||
|
||||
def test_missing_citation_fails(self):
|
||||
result = assess_review_workflow_source(
|
||||
_report(citation=False), canonical_hash=CANONICAL_HASH
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("review-merge-pr" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_missing_version_field_fails(self):
|
||||
result = assess_review_workflow_source(
|
||||
_report(version=None), canonical_hash=CANONICAL_HASH
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("workflow version" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_stale_hash_fails(self):
|
||||
result = assess_review_workflow_source(
|
||||
_report(version="deadbeef0000"), canonical_hash=CANONICAL_HASH
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("stale" in r.lower() or "does not match" in r.lower()
|
||||
for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_version_none_fails(self):
|
||||
result = assess_review_workflow_source(
|
||||
_report(version="none"), canonical_hash=CANONICAL_HASH
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
def test_without_canonical_hash_field_still_required(self):
|
||||
# No canonical hash supplied (e.g. offline validation): citation
|
||||
# and a populated version field are still required.
|
||||
self.assertTrue(
|
||||
assess_review_workflow_source(_report())["complete"]
|
||||
)
|
||||
self.assertFalse(
|
||||
assess_review_workflow_source(_report(version=None))["complete"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,81 @@
|
||||
import unittest
|
||||
|
||||
from reviewer_already_landed_classification import ( # noqa: E402
|
||||
ALREADY_LANDED_ELIGIBILITY_CLASS,
|
||||
assess_already_landed_classification_report,
|
||||
)
|
||||
|
||||
|
||||
class TestAlreadyLandedClassification(unittest.TestCase):
|
||||
def test_non_landed_report_passes(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Selected the next eligible PR: PR #286."
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["landed_context"])
|
||||
|
||||
def test_oldest_eligible_pr_on_landed_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"PR is already landed.\n"
|
||||
"Oldest eligible PR: PR #278."
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_next_eligible_pr_on_landed_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Ancestor proof: passed\n"
|
||||
"Selected the next eligible PR: PR #278."
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_eligible_for_review_on_landed_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Already landed on master and eligible for review next."
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_proper_landed_wording_passes(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Oldest open PR requiring action: PR #278\n"
|
||||
f"Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}\n"
|
||||
"Candidate head SHA: abc123\n"
|
||||
"Reviewed head SHA: none\n"
|
||||
"Queue blocked by already-landed PR requiring reconciliation",
|
||||
selected_pr_already_landed=True,
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_pinned_reviewed_head_without_validation_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Already landed.\n"
|
||||
"Pinned reviewed head: abc123def456",
|
||||
selected_pr_already_landed=True,
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_wrong_eligibility_class_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Already landed.\n"
|
||||
"Eligibility class: REVIEW_ELIGIBLE",
|
||||
selected_pr_already_landed=True,
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_missing_queue_blocker_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
f"Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}\n"
|
||||
"Candidate head SHA: abc123\n"
|
||||
"Reviewed head SHA: none",
|
||||
selected_pr_already_landed=True,
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_exported_from_review_proofs(self):
|
||||
from review_proofs import assess_already_landed_classification_report as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for already-landed handoff verifier (#299)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from reviewer_already_landed_handoff import ( # noqa: E402
|
||||
ALREADY_LANDED_STATE,
|
||||
assess_already_landed_handoff_report,
|
||||
)
|
||||
|
||||
|
||||
def _good_report() -> str:
|
||||
return "\n".join([
|
||||
"## PR reconciliation summary",
|
||||
f"Eligibility class: {ALREADY_LANDED_STATE}",
|
||||
"Candidate head SHA: 2a544f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"Reviewed head SHA: none",
|
||||
"Review worktree used: false",
|
||||
"Ancestor proof: PR head is ancestor of prgs/master",
|
||||
"",
|
||||
"## Controller Handoff",
|
||||
"- Selected PR: #278",
|
||||
f"- Eligibility class: {ALREADY_LANDED_STATE}",
|
||||
"- Candidate head SHA: 2a544f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Reviewed head SHA: none",
|
||||
"- Target branch: master",
|
||||
"- Target branch SHA: abc123def4567890abcdef1234567890abcdef12",
|
||||
"- Review worktree used: false",
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- Review decision: none",
|
||||
"- Merge result: none",
|
||||
"- Linked issue status: open",
|
||||
"- Safe next action: reconcile open PR and linked issue",
|
||||
])
|
||||
|
||||
|
||||
class TestAlreadyLandedHandoff(unittest.TestCase):
|
||||
def test_clean_handoff_passes(self):
|
||||
result = assess_already_landed_handoff_report(_good_report())
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
self.assertTrue(result["gate_active"])
|
||||
|
||||
def test_inactive_gate_skips_checks(self):
|
||||
report = "## Controller Handoff\n- Selected PR: #12\n- Review decision: approve"
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["gate_active"])
|
||||
|
||||
def test_rejects_stale_pinned_reviewed_head(self):
|
||||
report = _good_report().replace(
|
||||
"- Candidate head SHA:",
|
||||
"- Pinned reviewed head: 2a544f582026b72a229d59a172c0a63ac4aaeaf9\n"
|
||||
"- Candidate head SHA:",
|
||||
)
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("pinned reviewed head" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_rejects_scratch_worktree_used(self):
|
||||
report = _good_report() + "\n- Scratch worktree used: false"
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("scratch worktree" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_rejects_mutations_none_with_git_fetch(self):
|
||||
report = _good_report().replace(
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- Git ref mutations: none\n- Mutations: None",
|
||||
)
|
||||
result = assess_already_landed_handoff_report(
|
||||
report,
|
||||
command_log=[{"command": "git fetch prgs master", "performed": True}],
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
joined = " ".join(result["reasons"]).lower()
|
||||
self.assertIn("mutations", joined)
|
||||
self.assertIn("git ref", joined)
|
||||
|
||||
def test_rejects_workspace_mutations_none(self):
|
||||
report = _good_report() + "\n- Workspace mutations: None"
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("workspace mutations" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_rejects_reviewed_head_sha_when_gate_fired(self):
|
||||
report = _good_report().replace("- Reviewed head SHA: none", "- Reviewed head SHA: abc123")
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("reviewed head sha" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_rejects_narrative_handoff_eligibility_mismatch(self):
|
||||
report = _good_report().replace(
|
||||
f"Eligibility class: {ALREADY_LANDED_STATE}",
|
||||
"Eligibility class: NORMAL_REVIEW_CANDIDATE",
|
||||
1,
|
||||
)
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("eligibility class" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_session_gate_fired_without_text_marker(self):
|
||||
report = "## Controller Handoff\n- Selected PR: #1\n- Pinned reviewed head: abc"
|
||||
result = assess_already_landed_handoff_report(
|
||||
report,
|
||||
handoff_session={"gate_fired": True},
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["gate_active"])
|
||||
|
||||
def test_infra_stop_style_normal_handoff_not_blocked(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Selected PR: #12",
|
||||
"- Review decision: request_changes",
|
||||
"- Pinned reviewed head: abc123def4567890abcdef1234567890abcdef12",
|
||||
])
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
|
||||
class TestExport(unittest.TestCase):
|
||||
def test_review_proofs_reexport(self):
|
||||
from review_proofs import assess_already_landed_handoff_report as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user