Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d2214b17b |
@@ -1,142 +0,0 @@
|
||||
"""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
|
||||
@@ -1,232 +0,0 @@
|
||||
"""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,8 +13,6 @@ REVIEWER_CAPABILITY_TASKS = frozenset({
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"request_changes_pr",
|
||||
"approve_pr",
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ 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,31 +226,6 @@ 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,25 +169,6 @@ 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
|
||||
@@ -273,80 +254,7 @@ 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. `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:
|
||||
mutations), `status:in-progress`, and claim comments. Full portable wording:
|
||||
[`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||
|
||||
## Global LLM Worktree Rule
|
||||
@@ -841,22 +749,3 @@ 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.
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# 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,10 +17,6 @@
|
||||
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).
|
||||
@@ -35,4 +31,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.
|
||||
+7
-398
@@ -11,8 +11,6 @@ import inspect
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
import issue_lock_provenance
|
||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||
from review_proofs import (
|
||||
HANDOFF_HEADING,
|
||||
assess_controller_handoff,
|
||||
@@ -22,7 +20,6 @@ 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",
|
||||
@@ -90,72 +87,25 @@ _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)|"
|
||||
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",
|
||||
r"eligible for (?:review|merge)|ready for (?:review|merge)|next eligible pr",
|
||||
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",
|
||||
"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",
|
||||
"Linked issue live status",
|
||||
"Safe next action",
|
||||
"No review/merge confirmation",
|
||||
)
|
||||
@@ -240,54 +190,6 @@ 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,
|
||||
@@ -444,130 +346,19 @@ def _rule_reviewer_git_fetch_readonly(
|
||||
"classify git fetch under Git ref mutations, not read-only diagnostics",
|
||||
)
|
||||
]
|
||||
if not git_ref_reported:
|
||||
# Issue #307: an observed git fetch (report text or action log)
|
||||
# must be listed under Git ref mutations.
|
||||
if not git_ref_reported and _GIT_FETCH_RE.search(text):
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.git_fetch_readonly",
|
||||
"downgrade",
|
||||
"Git ref mutations",
|
||||
"git fetch occurred without Git ref mutation classification",
|
||||
"git fetch mentioned 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):
|
||||
@@ -676,34 +467,6 @@ 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():
|
||||
@@ -729,7 +492,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_gate_fired(text):
|
||||
if not _ALREADY_LANDED_RE.search(text):
|
||||
return []
|
||||
if not _ELIGIBLE_REVIEW_RE.search(text):
|
||||
return []
|
||||
@@ -744,65 +507,6 @@ 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
|
||||
|
||||
@@ -837,18 +541,10 @@ def _rule_reconcile_controller_handoff(report_text: str) -> list[dict[str, str]]
|
||||
return []
|
||||
|
||||
|
||||
def _rule_reconcile_stale_author_fields(
|
||||
report_text: str,
|
||||
*,
|
||||
session_pr_opened: bool = False,
|
||||
) -> list[dict[str, str]]:
|
||||
def _rule_reconcile_stale_author_fields(report_text: str) -> 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(
|
||||
@@ -976,54 +672,6 @@ 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,
|
||||
*,
|
||||
@@ -1043,56 +691,27 @@ def _rule_reviewer_review_mutation(
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
|
||||
result = assess_post_merge_cleanup_proof(report_text)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.post_merge_cleanup_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Cleanup status",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "report CLEANUP_SKIPPED with blocker or full cleanup checklist",
|
||||
)
|
||||
|
||||
|
||||
_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_post_merge_cleanup_proof,
|
||||
_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,
|
||||
@@ -1104,31 +723,25 @@ _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,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1190,8 +803,6 @@ 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).
|
||||
|
||||
@@ -1246,8 +857,6 @@ 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, ()):
|
||||
@@ -1266,4 +875,4 @@ def assess_final_report_validator(
|
||||
"safe_next_action": _primary_safe_next_action(findings),
|
||||
"complete": grade == "A",
|
||||
"handoff_heading": HANDOFF_HEADING,
|
||||
}
|
||||
}
|
||||
+120
-2249
File diff suppressed because it is too large
Load Diff
@@ -1,295 +0,0 @@
|
||||
"""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
|
||||
@@ -1,272 +0,0 @@
|
||||
"""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),
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
"""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"
|
||||
],
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
"""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)
|
||||
+5
-26
@@ -30,16 +30,8 @@ def resolve_author_worktree_path(
|
||||
return os.path.realpath(os.path.abspath(path))
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
def read_worktree_git_state(worktree_path: str) -> dict:
|
||||
"""Read branch name and porcelain status from a git worktree."""
|
||||
path = (worktree_path or "").strip()
|
||||
if not path:
|
||||
return {"current_branch": None, "porcelain_status": ""}
|
||||
@@ -71,7 +63,7 @@ def read_worktree_git_state(
|
||||
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, extra_bases)
|
||||
base_branch, base_sha = _find_matching_base_ref(path, head_sha)
|
||||
return {
|
||||
"current_branch": current_branch,
|
||||
"porcelain_status": status_res.stdout or "",
|
||||
@@ -211,26 +203,13 @@ def _assessment(
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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."""
|
||||
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],
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
"""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": [],
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
+14
-10
@@ -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,18 +37,22 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
|
||||
|
||||
|
||||
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
|
||||
if path:
|
||||
return issue_lock_store.read_lock_file(path.strip())
|
||||
return issue_lock_store.read_session_issue_lock()
|
||||
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
|
||||
|
||||
|
||||
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
|
||||
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)
|
||||
lock = read_issue_lock(lock_path)
|
||||
if not lock:
|
||||
return False
|
||||
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
|
||||
|
||||
|
||||
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
|
||||
|
||||
@@ -31,8 +31,6 @@ 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:
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
"""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"
|
||||
)
|
||||
),
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
|
||||
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
|
||||
|
||||
_CLEANUP_SECTION_HINT = re.compile(
|
||||
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
|
||||
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
|
||||
_CLEANUP_BLOCKER_RE = re.compile(
|
||||
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REMOTE_DELETE_CLAIM_RE = re.compile(
|
||||
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
|
||||
r"deleted remote branch|delete_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
|
||||
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
|
||||
r"worktree cleanup performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
|
||||
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
|
||||
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_TASK_RE = re.compile(
|
||||
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"merge result\s*:\s*(?:merged|success|performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_HEAD_BRANCH_RE = re.compile(
|
||||
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCH_NOT_PROTECTED_RE = re.compile(
|
||||
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OPEN_PR_INVENTORY_RE = re.compile(
|
||||
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
|
||||
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
|
||||
r"no other open pr references branch",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACTIVE_CLAIM_LEASE_RE = re.compile(
|
||||
r"(?:no active (?:heartbeat|claim|lease)|"
|
||||
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SESSION_OWNED_WORKTREE_RE = re.compile(
|
||||
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
|
||||
_CLEAN_TRACKED_RE = re.compile(
|
||||
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEAN_UNTRACKED_RE = re.compile(
|
||||
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_LIST_AFTER_RE = re.compile(
|
||||
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WRONG_BRANCH_RE = re.compile(
|
||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _claims_remote_delete(text: str) -> bool:
|
||||
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _claims_worktree_remove(text: str) -> bool:
|
||||
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _branch_safety_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
missing.append("delete-branch capability resolved (gitea.branch.delete)")
|
||||
if not _DELETE_TASK_RE.search(text):
|
||||
missing.append("delete-branch task named (delete_branch or cleanup)")
|
||||
if not _MERGE_RESULT_RE.search(text):
|
||||
missing.append("merge result: merged")
|
||||
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||
missing.append("merge commit SHA")
|
||||
if not _PR_HEAD_BRANCH_RE.search(text):
|
||||
missing.append("merged PR head branch / deleted branch name")
|
||||
if not _BRANCH_NOT_PROTECTED_RE.search(text):
|
||||
missing.append("branch not protected proof")
|
||||
if not _OPEN_PR_INVENTORY_RE.search(text):
|
||||
missing.append("open PR inventory proof (no other PR references branch)")
|
||||
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
|
||||
missing.append("no active heartbeat/claim/lease proof")
|
||||
return missing
|
||||
|
||||
|
||||
def _worktree_cleanup_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
match = _SESSION_OWNED_WORKTREE_RE.search(text)
|
||||
path = match.group(1).strip() if match else ""
|
||||
if not path:
|
||||
missing.append("session-owned worktree path")
|
||||
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
|
||||
missing.append("worktree path under branches/")
|
||||
if not _CLEAN_TRACKED_RE.search(text):
|
||||
missing.append("pre-removal tracked state: clean")
|
||||
if not _CLEAN_UNTRACKED_RE.search(text):
|
||||
missing.append("pre-removal untracked state: clean")
|
||||
if not _WORKTREE_LIST_AFTER_RE.search(text):
|
||||
missing.append("git worktree list after removal")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_post_merge_cleanup_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
cleanup_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
|
||||
text = report_text or ""
|
||||
session = dict(cleanup_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
|
||||
blocker = (session.get("blocker") or "").strip()
|
||||
if not blocker:
|
||||
match = _CLEANUP_BLOCKER_RE.search(text)
|
||||
blocker = match.group(1).strip() if match else ""
|
||||
if blocker.upper() == CLEANUP_SKIPPED:
|
||||
blocker = ""
|
||||
if not blocker:
|
||||
reasons.append(
|
||||
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"proven": not reasons,
|
||||
"outcome": CLEANUP_SKIPPED,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
|
||||
return {
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"outcome": None,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
remote_delete = bool(
|
||||
session.get("remote_delete_claimed") or _claims_remote_delete(text)
|
||||
)
|
||||
worktree_remove = bool(
|
||||
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
|
||||
)
|
||||
|
||||
if _WRONG_BRANCH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup report claims deleted branch that is not the merged PR head branch"
|
||||
)
|
||||
|
||||
if remote_delete:
|
||||
reasons.extend(
|
||||
f"remote branch deletion missing {field}"
|
||||
for field in _branch_safety_fields_present(text)
|
||||
)
|
||||
|
||||
if worktree_remove:
|
||||
reasons.extend(
|
||||
f"worktree removal missing {field}"
|
||||
for field in _worktree_cleanup_fields_present(text)
|
||||
)
|
||||
|
||||
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
|
||||
pass
|
||||
|
||||
if not remote_delete and not worktree_remove:
|
||||
cleanup_mutations = re.search(
|
||||
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if cleanup_mutations:
|
||||
reasons.append(
|
||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||
)
|
||||
|
||||
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
|
||||
if remote_delete or worktree_remove:
|
||||
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"proven": not block,
|
||||
"outcome": outcome,
|
||||
"remote_delete_claimed": remote_delete,
|
||||
"worktree_remove_claimed": worktree_remove,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
|
||||
"checklist before claiming remote delete or worktree removal"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
"""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),
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,332 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
+107
-1466
File diff suppressed because it is too large
Load Diff
@@ -1,143 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
"""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,8 +57,6 @@ REVIEWER_TASKS = frozenset({
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"request_changes_pr",
|
||||
"approve_pr",
|
||||
})
|
||||
@@ -74,15 +72,6 @@ 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 = {
|
||||
@@ -99,30 +88,14 @@ 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
|
||||
|
||||
|
||||
@@ -218,26 +191,6 @@ 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 = (
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/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 "$@"
|
||||
+5
-11
@@ -38,21 +38,13 @@ 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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -76,6 +68,8 @@ 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,7 +22,6 @@ 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
|
||||
|
||||
@@ -51,10 +50,6 @@ 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.
|
||||
|
||||
@@ -161,7 +156,6 @@ 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)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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,23 +91,4 @@ 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.
|
||||
|
||||
### 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.
|
||||
terminal review mutation, merge result, and linked issue status.
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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.
|
||||
```
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
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,28 +248,6 @@ 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,58 +538,6 @@ 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.
|
||||
@@ -626,28 +574,6 @@ 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.
|
||||
@@ -776,44 +702,6 @@ 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:
|
||||
@@ -824,7 +712,6 @@ 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
|
||||
@@ -841,7 +728,6 @@ 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
|
||||
@@ -881,40 +767,6 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc
|
||||
|
||||
If any local artifact is created after final cleanup, run and report a new final status check.
|
||||
|
||||
## 28A. Post-merge cleanup proof checklist (#402)
|
||||
|
||||
Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed.
|
||||
|
||||
### Remote branch deletion checklist
|
||||
|
||||
When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report:
|
||||
|
||||
* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call
|
||||
* Merge result: merged
|
||||
* Merge commit SHA: full 40-character SHA
|
||||
* Merged PR head branch / deleted branch: exact branch name (must match)
|
||||
* Branch protection: none / branch is not protected
|
||||
* Open PR inventory proof: no other open PR references the branch
|
||||
* Active heartbeat/claim/lease: none
|
||||
|
||||
### Local worktree removal checklist
|
||||
|
||||
When removing session-owned review/simulation worktrees under `branches/`, report:
|
||||
|
||||
* Session-owned worktree path: exact path under `branches/`
|
||||
* Pre-removal tracked state: clean
|
||||
* Pre-removal untracked state: clean
|
||||
* Git worktree list after removal: command output or equivalent proof
|
||||
|
||||
### Skipped cleanup
|
||||
|
||||
If any gate fails, report:
|
||||
|
||||
* Cleanup outcome: `CLEANUP_SKIPPED`
|
||||
* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`)
|
||||
|
||||
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
|
||||
|
||||
## 29. Recovery handoff rules
|
||||
|
||||
If blocked, produce a recovery handoff with:
|
||||
@@ -1244,8 +1096,6 @@ 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,20 +83,6 @@ 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:
|
||||
@@ -144,14 +130,6 @@ 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.
|
||||
@@ -305,36 +283,6 @@ 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.
|
||||
@@ -615,34 +563,6 @@ 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
|
||||
* explicit `remote`, `org`, and `repo` when not using defaults
|
||||
3. If assessment returns `assessment_failed: true` or `pr_lookup: failed`, stop
|
||||
and produce a recovery handoff with the structured `reasons` and
|
||||
`resolved_repo` fields — do not treat an MCP HTTP 500 as proof the push was
|
||||
unsafe or safe (#519).
|
||||
4. Do not push when a reviewer holds an active lease on the same PR.
|
||||
5. Do not force-push.
|
||||
6. Do not push from the main checkout or wrong cwd.
|
||||
|
||||
Conflict-fix final reports must state:
|
||||
|
||||
* branch head before push
|
||||
* branch head after push
|
||||
* active reviewer lease status
|
||||
* whether push was fast-forward
|
||||
* whether any reviewer was active
|
||||
|
||||
## 21. PR creation rules
|
||||
|
||||
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
|
||||
@@ -783,12 +703,6 @@ 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`.
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
"""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,14 +72,6 @@ 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",
|
||||
@@ -104,48 +96,6 @@ 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,42 +62,16 @@ 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, *_mocks):
|
||||
def test_lock_success_includes_artifact_warning(self, mock_state, _lock_file, *_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,
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,151 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,139 +0,0 @@
|
||||
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
||||
|
||||
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
|
||||
_fetch_pr_lease_comments_safe,
|
||||
gitea_assess_conflict_fix_push,
|
||||
)
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
AUTHOR_ENV = {
|
||||
"GITEA_PROFILE_NAME": "prgs-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.branch.push,gitea.pr.create",
|
||||
}
|
||||
|
||||
HEAD_BEFORE = "dad1dc8d5108ab01ed83065334116d7425a4471c"
|
||||
HEAD_AFTER = "3f3d6cb35d0fe225dcb236247f2a2d0ec193fa35"
|
||||
OPEN_PR = {
|
||||
"number": 508,
|
||||
"state": "open",
|
||||
"head": {"sha": HEAD_AFTER},
|
||||
}
|
||||
|
||||
|
||||
class TestFetchPrLeaseCommentsSafe(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_pr_lookup_404_returns_structured_failure(self, _auth, mock_api):
|
||||
mock_api.side_effect = RuntimeError(
|
||||
'HTTP 404: {"message":"issue does not exist","index":508}'
|
||||
)
|
||||
result = _fetch_pr_lease_comments_safe(
|
||||
508, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["comments"], [])
|
||||
self.assertTrue(result["reasons"])
|
||||
self.assertIn("PR lookup failed", result["reasons"][0])
|
||||
self.assertEqual(result["pr_lookup"], "failed")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_comment_fetch_404_after_pr_lookup(self, _auth, mock_api):
|
||||
def _api(method, url, auth, *args, **kwargs):
|
||||
if "/pulls/" in url:
|
||||
return OPEN_PR
|
||||
raise RuntimeError(
|
||||
'HTTP 404: {"message":"issue does not exist","index":508}'
|
||||
)
|
||||
|
||||
mock_api.side_effect = _api
|
||||
result = _fetch_pr_lease_comments_safe(
|
||||
508, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("comment fetch failed", result["reasons"][0].lower())
|
||||
self.assertEqual(result["pr_lookup"], "ok")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_success_returns_comments_and_head_sha(self, _auth, mock_api):
|
||||
comment = {"id": 1, "body": "<!-- mcp-conflict-fix-lease:v1 -->"}
|
||||
|
||||
def _api(method, url, auth, *args, **kwargs):
|
||||
if "/pulls/" in url:
|
||||
return OPEN_PR
|
||||
return [comment]
|
||||
|
||||
mock_api.side_effect = _api
|
||||
result = _fetch_pr_lease_comments_safe(
|
||||
508, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["comments"], [comment])
|
||||
self.assertEqual(result["head_sha"], OPEN_PR["head"]["sha"])
|
||||
|
||||
|
||||
class TestAssessConflictFixPushTool(unittest.TestCase):
|
||||
@patch("mcp_server._fetch_pr_lease_comments_safe")
|
||||
@patch("mcp_server._profile_operation_gate", return_value=None)
|
||||
def test_returns_structured_block_when_pr_lookup_fails(
|
||||
self, _gate, mock_fetch,
|
||||
):
|
||||
mock_fetch.return_value = {
|
||||
"success": False,
|
||||
"comments": [],
|
||||
"reasons": ["PR lookup failed"],
|
||||
"pr_lookup": "failed",
|
||||
"resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"remote": "prgs",
|
||||
"pr_number": 508,
|
||||
}
|
||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||
result = gitea_assess_conflict_fix_push(
|
||||
pr_number=508,
|
||||
branch_head_before=HEAD_BEFORE,
|
||||
branch_head_after=HEAD_AFTER,
|
||||
worktree_path="/proj/branches/fix-pr508",
|
||||
push_cwd="/proj/branches/fix-pr508",
|
||||
is_fast_forward=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(result["push_allowed"])
|
||||
self.assertTrue(result.get("assessment_failed"))
|
||||
self.assertEqual(result["pr_lookup"], "failed")
|
||||
|
||||
@patch("mcp_server._fetch_pr_lease_comments_safe")
|
||||
@patch("mcp_server._profile_operation_gate", return_value=None)
|
||||
def test_valid_push_assessment_when_pr_and_comments_resolve(
|
||||
self, _gate, mock_fetch,
|
||||
):
|
||||
mock_fetch.return_value = {
|
||||
"success": True,
|
||||
"comments": [],
|
||||
"reasons": [],
|
||||
"pr_lookup": "ok",
|
||||
"resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"remote": "prgs",
|
||||
"pr_number": 508,
|
||||
"head_sha": HEAD_AFTER,
|
||||
}
|
||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||
result = gitea_assess_conflict_fix_push(
|
||||
pr_number=508,
|
||||
branch_head_before=HEAD_BEFORE,
|
||||
branch_head_after=HEAD_AFTER,
|
||||
worktree_path="/proj/branches/fix-pr508",
|
||||
push_cwd="/proj/branches/fix-pr508",
|
||||
is_fast_forward=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(result["push_allowed"])
|
||||
self.assertEqual(result.get("live_pr_head_sha"), HEAD_AFTER)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+7
-46
@@ -284,40 +284,8 @@ 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}
|
||||
@@ -330,8 +298,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
{"login": "merger-bot"}, self._pr("author-bot"),
|
||||
self._pr("author-bot"),
|
||||
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||
"commit_id": "abc123", "submitted_at": "2026-07-06T10:00:00Z",
|
||||
"dismissed": False}],
|
||||
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
||||
{}, {"merged_commit_sha": "c1"},
|
||||
]
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
@@ -367,9 +334,7 @@ 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",
|
||||
@@ -378,16 +343,12 @@ 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 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,
|
||||
)
|
||||
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)
|
||||
self.assertTrue(r["performed"])
|
||||
recs = self._records()
|
||||
self.assertEqual(len(recs), 1)
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
"""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,14 +76,7 @@ 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,30 +55,10 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
|
||||
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_dir = tempfile.TemporaryDirectory()
|
||||
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
|
||||
|
||||
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_file_path = "/tmp/gitea_issue_lock.json"
|
||||
self.lock_data = {
|
||||
"issue_number": 263,
|
||||
"branch_name": "feat/issue-263-native-commit-payloads",
|
||||
@@ -86,13 +66,9 @@ 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"),
|
||||
),
|
||||
}
|
||||
self.lock_file_path = issue_lock_store.bind_session_lock(self.lock_data)
|
||||
with open(self.lock_file_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(self.lock_data))
|
||||
|
||||
# Reset preflight status to bypass/pass verification in tests
|
||||
self.orig_whoami_called = mcp_server._preflight_whoami_called
|
||||
@@ -100,14 +76,7 @@ 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()
|
||||
|
||||
@@ -116,7 +85,8 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
|
||||
self._dir.cleanup()
|
||||
self.locked_worktree_dir.cleanup()
|
||||
self._lock_dir.cleanup()
|
||||
if os.path.exists(self.lock_file_path):
|
||||
os.remove(self.lock_file_path)
|
||||
|
||||
def _env(self, profile: str) -> dict:
|
||||
return {
|
||||
@@ -124,8 +94,6 @@ 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")
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
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()
|
||||
@@ -1,217 +0,0 @@
|
||||
"""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,15 +25,11 @@ 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",
|
||||
@@ -41,10 +37,6 @@ 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)",
|
||||
@@ -61,47 +53,26 @@ def _review_handoff(**overrides):
|
||||
return text
|
||||
|
||||
|
||||
def _reconcile_handoff(drop=(), **overrides):
|
||||
def _reconcile_handoff(**overrides):
|
||||
lines = [
|
||||
"## Controller Handoff",
|
||||
"",
|
||||
"- Task: reconcile already-landed PR #99",
|
||||
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"- Role/profile: reconciler / prgs-reconciler",
|
||||
"- Identity: sysadmin",
|
||||
"- Role: reconciler",
|
||||
"- Identity: sysadmin / prgs-author",
|
||||
"- Selected PR: #99",
|
||||
"- 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",
|
||||
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"- 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",
|
||||
"- Blocker: missing gitea.pr.close capability",
|
||||
"- Safe next action: hand off to a profile with gitea.pr.close",
|
||||
"- Safe next action: none",
|
||||
"- No review/merge confirmation: confirmed",
|
||||
]
|
||||
kept = [
|
||||
line
|
||||
for line in lines
|
||||
if not any(line.startswith(f"- {name}:") for name in drop)
|
||||
]
|
||||
text = "\n".join(kept)
|
||||
text = "\n".join(lines)
|
||||
for key, value in overrides.items():
|
||||
if f"- {key}:" in text:
|
||||
text = text.replace(f"- {key}:", f"- {key}: {value}")
|
||||
@@ -219,76 +190,11 @@ 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")
|
||||
.replace(
|
||||
"- Read-only diagnostics: issue and PR metadata inspected",
|
||||
"- Read-only diagnostics: git fetch prgs master",
|
||||
)
|
||||
+ "\n- Read-only diagnostics: git fetch prgs master"
|
||||
)
|
||||
result = assess_final_report_validator(
|
||||
report,
|
||||
@@ -345,159 +251,6 @@ 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")
|
||||
@@ -517,4 +270,4 @@ class TestEntryPoint(unittest.TestCase):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
@@ -1,116 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,227 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,130 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,280 +0,0 @@
|
||||
"""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,59 +72,6 @@ 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(
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
"""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,8 +172,6 @@ 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,19 +122,15 @@ 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):
|
||||
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_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": "abc1234"}, "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": head_sha}, "mergeable": True}, # /pulls/9
|
||||
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9
|
||||
]
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]):
|
||||
_seed_ready_review_decision(9, "approve", sha=head_sha, remote="prgs")
|
||||
gitea_mark_final_review_decision(9, "approve", remote="prgs")
|
||||
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_review_pr(
|
||||
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
|
||||
@@ -27,7 +26,6 @@ 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
|
||||
|
||||
@@ -38,7 +36,6 @@ 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()
|
||||
|
||||
|
||||
@@ -81,12 +78,6 @@ 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():
|
||||
@@ -100,19 +91,6 @@ 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():
|
||||
@@ -121,7 +99,6 @@ 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
|
||||
|
||||
@@ -141,30 +118,6 @@ 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
|
||||
|
||||
@@ -177,48 +130,12 @@ 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
|
||||
|
||||
@@ -229,82 +146,3 @@ 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
|
||||
|
||||
+141
-672
File diff suppressed because it is too large
Load Diff
@@ -1,59 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,121 +0,0 @@
|
||||
"""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,9 +230,7 @@ class TestEligibilityDenialReport(PermissionReportBase):
|
||||
return PR_PAYLOAD
|
||||
mock_api.side_effect = fake_api
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
from tests.test_mcp_server import _seed_ready_review_decision
|
||||
|
||||
_seed_ready_review_decision(42, "approve", remote="prgs")
|
||||
mcp_server.gitea_mark_final_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",
|
||||
@@ -274,9 +272,7 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase):
|
||||
return PR_PAYLOAD
|
||||
mock_api.side_effect = fake_api
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
from tests.test_mcp_server import _seed_ready_review_decision
|
||||
|
||||
_seed_ready_review_decision(42, "comment", remote="prgs")
|
||||
mcp_server.gitea_mark_final_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",
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
"""Tests for post-merge cleanup proof enforcement (#402)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
from post_merge_cleanup_proof import ( # noqa: E402
|
||||
CLEANUP_SKIPPED,
|
||||
assess_post_merge_cleanup_proof,
|
||||
)
|
||||
|
||||
MERGE_SHA = "a" * 40
|
||||
HEAD_BRANCH = "feat/issue-274-branches-only-worktrees"
|
||||
WORKTREE = "branches/review-pr374-conflicts"
|
||||
|
||||
|
||||
def _full_remote_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Task": "review PR #374",
|
||||
"Merge result": "merged",
|
||||
"Merge commit SHA": MERGE_SHA,
|
||||
"Cleanup status": "remote branch deleted",
|
||||
"Delete-branch capability resolved": "gitea.branch.delete allowed via delete_branch task",
|
||||
"Merged PR head branch": HEAD_BRANCH,
|
||||
"Deleted branch": HEAD_BRANCH,
|
||||
"Branch protection": "none",
|
||||
"Open PR inventory proof": "no other open PR references branch (inventory complete)",
|
||||
"Active heartbeat/claim/lease": "none",
|
||||
"Cleanup mutations": "gitea_delete_branch on remote head branch",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _full_worktree_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Task": "review PR #374",
|
||||
"Merge result": "merged",
|
||||
"Cleanup status": "local worktree removed",
|
||||
"Removed worktree path": WORKTREE,
|
||||
"Pre-removal tracked state": "clean",
|
||||
"Pre-removal untracked state": "clean",
|
||||
"Git worktree list after removal": "only main checkout listed",
|
||||
"Cleanup mutations": "git worktree remove on session-owned review worktree",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TestPostMergeCleanupProof(unittest.TestCase):
|
||||
def test_no_cleanup_claim_passes(self):
|
||||
report = "## Controller Handoff\n- Task: review PR #1\n- Merge result: merged"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_missing_capability_proof_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Delete-branch capability resolved": "delete succeeded"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("capability" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_unmerged_pr_blocked(self):
|
||||
report = _full_remote_cleanup_report(**{"Merge result": "not merged"})
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("merge result" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_wrong_branch_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Deleted branch": "feat/other-branch"}
|
||||
)
|
||||
report += "\nDeleted branch does not match merged PR head branch"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_protected_branch_blocked(self):
|
||||
report = _full_remote_cleanup_report(**{"Branch protection": "enabled"})
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_open_pr_reference_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Open PR inventory proof": "PR #999 still references branch"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_active_claim_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Active heartbeat/claim/lease": "status:in-progress on linked issue"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_dirty_worktree_blocked(self):
|
||||
report = _full_worktree_cleanup_report(
|
||||
**{"Pre-removal tracked state": "dirty"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("tracked" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_foreign_worktree_blocked(self):
|
||||
report = _full_worktree_cleanup_report(
|
||||
**{"Removed worktree path": "/tmp/foreign-worktree"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("branches/" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_cleanup_skipped_with_blocker_passes(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
||||
"- Cleanup blocker: gitea.branch.delete capability not resolved in active profile",
|
||||
])
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["outcome"], CLEANUP_SKIPPED)
|
||||
|
||||
def test_cleanup_skipped_without_blocker_blocked(self):
|
||||
report = "## Controller Handoff\n- Cleanup outcome: CLEANUP_SKIPPED"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_full_remote_cleanup_passes(self):
|
||||
result = assess_post_merge_cleanup_proof(_full_remote_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["remote_delete_claimed"])
|
||||
|
||||
def test_full_worktree_cleanup_passes(self):
|
||||
result = assess_post_merge_cleanup_proof(_full_worktree_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["worktree_remove_claimed"])
|
||||
|
||||
def test_validator_integration_blocks_unproven_delete(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Cleanup mutations: gitea_delete_branch deleted remote branch\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.post_merge_cleanup_proof", rule_ids)
|
||||
|
||||
def test_validator_integration_allows_skipped(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
||||
"- Cleanup blocker: branch still referenced by open PR #414",
|
||||
])
|
||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
||||
cleanup_findings = [
|
||||
f for f in result["findings"]
|
||||
if f["rule_id"] == "reviewer.post_merge_cleanup_proof"
|
||||
]
|
||||
self.assertEqual(cleanup_findings, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,86 +0,0 @@
|
||||
"""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",
|
||||
}
|
||||
OPEN_PR = {"number": 12, "state": "open", "head": {"sha": "abc123"}}
|
||||
|
||||
|
||||
def _pr_then_comments(mock_api, comments_payload):
|
||||
def _api(method, url, auth, *args, **kwargs):
|
||||
if "/pulls/" in url:
|
||||
return OPEN_PR
|
||||
return comments_payload
|
||||
|
||||
mock_api.side_effect = _api
|
||||
|
||||
|
||||
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):
|
||||
_pr_then_comments(mock_api, {"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):
|
||||
_pr_then_comments(mock_api, 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 -->"}
|
||||
_pr_then_comments(mock_api, [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()
|
||||
@@ -1,269 +0,0 @@
|
||||
"""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,29 +128,18 @@ 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": head_sha},
|
||||
"base": {"ref": "master"}, "mergeable": True,
|
||||
"user": {"login": "other_user"}}
|
||||
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "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": head_sha},
|
||||
"head": {"sha": "abc1"},
|
||||
"mergeable": True,
|
||||
},
|
||||
{"id": 100, "state": "APPROVED"},
|
||||
@@ -159,27 +148,17 @@ class TestPRQueueInventory(unittest.TestCase):
|
||||
"id": 100,
|
||||
"user": {"login": "reviewer1"},
|
||||
"state": "APPROVED",
|
||||
"commit_id": head_sha,
|
||||
"commit_id": "abc1",
|
||||
"submitted_at": "2026-07-06T10:00:00Z",
|
||||
"dismissed": False,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
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,
|
||||
)
|
||||
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)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("=== PR Queue Inventory ===", result["message"])
|
||||
self.assertIn("Repository:", result["message"])
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,106 +0,0 @@
|
||||
"""#469: capability preflight survives interleaved read-only whoami 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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,149 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,188 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,89 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,87 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,162 +0,0 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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()
|
||||
@@ -1,106 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,149 +0,0 @@
|
||||
"""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,32 +121,6 @@ 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):
|
||||
@@ -301,40 +275,6 @@ 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,22 +115,6 @@ 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)
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"""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"])
|
||||
@@ -1,121 +0,0 @@
|
||||
"""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()
|
||||
+3
-585
@@ -23,13 +23,7 @@ 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,
|
||||
@@ -40,9 +34,6 @@ 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,
|
||||
@@ -2323,155 +2314,10 @@ class TestCreateIssueFinalReport(unittest.TestCase):
|
||||
self.assertTrue(result["downgraded"])
|
||||
|
||||
|
||||
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"])
|
||||
|
||||
class TestQueueOrderingProof(unittest.TestCase):
|
||||
"""Issue #321: reviewer queue selection must prove ordering."""
|
||||
|
||||
def test_next_eligible_claim_without_policy_fails(self):
|
||||
result = assess_queue_ordering_proof(
|
||||
report_text="Selected the next eligible PR: PR #286.",
|
||||
)
|
||||
@@ -2693,433 +2539,5 @@ 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()
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"""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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user